Files
Codeman/test/terminal-copy-shortcut.test.ts
Aamer Akhter e8a93ada1f fix(terminal): forward the orphaned input event instead of replaying a guessed key
The previous shape guessed the character from `event.key` on keydown, re-emitted
it, and then tried to suppress a late canonical copy with a 250 ms
character-keyed dedupe. Review found three defects in that, all reproducible:
the dedupe matched on the character alone with nothing scoping a candidate to
the keydown that created it, so the same character typed twice inside the window
had its second, real byte swallowed; anything whose committed text differed from
`event.key` (Enter, IME punctuation) was delivered twice, because the dedupe
could never match it; and the trigger ignored `key === 'Unidentified'`, which is
what a soft keyboard reports, so it may never have fired where it was needed.

The input event already carries the committed text in `ev.data` — exactly what
xterm itself would have forwarded — so nothing has to be guessed. The controller
now only decides WHETHER to forward, by asking whether xterm produced canonical
data since the keydown that began the keystroke. No character-keyed matching
survives, so the first two defects are structurally impossible rather than
defended against, and nothing reads `key`/`keyCode`, so the third cannot recur.

Three details are load-bearing and each has a test that fails without it:

- The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event.
  `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a
  snapshot read at input time already contains that emission, reads it as
  silence, and delivers the character twice.
- Our `input` listener is registered with `capture: true`. The target is visited
  twice in the event path, so a capture listener calling `stopPropagation()`
  stops later BUBBLE listeners on that same target; xterm's `cancel()` runs
  exactly in the branch where it handled the input, so on bubble we would never
  observe handled events, and whether we observed them at all would hang off
  `options.cancelEvents`. Measured in jsdom and headless chromium; the table is
  in the module header.
- Enter is deliberately no longer special-cased. That mapping is what made the
  committed text differ from the re-emitted value in the first place.

The scope is also narrower than the old name suggests, and the browser test now
proves it rather than assuming it. For a keydown that reports keyCode 229 xterm
ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()`
diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it"
there passes while xterm does all the work, so the browser tests assert WHO
delivered the byte: zero canonical emissions for the genuinely orphaned case,
exactly one delivery for the case xterm rescues itself.

Also addresses review notes: the module gains an `@fileoverview` with
`@dependency`/`@loadorder` and an entry in the load-order list and module
inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its
own. The keydown hook deliberately still runs for every key event rather than
moving behind the 229 gate: gating it would reinstate exactly the blindness
described above, and it is now a single counter assignment.
2026-09-07 19:11:20 -04:00

237 lines
9.6 KiB
TypeScript

