Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/browser-suite.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
59 changes: 52 additions & 7 deletions test/opencode-resize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,23 @@
* 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;

Expand Down Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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 }> = [];
Expand Down Expand Up @@ -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);

Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion test/perf-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ async function createSession(page: Page, name: string): Promise<string> {
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;
}
Expand Down
19 changes: 11 additions & 8 deletions test/split-pane-terminal.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down Expand Up @@ -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
Expand Down
Loading