Files
Codeman/test/opencode-resize.test.ts
T
Codeman maintainer 0f955327b2 fix(cli-registry): merge-time fixes for the run-menu consolidation (#458)
- test/opencode-resize.test.ts: retarget the launcher guard at the real code (this.selectSession(firstSessionId), any this.activeSessionId assignment) with an anti-vacuity check; the old strings existed nowhere, so it could never fail
- session-ui.js: restore as comments the two invariants the merged bodies lost (deepseek leaves statusReporting unset, i.e. ON; no effort field for external CLIs, it is Claude-specific)
- docs/cli-registry.md: move the frontend-guard paragraph below the two backend-guard paragraphs so they keep their antecedent, and note the widened comparison shape
- test/frontend-cli-no-id-branching.test.ts: the comparison shape accepts any left-hand identifier (const m = this._runMode; m === 'codex' was invisible), normalized to `mode`; the two `m !== 'shell'` display filters are allowlisted and the remaining blind spots documented
- test/run-mode-dispatch.test.ts: table-driven pin of run() dispatch (claude to runClaude, each RUN_MODE_LAUNCH id to _runCliMode(id), shell to runShell, unknown to runClaude, lock held and released)
- CLAUDE.md: name the second CI-gated guard next to the backend one
- server.ts: every </head> injection passes a replacer function; a clis.json label containing $' re-injected the rest of the document past escapeScriptJson (two render tests pin it, proven failing on the string form)
- _isAltCliMode(): no reference anywhere in the tree, nothing to fix

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1ea363ff808a62861559bc141e724b163cc1c56e)
2026-09-21 04:37:45 +02:00

361 lines
13 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 { 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}`;
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 }> = [];
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();
return data.id ?? 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();
return data.id ?? 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();
return data.id ?? 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);
await page.waitForTimeout(300);
// 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('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();
return data.id ?? 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();
return data.id ?? 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);
});
});