Files
Codeman/test/opencode-resize.test.ts
T
Codeman maintainer 02c65e988a fix(ci): #540 landing fixes
Move the nightly cron from 03:17 to 03:23 UTC. GitHub sends scheduled-run
failure notices to whoever last modified the cron line, and after the merge
that is the contributor, so a maintainer commit has to touch it. The
docs below give no clock time, so they cannot drift from the cron.

Drop the "Keep the failure artifacts" step and the blank line before it.
No browser test writes test-results/ or screenshots-echo-diag/ (only the
ignore files name them), and if-no-files-found: ignore made the step upload
nothing without a word. The run log already carries the failure output.

Reword the workflow header. Drop the claim that the skipped suite let two
semantically conflicting PRs merge green: that incident came from
test/mobile/keyboard.test.ts, which this job does not run. Correct the
codex-predictive-echo note: the test uses a fake key in a throwaway
CODEX_HOME and skips itself when codex is missing, so it needs a codex
binary, not an authenticated one.

opencode-resize: record WebSocket resize frames under the socket's own URL
instead of appending '#' + the session id. The URL already carries
/ws/sessions/<id>/terminal, and the suffix let toContain(sessionId) pass for
a resize sent on any session's socket, the bug this test exists to catch.

Reduce the six session-id extractions (opencode-resize and perf-browser) to
data.data?.session?.id. POST /api/sessions always answers in the
{ success, data: { session } } envelope, and the dead fallbacks are what
hid the original breakage.

split-pane-terminal: restore the browser config's 60 s test timeout (the
added 20000 ms override tightened it), and replace the comment that blamed
Codeman's post-create clear. Under vitest the session is an echo PTY, so
that clear comes back as text; the real fix is useMux:false, since a plain
prompt otherwise goes through tmux send-keys, which test mode no-ops.

CLAUDE.md: the CI note now says the gate excludes the Playwright tests in
BROWSER_TEST_GLOBS instead of a stale count of 14, and names
browser-suite.yml; the Testing warning says the browser suite runs nightly.
CONTRIBUTING.md gets the same one-line pointer under Tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 05:13:06 +02:00

404 lines
15 KiB
TypeScript