/**
* Smart copy in a real browser (#211).
*
* The gate itself is unit-tested in test/terminal-copy-selection.test.ts. What
* can only be proven in a browser is the half that decides whether the PTY sees
* an interrupt: xterm calls the custom key handler BEFORE its own cancel(), so
* returning false does not preventDefault, and a synthetic KeyboardEvent never
* triggers a browser default action. Both facts mean the copy/interrupt split
* has to be driven with real key presses.
*
* Assertions are on real state: what landed on the clipboard, and what xterm
* emitted through onData (the bytes that would reach the PTY).
*
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally: npm run test:browser -- test/terminal-copy-shortcut.test.ts
*
* Port: 3174 (per MEMORY.md, ports 3150+ for tests)
*/
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 = 3174;
const BASE_URL = `http://localhost:${PORT}`;
const IME_PUNCTUATION = ',。!?;:“”、《》、()';
describe('terminal Ctrl+C smart copy', () => {
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 });
const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
page = await context.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
// The first write after load can be dropped while the app finishes wiring
// its render pipeline, so poll until one really lands in the buffer.
await page.waitForFunction(
async () => {
const term = (window as any).app.terminal;
await new Promise((r) => term.write('\r\nWARMUP\r\n', r));
const buf = term.buffer.active;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes('WARMUP')) return true;
}
return false;
},
null,
{ timeout: 20000, polling: 500 }
);
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
/** Write a marker line, optionally select it, and reset the capture state. */
async function setup(line: string, select: boolean, overrides: Record<string, unknown> = {}) {
await page.evaluate(
async ({ line, select, overrides }) => {
const app = (window as any).app;
const term = app.terminal;
const settings = app.loadAppSettingsFromStorage();
settings.shortcutOverrides = overrides;
app.saveAppSettingsToStorage(settings);
(window as any).__data = [];
if (!(window as any).__dataHooked) {
term.onData((d: string) => (window as any).__data.push(d));
(window as any).__dataHooked = true;
}
await new Promise((r) => term.write('\r\n' + line + '\r\n', r));
term.clearSelection();
if (select) {
const buf = term.buffer.active;
let row = -1;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes(line)) row = i;
}
if (row === -1) throw new Error('marker line not found in buffer');
term.select(0, row, line.length);
if (!(term.getSelection() || '').trim()) throw new Error('selection is empty');
}
document.querySelector('.xterm-helper-textarea')!.dispatchEvent(new Event('focus'));
(document.querySelector('.xterm-helper-textarea') as HTMLElement).focus();
await navigator.clipboard.writeText('SENTINEL');
},
{ line, select, overrides }
);
}
async function outcome() {
await page.waitForTimeout(350);
return page.evaluate(async () => ({
data: (window as any).__data as string[],
clipboard: (await navigator.clipboard.readText()).trim(),
hasSelection: (window as any).app.terminal.hasSelection(),
}));
}
async function captureImeInput(targetPage: Page) {
await targetPage.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await targetPage.evaluate(() => {
const term = (window as any).app.terminal;
(window as any).__data = [];
if (!(window as any).__dataHooked) {
term.onData((d: string) => (window as any).__data.push(d));
(window as any).__dataHooked = true;
}
(document.querySelector('.xterm-helper-textarea') as HTMLElement).focus();
});
const cdp = await targetPage.context().newCDPSession(targetPage);
await cdp.send('Input.imeSetComposition', { text: '中文', selectionStart: 2, selectionEnd: 2 });
await cdp.send('Input.insertText', { text: '中文' });
await targetPage.waitForFunction(() => (window as any).__data.join('') === '中文', null, { polling: 10 });
let expected = '中文';
for (const punctuation of Array.from(IME_PUNCTUATION)) {
// Keep keydown -> DOM mutation -> input in one browser task, as a
// native key default action does. Separate CDP calls can let xterm's
// zero-delay textarea diff run before Input.insertText reaches the page.
await targetPage.evaluate((text) => {
const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
const down = new KeyboardEvent('keydown', {
key: 'Process',
code: 'Unidentified',
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperties(down, { keyCode: { value: 229 }, which: { value: 229 } });
textarea.dispatchEvent(down);
if (!document.execCommand('insertText', false, text)) throw new Error('browser rejected insertText');
const up = new KeyboardEvent('keyup', {
key: 'Process',
code: 'Unidentified',
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperties(up, { keyCode: { value: 229 }, which: { value: 229 } });
textarea.dispatchEvent(up);
}, punctuation);
expected += punctuation;
await targetPage.waitForFunction((text) => (window as any).__data.join('') === text, expected, {
polling: 10,
});
}
return targetPage.evaluate(() => (window as any).__data as string[]);
}
it('copies the selection and sends nothing to the PTY', async () => {
await setup('COPY-CASE-SELECTED', true);
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.clipboard).toBe('COPY-CASE-SELECTED');
expect(res.data).toEqual([]);
expect(res.hasSelection).toBe(false); // cleared, so a second Ctrl+C interrupts
});
it('still interrupts when nothing is selected', async () => {
await setup('COPY-CASE-UNSELECTED', false);
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
expect(res.clipboard).toBe('SENTINEL');
});
it('copies on the explicit Ctrl+Shift+C chord', async () => {
await setup('COPY-CASE-EXPLICIT', true);
await page.keyboard.press('Control+Shift+C');
const res = await outcome();
expect(res.clipboard).toBe('COPY-CASE-EXPLICIT');
expect(res.data).toEqual([]);
});
it('never interrupts on Ctrl+Shift+C with an empty selection', async () => {
await setup('COPY-CASE-EXPLICIT-EMPTY', false);
await page.keyboard.press('Control+Shift+C');
const res = await outcome();
expect(res.data).toEqual([]);
expect(res.clipboard).toBe('SENTINEL');
});
it('restores the plain interrupt when the shortcut is disabled', async () => {
await setup('COPY-CASE-DISABLED', true, { 'copy-selection': { disabled: true } });
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
expect(res.clipboard).toBe('SENTINEL');
});
it('follows a rebind, and Ctrl+C goes back to pure interrupt', async () => {
await setup('COPY-CASE-REBOUND', true, { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'y' }] } });
await page.keyboard.press('Alt+y');
const rebound = await outcome();
expect(rebound.clipboard).toBe('COPY-CASE-REBOUND');
expect(rebound.data).toEqual([]);
await setup('COPY-CASE-REBOUND-2', true, { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'y' }] } });
await page.keyboard.press('Control+c');
const res = await outcome();
expect(res.data).toEqual(['\x03']);
});
it('leaves Ctrl+V on the paste trap', async () => {
await setup('COPY-CASE-PASTE', false);
await page.keyboard.press('Control+v');
const res = await outcome();
expect(res.data.join('')).toContain('SENTINEL'); // pasted text, not ^V
expect(res.data.join('')).not.toContain('\x16');
});
it('forwards full-width punctuation after a Chinese IME composition', async () => {
await setup('IME-PUNCTUATION', false);
const desktopChunks = await captureImeInput(page);
expect(desktopChunks.join('')).toBe('中文' + IME_PUNCTUATION);
const touchContext = await browser.newContext({ hasTouch: true });
try {
const touchPage = await touchContext.newPage();
await touchPage.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
const touchChunks = await captureImeInput(touchPage);
expect(touchChunks.join('')).toBe('中文' + IME_PUNCTUATION);
} finally {
await touchContext.close();
}
});
});