Files
Codeman/test/image-paste-trap.test.ts
T
DDTandDDT dd09a383f1 fix: stop desktop dead-key doubling and bracket Ctrl+V paste
KeyCode 229 recovery forwarded a composed character that xterm had
already delivered, so a desktop dead-key backtick arrived twice.
create() now attaches only when the primary pointer is coarse.

Ctrl+V text paste went through terminal.paste(), which drops the
CSI 200~/201~ markers after a replay clears xterm's DECSET 2004
mirror. Plain text is now a hand-built bracketed frame on the
session input path.

Fixes #716

Co-authored-by: DDT <1786035110@qq.com>
2026-10-27 12:06:45 +00:00

378 lines
14 KiB
TypeScript

/**
* @fileoverview Unit tests for the Ctrl+V paste trap in image-input.js.
*
* `_handleImagePaste()` appends a hidden contenteditable div (the "paste
* trap"), focuses it, and reads the clipboard out of the paste event the
* browser delivers there. Two things can deliver that event for a single
* Ctrl+V: the `document.execCommand('paste')` the function issues itself, and
* the keydown's own default action, which still runs because xterm's custom key
* handler returns false without cancelling the event. A browser that honours
* execCommand('paste') therefore fires the trap's listener twice, and the
* clipboard text used to reach the PTY twice with it — while right-click →
* Paste, which involves no keydown, stayed correct.
*
* Loads the browser module into a vm sandbox with a fake document, so the tests
* drive the trap's listener directly rather than through a real browser.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const imageInputSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
interface TrapListener {
(e: Record<string, unknown>): void;
}
interface FakeTrap {
contentEditable: string;
style: { cssText: string };
parentNode: unknown;
focus: () => void;
addEventListener: (ev: string, fn: TrapListener) => void;
}
interface SentInput {
sessionId: string;
data: string;
}
interface Harness {
/** Fire a paste event on the trap the last _handleImagePaste() call created. */
firePaste: (payload: { text?: string; images?: string[] }) => void;
/** Text handed to xterm's terminal.paste(), one entry per call. */
pastedText: string[];
/** Frames handed to _sendInputAsync(), in order. */
sentInputs: SentInput[];
/** How many times predictive-echo predictions were cleared. */
predictionsCleared: () => number;
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
uploadedBatches: Array<Array<{ type: string }>>;
/** Messages handed to showToast(), one entry per call. */
toasts: string[];
/** How many trap divs are still attached to the fake body. */
attachedTraps: () => number;
runTimers: () => void;
}
interface PasteHarnessOptions {
/** Session the paste trap is aimed at. Defaults to the active session. */
sessionId?: string;
/** Unsent overlay text. Enables the local-echo flush path when set. */
localEchoPending?: string;
}
function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const traps: FakeTrap[] = [];
const listeners: TrapListener[] = [];
const attached = new Set<FakeTrap>();
const timers: Array<() => void> = [];
const documentObj = {
createElement: (): FakeTrap => {
const trap: FakeTrap = {
contentEditable: '',
style: { cssText: '' },
parentNode: null,
focus: () => {},
addEventListener: (ev: string, fn: TrapListener) => {
if (ev === 'paste') listeners.push(fn);
},
};
traps.push(trap);
return trap;
},
body: {
appendChild: (el: FakeTrap) => {
attached.add(el);
el.parentNode = documentObj.body;
},
removeChild: (el: FakeTrap) => {
attached.delete(el);
el.parentNode = null;
},
},
// A browser that honours the command fires the trap's paste listener from
// here as well; the tests model that by firing the listener twice.
execCommand: () => true,
getElementById: () => null,
};
const context = vm.createContext({
window: {},
document: documentObj,
setTimeout: (fn: () => void) => {
timers.push(fn);
return timers.length;
},
clearTimeout: () => {},
console,
});
vm.runInContext('class CodemanApp {}', context);
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const pastedText: string[] = [];
const sentInputs: SentInput[] = [];
const uploadedBatches: Array<Array<{ type: string }>> = [];
let predictionsCleared = 0;
const app = new CodemanApp();
app.activeSessionId = 'session-1';
// Replay leaves xterm's DECSET 2004 mirror off. Text must still go out bracketed,
// and must not ask terminal.paste() to consult that mirror (#716).
app.terminal = {
paste: (text: string) => pastedText.push(text),
focus: () => {},
modes: { bracketedPasteMode: false },
};
app._sendInputAsync = (sessionId: string, data: string) => {
sentInputs.push({ sessionId, data });
};
app._predictiveEcho = { clearPredictions: () => { predictionsCleared += 1; } };
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
uploadedBatches.push(Array.from(files));
};
if (options.localEchoPending !== undefined) {
app._localEchoEnabled = true;
app._pendingInput = '';
app._localEchoOverlay = {
pendingText: options.localEchoPending,
clear() {
this.pendingText = '';
},
suppressBufferDetection() {},
};
app._flushedOffsets = new Map();
app._flushedTexts = new Map();
app._flushLocalEchoPending = function (this: Record<string, any>) {
const pending = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (pending) this._pendingInput += pending;
};
}
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
const target = options.sessionId ? { terminal: app.terminal, sessionId: options.sessionId } : undefined;
(app._handleImagePaste as (t?: object) => void).call(app, target);
return {
firePaste({ text = '', images = [] }) {
const items = images.map((type) => ({ type, getAsFile: () => ({ type }) }));
const event = {
clipboardData: {
items,
getData: () => text,
},
preventDefault: () => {},
stopPropagation: () => {},
};
for (const fn of listeners) fn(event);
},
pastedText,
sentInputs,
predictionsCleared: () => predictionsCleared,
uploadedBatches,
toasts,
attachedTraps: () => attached.size,
runTimers: () => {
const pending = timers.splice(0, timers.length);
for (const fn of pending) fn();
},
};
}
function loadImageInputApp() {
const context = vm.createContext({ console, window: {}, document: {} });
vm.runInContext('class CodemanApp {}', context);
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const app = new CodemanApp();
app.activeSessionId = 'session-1';
app.showToast = vi.fn();
app.sendInput = vi.fn(async () => {});
app._sendInputAsync = vi.fn();
app._normalizeImageForUpload = vi.fn(async (file) => file);
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
return app as Record<string, any>;
}
describe('Ctrl+V paste trap', () => {
it('sends clipboard text once as a bracketed paste, not through xterm paste()', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'hello world' });
// terminal.paste() would drop the markers: the harness mirror is off, which
// is what a replay leaves behind.
expect(h.pastedText).toEqual([]);
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]);
expect(h.predictionsCleared()).toBe(1);
});
it('brackets a multi-line paste and strips nested paste markers', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' });
expect(h.pastedText).toEqual([]);
expect(h.sentInputs).toEqual([
{ sessionId: 'session-1', data: '\x1b[200~line one\rline two\rnested\r\x1b[201~' },
]);
});
it('addresses the bracketed paste to the pane that received Ctrl+V', () => {
const h = loadPasteHarness({ sessionId: 'pane-b' });
h.firePaste({ text: 'into pane b' });
expect(h.sentInputs).toEqual([{ sessionId: 'pane-b', data: '\x1b[200~into pane b\x1b[201~' }]);
expect(h.pastedText).toEqual([]);
});
it('flushes unsent local-echo text ahead of the bracketed paste', () => {
const h = loadPasteHarness({ localEchoPending: 'hi' });
h.firePaste({ text: 'world\n' });
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: 'hi' }]);
h.runTimers();
expect(h.sentInputs).toEqual([
{ sessionId: 'session-1', data: 'hi' },
{ sessionId: 'session-1', data: '\x1b[200~world\r\x1b[201~' },
]);
expect(h.pastedText).toEqual([]);
});
it('ignores a second paste event for the same Ctrl+V', () => {
const h = loadPasteHarness();
// execCommand('paste') and the uncancelled keydown's default action both
// land on the same trap in browsers that honour the command.
h.firePaste({ text: 'hello world' });
h.firePaste({ text: 'hello world' });
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~hello world\x1b[201~' }]);
expect(h.pastedText).toEqual([]);
});
it('uploads a pasted image once when the trap sees two paste events', () => {
const h = loadPasteHarness();
h.firePaste({ images: ['image/png'] });
h.firePaste({ images: ['image/png'] });
expect(h.uploadedBatches).toHaveLength(1);
expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]);
expect(h.pastedText).toEqual([]);
expect(h.sentInputs).toEqual([]);
});
it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'shot.png', images: ['image/png', 'application/x-sh'] });
expect(h.uploadedBatches).toEqual([[{ type: 'image/png' }]]);
expect(h.toasts).toEqual(['1 file skipped: unsupported type']);
});
it('a paste whose only file is refused pastes its text instead, without a toast', () => {
const h = loadPasteHarness();
// Copying from a document can carry both: the text is what the user gets.
h.firePaste({ text: 'echo hi', images: ['application/x-sh'] });
expect(h.sentInputs).toEqual([{ sessionId: 'session-1', data: '\x1b[200~echo hi\x1b[201~' }]);
expect(h.pastedText).toEqual([]);
expect(h.uploadedBatches).toEqual([]);
expect(h.toasts).toEqual([]);
});
it('a paste of a refused file and no text says so instead of doing nothing', () => {
const h = loadPasteHarness();
h.firePaste({ images: ['application/x-sh'] });
expect(h.pastedText).toEqual([]);
expect(h.uploadedBatches).toEqual([]);
expect(h.toasts).toEqual(['Unsupported file type']);
});
it('removes the trap and hands focus back after the paste it accepted', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'hello world' });
expect(h.attachedTraps()).toBe(1);
h.runTimers();
expect(h.attachedTraps()).toBe(0);
});
});
describe('image upload insertion policy', () => {
it('returns ordered paths without terminal insertion when requested by the composer', async () => {
const app = loadImageInputApp();
const files = [{ path: '/tmp/first.png' }, { path: '/tmp/second.png' }];
const paths = await app._uploadAndInsertImages(files, { insert: false });
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
expect(app.sendInput).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('preserves terminal insertion by default', async () => {
const app = loadImageInputApp();
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
// The same delivery sendInput() uses (durable queue, useMux for the POST
// fallback), but addressed to the session the batch was uploaded to.
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/legacy.png', { useMux: true });
});
it('inserts into the session the upload started in, even after a tab switch mid-upload', async () => {
const app = loadImageInputApp();
// The user switches tabs while the upload is in flight. sendInput() re-read
// activeSessionId after the awaits, so the paths used to land in session-2.
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => {
app.activeSessionId = 'session-2';
return file.path;
});
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-1', { path: '/tmp/shot.png' });
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/shot.png', { useMux: true });
expect(app.sendInput).not.toHaveBeenCalled();
});
it('uploads to and inserts into an explicitly named session', async () => {
const app = loadImageInputApp();
await app._uploadAndInsertImages([{ path: '/tmp/pane-b.png' }], { sessionId: 'session-b' });
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
});
it('shows the server reason in the toast when an upload fails', async () => {
const app = loadImageInputApp();
const reason = 'Prompt uploads are not supported for remote (SSH) sessions';
app._uploadPasteImage = vi.fn(async () => {
throw new Error(reason);
});
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
expect(app.showToast).toHaveBeenCalledWith(`1 failed: ${reason}`, 'error');
});
});