diff --git a/.github/workflows/browser-suite.yml b/.github/workflows/browser-suite.yml new file mode 100644 index 00000000..7581e07c --- /dev/null +++ b/.github/workflows/browser-suite.yml @@ -0,0 +1,61 @@ +name: Browser suite + +# The per-push CI gate deliberately skips the Playwright-driven suite (config/test-suites.ts), +# which has twice let two PRs that conflict semantically merge green. This job runs it on a +# schedule and on demand, so a browser-only regression (the Shift+Enter keypress bug was one) +# is caught within a day instead of by a user. It is NOT a merge gate: a red run means "look", +# and it never blocks a push or a PR. +# +# Needs: chromium (installed below), tmux, and the live server the tests start themselves. +# Not run here: test:mobile (per-machine PNG baselines), test:perf (wall-clock), and +# codex-predictive-echo (needs a real, authenticated codex binary). + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + browser: + name: Playwright browser suite + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install tmux + run: | + if ! command -v tmux >/dev/null; then + sudo apt-get update -qq + sudo apt-get install -y tmux + fi + + - name: Install chromium + run: npx playwright install --with-deps chromium + + - name: Run the browser suite + run: npm run test:browser -- --exclude test/codex-predictive-echo.test.ts + + - name: Keep the failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: browser-suite-results + path: | + test-results/ + screenshots-echo-diag/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe86f671..4df79374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,6 +219,8 @@ jobs: run: npx vitest run working-directory: packages/xterm-zerolag-input +# The browser suite also runs nightly (and on demand) in .github/workflows/browser-suite.yml; +# that job is informational and never gates a push or a PR. # Note: three suites are excluded from CI, each with its own local runner: # npm run test:browser Playwright + chromium (+ a live server, and a real # codex binary for codex-predictive-echo) diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts index ed5540a5..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(); @@ -123,10 +146,12 @@ describe('OpenCode session initial resize', () => { body: JSON.stringify({ workingDir: '/tmp', name: 'oc-resize-test' }), }); const data = await res.json(); - return data.id ?? data.session?.id; + // POST /api/sessions answers in the { success, data: { session } } envelope. + return data.data?.session?.id ?? data.id ?? data.session?.id; }); expect(sessionId).toBeTruthy(); + sessionIdForWs = sessionId; // Call selectSession (which is what runOpenCode does after fix) await page.evaluate(async (sid: string) => { @@ -168,7 +193,8 @@ describe('OpenCode session initial resize', () => { body: JSON.stringify({ workingDir: '/tmp', name: 'oc-earlyret-test' }), }); const data = await res.json(); - return data.id ?? data.session?.id; + // POST /api/sessions answers in the { success, data: { session } } envelope. + return data.data?.session?.id ?? data.id ?? data.session?.id; }); expect(sessionId).toBeTruthy(); @@ -219,7 +245,8 @@ describe('OpenCode session initial resize', () => { body: JSON.stringify({ workingDir: '/tmp', name: 'oc-refresh-test' }), }); const data = await res.json(); - return data.id ?? data.session?.id; + // POST /api/sessions answers in the { success, data: { session } } envelope. + return data.data?.session?.id ?? data.id ?? data.session?.id; }); expect(sessionId).toBeTruthy(); @@ -230,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 }> = []; @@ -275,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); @@ -287,7 +330,8 @@ describe('OpenCode close modal text', () => { body: JSON.stringify({ workingDir: '/tmp', name: 'oc-close-test', mode: 'opencode' }), }); const data = await res.json(); - return data.id ?? data.session?.id; + // POST /api/sessions answers in the { success, data: { session } } envelope. + return data.data?.session?.id ?? data.id ?? data.session?.id; }); expect(sessionId).toBeTruthy(); @@ -329,7 +373,8 @@ describe('OpenCode close modal text', () => { body: JSON.stringify({ workingDir: '/tmp', name: 'cc-close-test' }), }); const data = await res.json(); - return data.id ?? data.session?.id; + // POST /api/sessions answers in the { success, data: { session } } envelope. + return data.data?.session?.id ?? data.id ?? data.session?.id; }); expect(sessionId).toBeTruthy(); diff --git a/test/perf-browser.test.ts b/test/perf-browser.test.ts index 39f13a6c..97c65229 100644 --- a/test/perf-browser.test.ts +++ b/test/perf-browser.test.ts @@ -69,7 +69,8 @@ async function createSession(page: Page, name: string): Promise { body: JSON.stringify({ workingDir: '/tmp', name: n }), }); const data = await res.json(); - return data.id ?? data.session?.id; + // POST /api/sessions answers in the { success, data: { session } } envelope. + return data.data?.session?.id ?? data.id ?? data.session?.id; }, name); return result as string; } 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