From 4c2fdd5f5a142baeff8bedf358e899bd8d67d738 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:53:05 +0800 Subject: [PATCH] test: make opencode-resize and split-pane browser tests environment-proof - opencode-resize: record WebSocket resize frames as well as POST /resize, seed the needsRefresh test with real PTY output, skip the OpenCode close modal test when opencode is not installed - split-pane: send the marker with useMux:false (plain prompts otherwise go through tmux send-keys, which test mode does not have) and retry past Codeman's own post-create clear Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS --- test/opencode-resize.test.ts | 44 ++++++++++++++++++++++-- test/split-pane-terminal.browser.test.ts | 19 +++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts index eea954b1..e5a40161 100644 --- a/test/opencode-resize.test.ts +++ b/test/opencode-resize.test.ts @@ -11,6 +11,7 @@ * 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'; @@ -18,6 +19,15 @@ 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; @@ -103,6 +113,19 @@ describe('OpenCode session initial resize', () => { // 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() + '#' + sessionIdForWs, cols: msg.c, rows: msg.r }); + } catch { + /* not JSON */ + } + }); + }); + let sessionIdForWs = ''; await page.route('**/api/sessions/*/resize', async (route) => { const request = route.request(); const body = request.postDataJSON(); @@ -128,6 +151,7 @@ describe('OpenCode session initial resize', () => { }); expect(sessionId).toBeTruthy(); + sessionIdForWs = sessionId; // Call selectSession (which is what runOpenCode does after fix) await page.evaluate(async (sid: string) => { @@ -233,7 +257,23 @@ describe('OpenCode session initial resize', () => { await app.selectSession(sid); }, sessionId); - await page.waitForTimeout(300); + // 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 }> = []; @@ -278,7 +318,7 @@ describe('OpenCode close modal text', () => { await context?.close(); }); - it('shows "Kill Tmux & OpenCode" for opencode sessions', async () => { + it.skipIf(!HAS_OPENCODE)('shows "Kill Tmux & OpenCode" for opencode sessions', async () => { ({ context, page } = await freshPage()); await navigateAndWait(page); diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts index bd5b211a..7e025b2e 100644 --- a/test/split-pane-terminal.browser.test.ts +++ b/test/split-pane-terminal.browser.test.ts @@ -113,18 +113,21 @@ describe('SplitTerminalPane in a real browser', () => { // own startup can race an early write and, on this box, a startup // script issues a `clear` that erases scrollback (modern ncurses // `clear` emits \x1b[3J) if the input lands before the shell is ready. - await fetch(`/api/sessions/${id}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: 'PRE_EXISTING_MARKER\r' }), - }); - const deadline = Date.now() + 5000; + // Codeman itself writes `clear` into a NEW shell session ~100ms after + // creating it, which can erase an early marker, so re-send until the + // marker is present in the capture rather than writing once. + const deadline = Date.now() + 8000; for (;;) { + await fetch(`/api/sessions/${id}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: 'PRE_EXISTING_MARKER\r', useMux: false }), + }); + await new Promise((r) => setTimeout(r, 400)); const res2 = await fetch(`/api/sessions/${id}/terminal?full=1`); const buffer = (await res2.json())?.data?.terminalBuffer ?? ''; if (buffer.includes('PRE_EXISTING_MARKER')) break; if (Date.now() > deadline) throw new Error('marker never landed in ?full=1 capture: ' + JSON.stringify(buffer)); - await new Promise((r) => setTimeout(r, 200)); } return id; }); @@ -166,7 +169,7 @@ describe('SplitTerminalPane in a real browser', () => { await page.evaluate(async (id) => { await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); }, sessionId); - }); + }, 20000); it('gates app-level chords out of Pane B instead of forwarding their raw bytes', async () => { // Regression guard for PR #453's Ctrl+K/Alt+1/Alt+B leak: Pane B had no