/**
* OpenCode session UI tests
*
* Tests OpenCode-specific UI behavior:
* - Initial terminal resize (not stuck at 120x40)
* - Close modal shows "Kill Tmux & OpenCode" (not "Claude Code")
* - needsRefresh handler sends resize
*
* Port: 3211 (opencode UI tests)
*
* Run: npx vitest run test/opencode-resize.test.ts
*/
import { execSync } from 'node:child_process';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3211;
const BASE_URL = `http://localhost:${PORT}`;
const HAS_OPENCODE = (() => {
try {
execSync('command -v opencode', { stdio: 'ignore', shell: '/bin/bash' });
return true;
} catch {
return false;
}
})();
let server: WebServer;
let browser: Browser;
async function freshPage(): Promise<{ context: BrowserContext; page: Page }> {
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
});
const page = await context.newPage();
return { context, page };
}
async function navigateAndWait(page: Page): Promise<void> {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), {
timeout: 5000,
});
}
beforeAll(async () => {
server = new WebServer(PORT, false, true); // testMode
await server.start();
browser = await chromium.launch({ headless: true });
}, 30_000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 30_000);
describe('OpenCode session initial resize', () => {
let context: BrowserContext;
let page: Page;
afterAll(async () => {
await context?.close();
});
it('selectSession is not bypassed when the shared launcher sets activeSessionId', async () => {
// This test verifies at the code level that the OpenCode launch path does
// NOT pre-set activeSessionId before calling selectSession. If it did,
// selectSession would early-return and skip sendResize.
//
// PR B2 consolidated runOpenCode() (and 7 siblings) into one shared
// _runCliMode(mode) — runOpenCode is now a one-line wrapper
// (`return this._runCliMode('opencode')`), so inspecting ITS source would
// never see the real launch logic and this check would pass vacuously
// regardless of what _runCliMode actually does. Inspect _runCliMode itself.
({ context, page } = await freshPage());
await navigateAndWait(page);
const { selectIdx, assignIdx } = await page.evaluate(() => {
const app = (window as unknown as { app: { _runCliMode: { toString: () => string } } }).app;
const source = app._runCliMode.toString();
// The launcher hands the FIRST created session to selectSession
// (`_launchQuickStartInstances()` returns `firstSessionId`). An earlier
// version of this check looked for `this.selectSession(data.sessionId)`,
// a string that exists nowhere in session-ui.js, so both lookups came
// back -1 and the assertion could never fail. Hence the anti-vacuity
// check below: the select call itself must be found.
const selectIdx = source.indexOf('this.selectSession(firstSessionId)');
// ANY assignment to activeSessionId (whatever the right-hand side is
// called), not `==`/`===` comparisons and not the comment that mentions
// pre-setting it without a `this.` prefix.
const assign = /this\.activeSessionId\s*=(?!=)/.exec(source);
return { selectIdx, assignIdx: assign ? assign.index : -1 };
});
// Anti-vacuity: if the select call is renamed again, fail here rather
// than pass on two -1s.
expect(selectIdx).toBeGreaterThan(-1);
// Correct: no assignment at all. Bug: an assignment that lands BEFORE
// selectSession runs, which makes selectSession early-return.
expect(
assignIdx === -1 || assignIdx > selectIdx,
`activeSessionId is assigned at ${assignIdx}, before selectSession at ${selectIdx}`
).toBe(true);
});
it('sends resize to server after creating a session via quick-start', async () => {
({ context, page } = await freshPage());
await navigateAndWait(page);
// Intercept resize API calls to track when they happen
const resizeCalls: Array<{ url: string; cols: number; rows: number }> = [];
// While the WebSocket is connected, resizes go out as {t:'z',c,r} frames
// instead of POST /resize, so record both transports.
page.on('websocket', (ws) => {
ws.on('framesent', (frame) => {
try {
const msg = JSON.parse(String(frame.payload));
if (msg.t === 'z') resizeCalls.push({ url: ws.url(), cols: msg.c, rows: msg.r });
} catch {
/* not JSON */
}
});
});
await page.route('**/api/sessions/*/resize', async (route) => {
const request = route.request();
const body = request.postDataJSON();
resizeCalls.push({
url: request.url(),
cols: body.cols,
rows: body.rows,
});
// Let the request through to the server
await route.continue();
});
// Create a session via API (simulating what quick-start does)
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-resize-test' }),
});
const data = await res.json();
// POST /api/sessions answers in the { success, data: { session } } envelope.
return data.data?.session?.id;
});
expect(sessionId).toBeTruthy();
// Call selectSession (which is what runOpenCode does after fix)
await page.evaluate(async (sid: string) => {
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
await app.selectSession(sid);
}, sessionId);
// Wait for the resize to be sent (it's fire-and-forget in selectSession)
await page.waitForTimeout(500);
// Verify resize was called with reasonable dimensions (not 120x40 default)
expect(resizeCalls.length).toBeGreaterThanOrEqual(1);
const lastResize = resizeCalls[resizeCalls.length - 1];
expect(lastResize.url).toContain(sessionId);
// Browser viewport is 1280x800 — terminal cols/rows should be substantially
// different from the hardcoded 120x40 default. xterm.js calculates these
// from container dimensions and cell size, but in headless mode with a
// 1280x800 viewport, we should get something reasonable (>= 40 cols).
expect(lastResize.cols).toBeGreaterThanOrEqual(40);
expect(lastResize.rows).toBeGreaterThanOrEqual(10);
console.log(`[opencode-resize] resize sent: ${lastResize.cols}x${lastResize.rows}`);
// Cleanup
await page.evaluate(async (sid: string) => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, sessionId);
});
it('selectSession does NOT early-return for a new session', async () => {
({ context, page } = await freshPage());
await navigateAndWait(page);
// Create a session
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-earlyret-test' }),
});
const data = await res.json();
// POST /api/sessions answers in the { success, data: { session } } envelope.
return data.data?.session?.id;
});
expect(sessionId).toBeTruthy();
// Verify activeSessionId is NOT the new session before selectSession
const activeBeforeSelect = await page.evaluate(() => {
const app = (window as unknown as { app: { activeSessionId: string | null } }).app;
return app.activeSessionId;
});
// activeSessionId should be null or empty (welcome screen) — not our session
expect(activeBeforeSelect).not.toBe(sessionId);
// Now call selectSession and verify it actually runs (sets activeSessionId)
await page.evaluate(async (sid: string) => {
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
await app.selectSession(sid);
}, sessionId);
const activeAfterSelect = await page.evaluate(() => {
const app = (window as unknown as { app: { activeSessionId: string | null } }).app;
return app.activeSessionId;
});
expect(activeAfterSelect).toBe(sessionId);
// Cleanup
await page.evaluate(async (sid: string) => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, sessionId);
});
it('needsRefresh handler includes sendResize call', async () => {
// The needsRefresh handler is registered inside a closure (connectSSE),
// so we can't directly invoke it from tests. Instead, verify that the
// handler source code dispatched to the EventSource includes sendResize.
// This is a structural test — if the handler code changes, this test
// ensures the resize call is preserved.
({ context, page } = await freshPage());
await navigateAndWait(page);
// Dispatch a needsRefresh event on the EventSource and intercept
// the resulting resize API call
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-refresh-test' }),
});
const data = await res.json();
// POST /api/sessions answers in the { success, data: { session } } envelope.
return data.data?.session?.id;
});
expect(sessionId).toBeTruthy();
// Select the session first so activeSessionId is set
await page.evaluate(async (sid: string) => {
const app = (window as unknown as { app: { selectSession: (id: string) => Promise<void> } }).app;
await app.selectSession(sid);
}, sessionId);
// The handler only resizes after it has replayed a NON-EMPTY terminal
// buffer, so give the session a real PTY with some output first.
await page.evaluate(async (sid: string) => {
await fetch(`/api/sessions/${sid}/shell`, { method: 'POST' });
const deadline = Date.now() + 5000;
for (;;) {
await fetch(`/api/sessions/${sid}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'echo needs-refresh-seed\r', useMux: false }),
});
await new Promise((r) => setTimeout(r, 400));
const res = await fetch(`/api/sessions/${sid}/terminal?full=1`);
if ((((await res.json())?.data?.terminalBuffer as string) ?? '').includes('needs-refresh-seed')) break;
if (Date.now() > deadline) throw new Error('seed output never appeared');
}
}, sessionId);
// Intercept resize calls
const resizeCalls: Array<{ url: string }> = [];
await page.route('**/api/sessions/*/resize', async (route) => {
resizeCalls.push({ url: route.request().url() });
await route.continue();
});
// Exercise the SSE fallback path. While WebSocket owns terminal I/O these
// duplicate SSE terminal events are intentionally ignored.
await page.evaluate((sid: string) => {
const app = (window as unknown as { app: { eventSource: EventSource; _disconnectWs: () => void } }).app;
app._disconnectWs();
if (app.eventSource) {
const event = new MessageEvent('session:needsRefresh', {
data: JSON.stringify({ id: sid }),
});
app.eventSource.dispatchEvent(event);
}
}, sessionId);
// Wait for the async handler (fetches /terminal buffer + sends resize)
await page.waitForTimeout(1500);
// Verify resize was called
expect(resizeCalls.length).toBeGreaterThanOrEqual(1);
console.log(`[opencode-resize] needsRefresh triggered ${resizeCalls.length} resize call(s)`);
// Cleanup
await page.route('**/api/sessions/*/resize', (route) => route.continue());
await page.evaluate(async (sid: string) => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, sessionId);
});
});
describe('OpenCode close modal text', () => {
let context: BrowserContext;
let page: Page;
afterAll(async () => {
await context?.close();
});
it.skipIf(!HAS_OPENCODE)('shows "Kill Tmux & OpenCode" for opencode sessions', async () => {
({ context, page } = await freshPage());
await navigateAndWait(page);
// Create a session and mark it as opencode mode
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'oc-close-test', mode: 'opencode' }),
});
const data = await res.json();
// POST /api/sessions answers in the { success, data: { session } } envelope.
return data.data?.session?.id;
});
expect(sessionId).toBeTruthy();
// Wait for SSE to propagate the session
await page.waitForTimeout(500);
// Open the close confirmation modal
await page.evaluate((sid: string) => {
const app = (window as unknown as { app: { requestCloseSession: (id: string) => void } }).app;
app.requestCloseSession(sid);
}, sessionId);
// Check the kill button text
const killTitle = await page.locator('#closeConfirmKillTitle').textContent();
expect(killTitle).toBe('Kill Tmux & OpenCode');
// Close the modal
await page.evaluate(() => {
const app = (window as unknown as { app: { cancelCloseSession: () => void } }).app;
app.cancelCloseSession();
});
// Cleanup
await page.evaluate(async (sid: string) => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, sessionId);
});
it('shows "Kill Tmux & Claude Code" for claude sessions', async () => {
({ context, page } = await freshPage());
await navigateAndWait(page);
// Create a standard claude session
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'cc-close-test' }),
});
const data = await res.json();
// POST /api/sessions answers in the { success, data: { session } } envelope.
return data.data?.session?.id;
});
expect(sessionId).toBeTruthy();
await page.waitForTimeout(500);
// Open the close confirmation modal
await page.evaluate((sid: string) => {
const app = (window as unknown as { app: { requestCloseSession: (id: string) => void } }).app;
app.requestCloseSession(sid);
}, sessionId);
// Check the kill button text
const killTitle = await page.locator('#closeConfirmKillTitle').textContent();
expect(killTitle).toBe('Kill Tmux & Claude Code');
// Close the modal
await page.evaluate(() => {
const app = (window as unknown as { app: { cancelCloseSession: () => void } }).app;
app.cancelCloseSession();
});
// Cleanup
await page.evaluate(async (sid: string) => {
await fetch(`/api/sessions/${sid}`, { method: 'DELETE' });
}, sessionId);
});
});