mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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.
237 lines
9.6 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|