mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
fix(terminal): drive the shipped Shift+Enter handlers and the Key tester cap in their browser tests (#520, #522 review)
- Minor: the Key tester "14 lines" test never pressed a key into the tester (the previous test blurred it, so the presses landed on <body> and the cap was never exercised). It now refocuses the field, asserts the focus, clears the log, checks 2 presses accumulate to 6 lines, then 4 presses of another key cap the log at exactly 14 with the oldest 4 lines evicted in order, and the readonly field stays empty. Verified to fail with the cap changed to 20. - Nit: the split-pane invariant implied Ctrl+Enter could use the CLI's declared newline chord. Reworded after checking the send-key route: Ctrl+Enter is always a real 0x0a, Shift+Enter is the declared capabilities.newline chord (0x0a unless the CLI declares another), sent on keydown only. The same imprecision in the auto-named sessions paragraph is corrected too. - Nit: docs/wiki/Settings-Reference.md now lists the Key tester row in the Terminal & Input table. - Nit: test/shift-enter-keypress.browser.test.ts exercised a hand-copied predicate named `shipped`. It now loads the real app from a real WebServer and presses real keys into the handlers terminal-ui.js (app.terminal, recording the real _sendInputAsync send path) and terminal-split.js (a real SplitTerminalPane) attach, recording the send-key POSTs through a fetch wrapper. It asserts no \r reaches either send path for Shift/Ctrl+Enter, exactly one send-key per press for the right session, and that Enter and Alt+Enter are untouched. The old keydown-only gate stays as a labelled reproduction of xterm's keypress behaviour on a bare Terminal. Verified to fail on both panes with the gate narrowed back to keydown. - Nit: the keypress trap is now written down beside the other key-gate rules (Command palette and shortcut registry): xterm runs the custom handler for keydown, keypress and keyup and drops only Ctrl/Alt/Meta keypresses, so a gate on a chord that can carry Shift alone must swallow every event type. The smart-copy keydown-only rule points at it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,8 +89,29 @@ describe('Key tester in a real browser', () => {
|
||||
});
|
||||
|
||||
it('keeps only the last 14 lines and never types into the field', async () => {
|
||||
for (let i = 0; i < 8; i++) await page.keyboard.press('a');
|
||||
expect((await log()).split('\n').length).toBeLessThanOrEqual(14);
|
||||
// The test above blurred the field, so focus it again: without this the presses land on
|
||||
// <body>, the log keeps whatever the earlier tests left, and the cap is never exercised.
|
||||
await page.focus('#keyTesterInput');
|
||||
expect(await page.evaluate(() => document.activeElement?.id)).toBe('keyTesterInput');
|
||||
await page.evaluate(() => (document.getElementById('keyTesterLog')!.textContent = ''));
|
||||
const lines = async () => (await log()).split('\n');
|
||||
|
||||
// A printable key fires keydown, keypress and keyup, so 2 presses are 6 lines: under the cap
|
||||
// the log accumulates rather than showing only the latest event.
|
||||
for (let i = 0; i < 2; i++) await page.keyboard.press('a');
|
||||
expect(await lines()).toHaveLength(6);
|
||||
|
||||
// A different key, so eviction is visible: 4 x 3 = 12 more lines makes 18, capped to 14. The
|
||||
// oldest 4 go (all of the first 'a' press and the second one's keydown), the newest stay in order.
|
||||
for (let i = 0; i < 4; i++) await page.keyboard.press('b');
|
||||
const capped = await lines();
|
||||
expect(capped).toHaveLength(14);
|
||||
expect(capped.filter((l) => l.includes('key="a"'))).toHaveLength(2);
|
||||
expect(capped.filter((l) => l.includes('key="b"'))).toHaveLength(12);
|
||||
expect(capped[0]).toMatch(/^keypress\s+key="a" code=KeyA mods=none charCode=97$/);
|
||||
expect(capped[13]).toMatch(/^keyup\s+key="b" code=KeyB mods=none$/);
|
||||
|
||||
// Readonly: none of those presses typed anything into the field itself.
|
||||
expect(await page.inputValue('#keyTesterInput')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,81 +1,217 @@
|
||||
// @vitest-environment node
|
||||
// Real xterm.js in real Chromium, real keystrokes. xterm runs the custom key handler for
|
||||
// keydown AND keypress, and drops a keypress that carries Ctrl/Alt but NOT a Shift-only one,
|
||||
// so a handler that returns false for keydown alone lets Shift+Enter fall through to a bare
|
||||
// \r (submit). That is why Ctrl+Enter and Alt+Enter inserted a newline while Shift+Enter
|
||||
// submitted. Self-contained: no server, only the xterm bundle.
|
||||
/**
|
||||
* @fileoverview Shift/Ctrl+Enter against the SHIPPED key handlers, with real keystrokes in Chromium.
|
||||
*
|
||||
* xterm runs the custom key handler for keydown, keypress AND keyup, and drops a keypress that
|
||||
* carries Ctrl/Alt but NOT a Shift-only one. A handler that returns false for keydown alone
|
||||
* therefore lets Shift+Enter's keypress fall through to onData as a bare \r, which went out over
|
||||
* the WebSocket ahead of the async send-key fetch and submitted the prompt (#520). Ctrl+Enter
|
||||
* worked all along because Chromium fires no keypress for it.
|
||||
*
|
||||
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
|
||||
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
|
||||
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
||||
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
||||
*
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other Playwright suites:
|
||||
* npm run test:browser -- test/shift-enter-keypress.browser.test.ts
|
||||
* The CI gate's guard on the same handlers is the source check in
|
||||
* test/shift-enter-keypress-swallowed.test.ts.
|
||||
*
|
||||
* Port: 3267 (per CLAUDE.md, ports 3150+ for tests)
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const XTERM = readFileSync(join(process.cwd(), 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8');
|
||||
const PORT = 3267;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
const MAIN_ID = 'shift-enter-probe-main';
|
||||
const PANE_B_ID = 'shift-enter-probe-pane-b';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__t: Record<string, { term: { focus(): void }; sent: string[] }>;
|
||||
Terminal: new () => {
|
||||
open(el: HTMLElement): void;
|
||||
focus(): void;
|
||||
onData(cb: (d: string) => void): void;
|
||||
attachCustomKeyEventHandler(cb: (ev: KeyboardEvent) => boolean): void;
|
||||
};
|
||||
}
|
||||
interface Pressed {
|
||||
/** What xterm emitted through onData, i.e. what would have been written to the PTY. */
|
||||
data: string[];
|
||||
/** The send-key POSTs the handler made, as `<session id> <key>`. */
|
||||
sendKeys: string[];
|
||||
/** Whether xterm's own textarea held focus, so a key that emits nothing was really delivered. */
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
describe('Shift/Ctrl+Enter reach the PTY as a bare \\r only if the handler lets keypress through', () => {
|
||||
describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be swallowed too)', () => {
|
||||
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.setContent('<body></body>');
|
||||
await page.addScriptTag({ content: XTERM });
|
||||
await page.evaluate(() => {
|
||||
const mk = (handler: (ev: KeyboardEvent) => boolean) => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const term = new window.Terminal();
|
||||
term.open(host);
|
||||
const sent: string[] = [];
|
||||
term.onData((d) => sent.push(d));
|
||||
term.attachCustomKeyEventHandler(handler);
|
||||
return { term, sent };
|
||||
};
|
||||
// The shipped predicate (terminal-ui.js / terminal-split.js) and the one it replaced.
|
||||
const shipped = (ev: KeyboardEvent) => !(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey));
|
||||
const keydownOnly = (ev: KeyboardEvent) =>
|
||||
!(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown');
|
||||
window.__t = { shipped: mk(shipped), keydownOnly: mk(keydownOnly) };
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(
|
||||
({ paneBId }) => {
|
||||
const w = window as any;
|
||||
// Record send-key instead of reaching the server (no tmux pane exists behind these ids).
|
||||
w.__sendKeys = [] as string[];
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const m = /\/api\/sessions\/([^/]+)\/send-key$/.exec(url);
|
||||
if (m) {
|
||||
w.__sendKeys.push(`${m[1]} ${JSON.parse(String(init?.body ?? '{}')).key}`);
|
||||
return Promise.resolve(
|
||||
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
);
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
|
||||
async function typed(which: 'shipped' | 'keydownOnly', key: string): Promise<string[]> {
|
||||
await page.evaluate((w) => {
|
||||
window.__t[w].sent.length = 0;
|
||||
window.__t[w].term.focus();
|
||||
}, which);
|
||||
// Pane B, built by the real class. connect() installs the key handler synchronously,
|
||||
// before its first await; the buffer fetch and WebSocket for the missing session just fail.
|
||||
const mount = document.createElement('div');
|
||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
||||
document.body.appendChild(mount);
|
||||
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' });
|
||||
void pane.connect().catch(() => {});
|
||||
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
||||
w.__paneBData = [] as string[];
|
||||
pane.terminal.onData((d: string) => w.__paneBData.push(d));
|
||||
w.__paneB = pane;
|
||||
|
||||
// The bare xterm the old keydown-only gate is reproduced on (see the first test).
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'position:fixed;left:420px;top:0;width:400px;height:300px;';
|
||||
document.body.appendChild(host);
|
||||
const old = new w.Terminal();
|
||||
old.open(host);
|
||||
w.__oldData = [] as string[];
|
||||
old.onData((d: string) => w.__oldData.push(d));
|
||||
old.attachCustomKeyEventHandler(
|
||||
(ev: KeyboardEvent) => !(ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown')
|
||||
);
|
||||
w.__oldTerm = old;
|
||||
},
|
||||
{ paneBId: PANE_B_ID }
|
||||
);
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* Press `key` in the main pane. `sessionId` null is the welcome screen; otherwise the app thinks
|
||||
* that session is active, local echo is off (the desktop default) and its real send path,
|
||||
* `_sendInputAsync`, is recorded instead of posting.
|
||||
*/
|
||||
async function pressMain(key: string, sessionId: string | null): Promise<Pressed> {
|
||||
await page.evaluate((id) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
w.__mainSaved ??= { id: app.activeSessionId, echo: app._localEchoEnabled, send: app._sendInputAsync };
|
||||
w.__mainData = [] as string[];
|
||||
w.__sendKeys.length = 0;
|
||||
app.activeSessionId = id;
|
||||
app._localEchoEnabled = false;
|
||||
app._pendingInput = '';
|
||||
app._sendInputAsync = (_sid: string, chunk: string) => void w.__mainData.push(chunk);
|
||||
app.terminal.focus();
|
||||
}, sessionId);
|
||||
const focused = await page.evaluate(() => document.activeElement === (window as any).app.terminal.textarea);
|
||||
await page.keyboard.press(key);
|
||||
return page.evaluate((w) => [...window.__t[w].sent], which);
|
||||
// The main pane batches onData through a short flush timer before _sendInputAsync.
|
||||
await page.waitForTimeout(150);
|
||||
return page.evaluate((f) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
const out = { data: [...w.__mainData], sendKeys: [...w.__sendKeys], focused: f };
|
||||
app.activeSessionId = w.__mainSaved.id;
|
||||
app._localEchoEnabled = w.__mainSaved.echo;
|
||||
app._sendInputAsync = w.__mainSaved.send;
|
||||
app._pendingInput = '';
|
||||
return out;
|
||||
}, focused);
|
||||
}
|
||||
|
||||
it('reproduces the bug with the old keydown-only handler', async () => {
|
||||
expect(await typed('keydownOnly', 'Shift+Enter')).toEqual(['\r']);
|
||||
expect(await typed('keydownOnly', 'Control+Enter')).toEqual([]);
|
||||
async function pressIn(which: 'paneB' | 'old', key: string): Promise<Pressed> {
|
||||
await page.evaluate((k) => {
|
||||
const w = window as any;
|
||||
w[k === 'paneB' ? '__paneBData' : '__oldData'].length = 0;
|
||||
w.__sendKeys.length = 0;
|
||||
(k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).focus();
|
||||
}, which);
|
||||
const focused = await page.evaluate((k) => {
|
||||
const w = window as any;
|
||||
return document.activeElement === (k === 'paneB' ? w.__paneB.terminal : w.__oldTerm).textarea;
|
||||
}, which);
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForTimeout(50);
|
||||
return page.evaluate(
|
||||
({ k, f }) => {
|
||||
const w = window as any;
|
||||
return { data: [...w[k === 'paneB' ? '__paneBData' : '__oldData']], sendKeys: [...w.__sendKeys], focused: f };
|
||||
},
|
||||
{ k: which, f: focused }
|
||||
);
|
||||
}
|
||||
|
||||
it('reproduces the leak on a bare xterm with the old keydown-only gate (the behaviour the fix depends on)', async () => {
|
||||
// Not shipped code: this pins the xterm behaviour, so a future xterm that stops emitting the
|
||||
// Shift-only keypress shows up here instead of silently turning the tests below vacuous.
|
||||
const shift = await pressIn('old', 'Shift+Enter');
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual(['\r']);
|
||||
expect((await pressIn('old', 'Control+Enter')).data).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends nothing for Shift+Enter and Ctrl+Enter with the shipped handler (the send-key route supplies the newline)', async () => {
|
||||
expect(await typed('shipped', 'Shift+Enter')).toEqual([]);
|
||||
expect(await typed('shipped', 'Control+Enter')).toEqual([]);
|
||||
it('main pane (terminal-ui.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key exactly once', async () => {
|
||||
const shift = await pressMain('Shift+Enter', MAIN_ID);
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
// Once, from the keydown: the swallowed keypress and keyup must not send again.
|
||||
expect(shift.sendKeys).toEqual([`${MAIN_ID} S-Enter`]);
|
||||
|
||||
const ctrl = await pressMain('Control+Enter', MAIN_ID);
|
||||
expect(ctrl.data).toEqual([]);
|
||||
expect(ctrl.sendKeys).toEqual([`${MAIN_ID} C-Enter`]);
|
||||
});
|
||||
|
||||
it('leaves plain Enter and Alt+Enter alone', async () => {
|
||||
expect(await typed('shipped', 'Enter')).toEqual(['\r']);
|
||||
expect(await typed('shipped', 'Alt+Enter')).toEqual(['\x1b\r']);
|
||||
it('main pane: plain Enter still reaches the send path as \\r, and Alt+Enter as ESC CR', async () => {
|
||||
const enter = await pressMain('Enter', MAIN_ID);
|
||||
expect(enter.focused).toBe(true);
|
||||
expect(enter.data).toEqual(['\r']);
|
||||
expect(enter.sendKeys).toEqual([]);
|
||||
|
||||
const alt = await pressMain('Alt+Enter', MAIN_ID);
|
||||
expect(alt.data).toEqual(['\x1b\r']);
|
||||
expect(alt.sendKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('main pane on the welcome screen: Shift+Enter sends nothing and posts no send-key', async () => {
|
||||
// With no active session the app's onData drops input anyway, so this pins the send-key
|
||||
// guard (`this.activeSessionId`), not the keypress swallow; the tests above pin that.
|
||||
const shift = await pressMain('Shift+Enter', null);
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
expect(shift.sendKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
||||
const shift = await pressIn('paneB', 'Shift+Enter');
|
||||
expect(shift.focused).toBe(true);
|
||||
expect(shift.data).toEqual([]);
|
||||
expect(shift.sendKeys).toEqual([`${PANE_B_ID} S-Enter`]);
|
||||
|
||||
const ctrl = await pressIn('paneB', 'Control+Enter');
|
||||
expect(ctrl.data).toEqual([]);
|
||||
expect(ctrl.sendKeys).toEqual([`${PANE_B_ID} C-Enter`]);
|
||||
|
||||
const enter = await pressIn('paneB', 'Enter');
|
||||
expect(enter.data).toEqual(['\r']);
|
||||
expect(enter.sendKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user