Files
Codeman/test/split-pane-terminal.browser.test.ts
timkjrandClaude Sonnet 5 46d8b92049 fix(split-pane): port Ctrl+Shift+C's never-falls-through guarantee to Pane B
The smart-copy gate only entered its selection-check block behind
hasSelection(), so a selection-less Ctrl+Shift+C skipped straight to
`return true` and ceded the keystroke to the browser's own handling
(e.g. Chrome's Inspect-Element binding) instead of matching Pane A's
"never falls through" contract for that chord.

Verified live in a real browser that this is a UX-parity fix, not an
interrupt-safety one: xterm's evaluateKeyboardEvent never emits PTY
data for a shifted ctrl-letter regardless of any gate (only "_" and
"@" get special-cased), so no accidental 0x03 was ever at risk. The
regression test added here asserts on the dispatched event's
defaultPrevented rather than the absence of a WS frame, since the
frame-count check passes vacuously for this exact key combo whether
or not the gate fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:34 -05:00

317 lines
14 KiB
TypeScript

/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3175;
const BASE_URL = `http://localhost:${PORT}`;
describe('SplitTerminalPane in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
it('connects, echoes real PTY output, and cleans up on destroy', async () => {
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
// Session creation alone leaves pid:null and no pane (per CLAUDE.md's
// Testing section) — the shell PTY only spawns once this is called, and
// without it the WS opens but no bytes ever flow, and the echo assertion
// below would hang until its own timeout for reasons unrelated to
// SplitTerminalPane.
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id;
});
const result = await page.evaluate(async (id) => {
const mount = document.createElement('div');
mount.style.width = '400px';
mount.style.height = '300px';
document.body.appendChild(mount);
const pane = new (window as any).SplitTerminalPane(id, mount);
pane.connect();
// Wait for the WS to open, then send a real input frame — testMode's
// echo PTY (TEST_PTY_SCRIPT) echoes each byte back exactly once, which
// is what proves the WS round-trip actually reaches a real PTY and back,
// not just that xterm can render locally-written text.
await new Promise((resolve) => {
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
check();
});
pane.ws.send(JSON.stringify({ t: 'i', d: 'SPLITPANE_MARKER\r' }));
const hasEcho = await new Promise((resolve) => {
const deadline = Date.now() + 5000;
const poll = () => {
const buf = pane.terminal.buffer.active;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes('SPLITPANE_MARKER')) {
resolve(true);
return;
}
}
if (Date.now() > deadline) resolve(false);
else setTimeout(poll, 100);
};
poll();
});
pane.destroy();
const cleanedUp = mount.querySelector('.xterm') === null;
document.body.removeChild(mount);
return { hasEcho, cleanedUp };
}, sessionId);
expect(result.hasEcho).toBe(true);
expect(result.cleanedUp).toBe(true);
await page.evaluate(async (id) => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, sessionId);
});
it('shows existing scrollback immediately on connect, before any new output', async () => {
// Regression guard: connect() previously only opened the WS and waited for
// live 'terminal' events (ws-routes.ts sends nothing on connect), so a pane
// opened onto an already-quiet session stayed blank until either new output
// arrived or a resize happened to trigger a tmux repaint. Writing a marker
// and letting the echo settle BEFORE connect() proves the fetched buffer,
// not a live echo, is what populates the pane.
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
// Write directly to the session (not through SplitTerminalPane, which
// does not exist yet). Poll the real ?full=1 capture (same endpoint
// connect() below will use) rather than a fixed delay — the shell's
// 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;
for (;;) {
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;
});
const hasMarker = await page.evaluate(async (id) => {
const mount = document.createElement('div');
mount.style.width = '400px';
mount.style.height = '300px';
document.body.appendChild(mount);
const pane = new (window as any).SplitTerminalPane(id, mount);
await pane.connect();
// xterm's write() parses asynchronously (it queues data and processes it
// on a later microtask/frame), so the fetched buffer connect() writes is
// not necessarily in the rendered buffer the instant connect() resolves.
// Poll rather than check once — no new input is sent here, so any pass
// still comes from the ?full=1 fetch inside connect(), never a live echo.
let found = false;
const deadline = Date.now() + 3000;
while (!found && Date.now() < deadline) {
const buf = pane.terminal.buffer.active;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes('PRE_EXISTING_MARKER')) {
found = true;
break;
}
}
if (!found) await new Promise((r) => setTimeout(r, 50));
}
pane.destroy();
document.body.removeChild(mount);
return found;
}, sessionId);
expect(hasMarker).toBe(true);
await page.evaluate(async (id) => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, sessionId);
});
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
// attachCustomKeyEventHandler of its own, so the document capture-phase
// shortcut handler's preventDefault() (which does not stop xterm) left
// every one of these chords ALSO writing its raw byte/escape sequence into
// Pane B's live PTY on top of whatever the app action did to Pane A.
const sessionId = await page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id;
});
const result = await page.evaluate(async (id) => {
const mount = document.createElement('div');
mount.style.width = '400px';
mount.style.height = '300px';
document.body.appendChild(mount);
const pane = new (window as any).SplitTerminalPane(id, mount);
await pane.connect();
await new Promise((resolve) => {
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
check();
});
const sent: string[] = [];
const realSend = pane.ws.send.bind(pane.ws);
pane.ws.send = (payload: string) => {
sent.push(payload);
return realSend(payload);
};
pane.terminal.focus();
// Dispatch straight at xterm's own textarea, matching how a real
// keypress reaches attachCustomKeyEventHandler — page.keyboard.press()
// goes through the OS/CDP input pipeline and would also trigger the
// app's document-capture handler (opening a real command palette),
// which is not what this test is isolating.
const textarea = (pane.terminal as any)._core?.textarea || (pane.terminal as any).textarea;
const fire = (init: KeyboardEventInit) => {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
textarea.dispatchEvent(event);
return event.defaultPrevented;
};
// keyCode is what xterm's evaluateKeyboardEvent switches on to decide
// whether to produce a data frame at all — at keyCode 0 (unset) it can
// never emit bytes, so the assertion below held regardless of whether
// the custom key handler's gate actually fired. Real values (K=75,
// 1=49, B=66) are what a real keypress carries.
const app = window.app as any;
fire({ key: 'k', code: 'KeyK', keyCode: 75, ctrlKey: true }); // command palette
fire({ key: '1', code: 'Digit1', keyCode: 49, altKey: true }); // Alt+1 tab switch
// Ctrl+Z (SIGTSTP): this pane's own sessionMode is undefined (no `mode`
// opt passed to the constructor above), so `this.sessionMode !== 'shell'`
// holds and the gate must block it, mirroring a non-shell (agent) mode.
fire({ key: 'z', code: 'KeyZ', keyCode: 90, ctrlKey: true });
// Shift+Enter: must never reach the PTY as a bare \r (that would submit
// an incomplete prompt instead of inserting a newline) — it goes out as
// a POST to /api/sessions/:id/send-key instead.
const sendKeyCalls: unknown[] = [];
const realFetch = window.fetch.bind(window);
window.fetch = ((...args: Parameters<typeof fetch>) => {
const url = String(args[0]);
if (url.includes('/send-key')) {
sendKeyCalls.push(args[1] ? JSON.parse((args[1] as RequestInit).body as string) : null);
}
return realFetch(...args);
}) as typeof fetch;
fire({ key: 'Enter', code: 'Enter', keyCode: 13, shiftKey: true });
window.fetch = realFetch;
// Smart-copy Ctrl+C: with a real selection in THIS pane's own terminal,
// Ctrl+C must copy it (never send 0x03) and must copy Pane B's
// selection, not Pane A's. app._copyText is stubbed rather than relying
// on a real clipboard, which headless Chromium may refuse permission
// for.
pane.terminal.write('SPLITPANE_COPY_MARKER');
await new Promise((r) => setTimeout(r, 100));
pane.terminal.selectAll();
let copiedText: string | null = null;
const realCopyText = app._copyText;
app._copyText = async (text: string) => {
copiedText = text;
return true;
};
fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true });
await new Promise((r) => setTimeout(r, 50));
app._copyText = realCopyText;
// Ctrl+Shift+C with NO selection: the blanket "no 'i' frames" check
// below is NOT what proves this gate works — xterm's own
// evaluateKeyboardEvent never emits data for a shifted ctrl-letter in
// the first place (verified live: removing the gate entirely still
// produces zero WS frames for this exact key), so an absent 'i' frame
// is true whether or not the app-level shiftKey branch fires. What the
// branch actually buys is `preventDefault()`, so the browser's own
// handling of the chord (e.g. Chrome's Inspect-Element binding) is
// pre-empted, mirroring Pane A's own "never falls through" contract —
// asserted directly via the dispatched event's defaultPrevented.
pane.terminal.clearSelection();
const ctrlShiftCPrevented = fire({ key: 'c', code: 'KeyC', keyCode: 67, ctrlKey: true, shiftKey: true });
// Alt+B only reaches shouldToggleSessionSidebarFromShortcut's gate when
// the sidebar layout is actually active (app.js:4325) — under the
// default header-strip layout the app doesn't treat Alt+B as its own
// shortcut either, so Pane A forwards the same `ESC b` to its own PTY.
// Assert the gate where it is meant to hold: sidebar layout active.
//
// Setting only the `data-session-list` attribute is not enough: this
// event bubbles (matching how a real keypress reaches xterm), so it
// also reaches app.js's OWN document-level capture-phase shortcut
// dispatcher, which matches the same Alt+B binding and calls the real
// toggleSessionSidebar() — that reads the persisted settings (still
// 'header'), re-runs applySessionListLayout(), and resets the
// attribute back to 'header' before xterm's own (later, non-capture)
// key handler ever sees it. Persisting the setting through the app's
// own settings cache keeps the attribute stable across that bubble.
const prevSettings = { ...app.loadAppSettingsFromStorage() };
app._cachedAppSettings = { ...prevSettings, sessionListLayout: 'sidebar' };
app.applySessionListLayout();
fire({ key: 'b', code: 'KeyB', keyCode: 66, altKey: true }); // Alt+B sidebar toggle
app._cachedAppSettings = prevSettings;
app.applySessionListLayout();
pane.destroy();
document.body.removeChild(mount);
return { sent, sendKeyCalls, copiedText, ctrlShiftCPrevented };
}, sessionId);
expect(result.sent.every((f) => JSON.parse(f).t !== 'i')).toBe(true);
expect(result.sendKeyCalls).toEqual([{ key: 'S-Enter' }]);
expect(result.copiedText).toContain('SPLITPANE_COPY_MARKER');
expect(result.ctrlShiftCPrevented).toBe(true);
await page.evaluate(async (id) => {
await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
}, sessionId);
});
});