@pi-archimedes/image-paste 2.10.4 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,516 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { Editor, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ // The three clipboard readers are the seam: the dispatch under test decides
6
+ // *which* of them wins, and the real ones would shell out to wl-paste / xclip
7
+ // and open the X11 display during a unit test. `isImageFilePath` stays real —
8
+ // the no-double-attach case asserts its premise, so mocking it would be
9
+ // circular.
10
+ const { mockReadClipboardImage, mockReadClipboardFilePaths, mockReadClipboardText } = vi.hoisted(() => ({
11
+ mockReadClipboardImage: vi.fn(),
12
+ mockReadClipboardFilePaths: vi.fn(),
13
+ mockReadClipboardText: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("./clipboard.js", async (importOriginal) => {
17
+ const real = await importOriginal<typeof import("./clipboard.js")>();
18
+ return {
19
+ ...real,
20
+ readClipboardImage: mockReadClipboardImage,
21
+ readClipboardFilePaths: mockReadClipboardFilePaths,
22
+ readClipboardText: mockReadClipboardText,
23
+ };
24
+ });
25
+
26
+ import { createImageQueue, dispatchClipboardPaste, type ImageQueue } from "./index.js";
27
+ import { isImageFilePath } from "./clipboard.js";
28
+
29
+ // ── The editor is real, not a stub ──────────────────────────────────────────
30
+ //
31
+ // `ctx.ui.pasteToEditor` is pi's bracketed-paste wiring verbatim
32
+ // (interactive-mode.js:2075: `this.editor.handleInput("\x1b[200~" + text +
33
+ // "\x1b[201~")`) and `getEditorText` is :2077 (`getExpandedText?.() ??
34
+ // getText()`), so the assertions below read the text a real pi-tui `Editor`
35
+ // ends up holding — including the editor's own paste handling (its leading
36
+ // space for `/`-leading pastes, its large-paste collapse marker), not a
37
+ // hand-written approximation of it. Only the renderer is faked: `Editor`
38
+ // touches `tui.requestRender()` and `tui.terminal.rows` and nothing else on
39
+ // this path.
40
+ interface Harness {
41
+ ctx: ExtensionContext;
42
+ queue: ImageQueue;
43
+ editor: Editor;
44
+ inserted: string[];
45
+ /** Drafts the editor submitted (Enter) — a clipboard must never cause one. */
46
+ submitted: string[];
47
+ notifications: Array<[string, string | undefined]>;
48
+ }
49
+
50
+ function createHarness(options?: { editorText?: string }): Harness {
51
+ const editor = new Editor(
52
+ { requestRender: () => {}, terminal: { rows: 40, cols: 120 } } as unknown as TUI,
53
+ { borderColor: (s: string) => s, selectList: {} } as unknown as EditorTheme,
54
+ );
55
+ const submitted: string[] = [];
56
+ editor.onSubmit = (text: string) => {
57
+ submitted.push(text);
58
+ };
59
+ if (options?.editorText) {
60
+ editor.setText(options.editorText);
61
+ }
62
+
63
+ const inserted: string[] = [];
64
+ const notifications: Array<[string, string | undefined]> = [];
65
+ const ctx = {
66
+ hasUI: true,
67
+ ui: {
68
+ pasteToEditor: (text: string) => {
69
+ inserted.push(text);
70
+ editor.handleInput(`\x1b[200~${text}\x1b[201~`);
71
+ },
72
+ getEditorText: () => editor.getExpandedText?.() ?? editor.getText(),
73
+ notify: (message: string, type?: "info" | "warning" | "error") => {
74
+ notifications.push([message, type]);
75
+ },
76
+ },
77
+ } as unknown as ExtensionContext;
78
+
79
+ return { ctx, queue: createImageQueue(), editor, inserted, submitted, notifications };
80
+ }
81
+
82
+ function editorText(h: Harness): string {
83
+ return h.editor.getExpandedText?.() ?? h.editor.getText();
84
+ }
85
+
86
+ const PNG = { bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), mimeType: "image/png" };
87
+
88
+ describe("dispatchClipboardPaste: precedence image → file paths → text", () => {
89
+ it("attaches the image and never consults the path reader when both are on the clipboard", async () => {
90
+ const h = createHarness();
91
+ mockReadClipboardImage.mockResolvedValue(PNG);
92
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/also-on-clipboard.txt"]);
93
+ mockReadClipboardText.mockResolvedValue("also text");
94
+
95
+ await dispatchClipboardPaste(h.ctx, h.queue);
96
+
97
+ // The image branch wins *first*: the path reader is not even reached.
98
+ // This is the whole point of diverging from pi's native order, so the
99
+ // assertion is on the call, not just on the visible outcome.
100
+ expect(mockReadClipboardFilePaths).not.toHaveBeenCalled();
101
+ expect(mockReadClipboardText).not.toHaveBeenCalled();
102
+ expect(h.inserted).toEqual(["[Image #1] "]);
103
+ expect(h.notifications).toEqual([["Image attached from clipboard.", "info"]]);
104
+ expect(h.queue.images).toHaveLength(1);
105
+ expect(editorText(h)).toBe("[Image #1] ");
106
+ });
107
+
108
+ it("inserts clipboard file paths with pi's spacing when there is no image", async () => {
109
+ const h = createHarness({ editorText: "look at" });
110
+ mockReadClipboardImage.mockResolvedValue(null);
111
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/report.pdf"]);
112
+ mockReadClipboardText.mockResolvedValue("ignored text");
113
+
114
+ await dispatchClipboardPaste(h.ctx, h.queue);
115
+
116
+ // pi: character before the cursor is non-whitespace → one leading space;
117
+ // nothing after it → no trailing space (interactive-mode.js:2507-2511).
118
+ expect(h.inserted).toEqual([" /tmp/report.pdf"]);
119
+ expect(editorText(h)).toBe("look at /tmp/report.pdf");
120
+ expect(h.notifications).toEqual([]);
121
+ expect(h.queue.images).toHaveLength(0);
122
+ });
123
+
124
+ it("inserts clipboard text when there is neither an image nor file paths", async () => {
125
+ const h = createHarness({ editorText: "summarise " });
126
+ mockReadClipboardImage.mockResolvedValue(null);
127
+ mockReadClipboardFilePaths.mockResolvedValue(null);
128
+ mockReadClipboardText.mockResolvedValue("the pasted paragraph");
129
+
130
+ await dispatchClipboardPaste(h.ctx, h.queue);
131
+
132
+ expect(h.inserted).toEqual(["the pasted paragraph"]);
133
+ expect(editorText(h)).toBe("summarise the pasted paragraph");
134
+ expect(h.notifications).toEqual([]);
135
+ });
136
+
137
+ it("adds no leading space when the draft ends in a newline (the cursor is on a fresh line)", async () => {
138
+ const h = createHarness({ editorText: "look at\n" });
139
+ mockReadClipboardImage.mockResolvedValue(null);
140
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/report.pdf"]);
141
+
142
+ await dispatchClipboardPaste(h.ctx, h.queue);
143
+
144
+ // pi reads the cursor's own line, and that line is empty (:2506-2509), so
145
+ // the path lands bare on the second line — asserted against the real
146
+ // editor, which is what actually splits the paste across lines.
147
+ expect(h.inserted).toEqual(["/tmp/report.pdf"]);
148
+ expect(editorText(h)).toBe("look at\n/tmp/report.pdf");
149
+ });
150
+
151
+ it("inserts nothing and notifies nothing when the clipboard holds nothing (pi is silent here)", async () => {
152
+ const h = createHarness();
153
+ mockReadClipboardImage.mockResolvedValue(null);
154
+ mockReadClipboardFilePaths.mockResolvedValue(null);
155
+ mockReadClipboardText.mockResolvedValue(null);
156
+
157
+ await dispatchClipboardPaste(h.ctx, h.queue);
158
+
159
+ expect(h.inserted).toEqual([]);
160
+ expect(h.notifications).toEqual([]);
161
+ });
162
+
163
+ it("still pastes text when the image reader cannot run at all (no reader is not an empty clipboard)", async () => {
164
+ const h = createHarness();
165
+ mockReadClipboardImage.mockRejectedValue(
166
+ new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY."),
167
+ );
168
+ mockReadClipboardFilePaths.mockResolvedValue(null);
169
+ mockReadClipboardText.mockResolvedValue("text from a headless session");
170
+
171
+ await dispatchClipboardPaste(h.ctx, h.queue);
172
+
173
+ expect(h.inserted).toEqual(["text from a headless session"]);
174
+ expect(h.notifications).toEqual([]);
175
+ });
176
+
177
+ it("reports the reader failure when nothing else was on the clipboard", async () => {
178
+ const h = createHarness();
179
+ mockReadClipboardImage.mockRejectedValue(new Error("no clipboard reader available"));
180
+ mockReadClipboardFilePaths.mockResolvedValue(null);
181
+ mockReadClipboardText.mockResolvedValue(null);
182
+
183
+ await dispatchClipboardPaste(h.ctx, h.queue);
184
+
185
+ expect(h.inserted).toEqual([]);
186
+ expect(h.notifications).toEqual([["Clipboard paste failed: no clipboard reader available", "warning"]]);
187
+ });
188
+
189
+ it("keeps the size limit as a hard stop: an oversized image does not fall through to paths", async () => {
190
+ const h = createHarness();
191
+ mockReadClipboardImage.mockResolvedValue({
192
+ bytes: new Uint8Array(20 * 1024 * 1024 + 1),
193
+ mimeType: "image/png",
194
+ });
195
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/report.pdf"]);
196
+
197
+ await dispatchClipboardPaste(h.ctx, h.queue);
198
+
199
+ expect(h.inserted).toEqual([]);
200
+ expect(h.notifications[0]?.[0]).toMatch(/Image too large/);
201
+ expect(h.notifications[0]?.[1]).toBe("warning");
202
+ });
203
+ });
204
+
205
+ describe("dispatchClipboardPaste: pi's path branch details", () => {
206
+ it("refuses a path containing control characters instead of inserting it", async () => {
207
+ const h = createHarness();
208
+ mockReadClipboardImage.mockResolvedValue(null);
209
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/ok.txt", "/tmp/bad\nname.txt"]);
210
+
211
+ await dispatchClipboardPaste(h.ctx, h.queue);
212
+
213
+ // interactive-mode.js:2501-2503 throws rather than inserting; the user
214
+ // gets the failure, the editor gets nothing.
215
+ expect(h.inserted).toEqual([]);
216
+ expect(h.notifications).toEqual([
217
+ ["Clipboard paste failed: Clipboard file path contains control characters", "warning"],
218
+ ]);
219
+ });
220
+
221
+ it("inserts a copied image file as path text and attaches nothing (one branch per keypress)", async () => {
222
+ // Premise, from this package's own predicate: the path IS an image path.
223
+ expect(isImageFilePath("/tmp/screenshot.png")).toBe(true);
224
+
225
+ const h = createHarness();
226
+ mockReadClipboardImage.mockResolvedValue(null);
227
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/screenshot.png"]);
228
+
229
+ await dispatchClipboardPaste(h.ctx, h.queue);
230
+
231
+ // Intended behaviour (ADR 0030: the path branch matches pi): a file
232
+ // copied in the file manager is a *path* on the clipboard, not image
233
+ // bytes, so it is inserted as text and never read off disk. Because
234
+ // exactly one branch runs per keypress, a path can never be attached as
235
+ // well — nothing double-attaches, and the queue stays empty.
236
+ expect(h.inserted).toEqual(["/tmp/screenshot.png"]);
237
+ expect(editorText(h)).toBe("/tmp/screenshot.png");
238
+ expect(h.queue.images).toHaveLength(0);
239
+ expect(h.queue.markers).toHaveLength(0);
240
+ expect(h.notifications).toEqual([]);
241
+ });
242
+
243
+ it("joins a multi-path clipboard with newlines and keeps pi's surrounding spaces", async () => {
244
+ const h = createHarness({ editorText: "compare" });
245
+ mockReadClipboardImage.mockResolvedValue(null);
246
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/one.txt", "/tmp/two.txt", "/tmp/three.txt"]);
247
+
248
+ await dispatchClipboardPaste(h.ctx, h.queue);
249
+
250
+ expect(h.inserted).toEqual([" /tmp/one.txt\n/tmp/two.txt\n/tmp/three.txt"]);
251
+ expect(editorText(h)).toBe("compare /tmp/one.txt\n/tmp/two.txt\n/tmp/three.txt");
252
+ });
253
+
254
+ it("quotes and space-joins paths when the draft is a bash command (pi's isBashMode branch)", async () => {
255
+ const h = createHarness({ editorText: "!ls " });
256
+ mockReadClipboardImage.mockResolvedValue(null);
257
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/a file.txt", "/tmp/plain.txt"]);
258
+
259
+ await dispatchClipboardPaste(h.ctx, h.queue);
260
+
261
+ // interactive-mode.js:2504 quotes each path when the draft starts with "!"
262
+ // and joins with spaces, so the shell sees two arguments.
263
+ expect(h.inserted).toEqual(["'/tmp/a file.txt' /tmp/plain.txt"]);
264
+ expect(editorText(h)).toBe("!ls '/tmp/a file.txt' /tmp/plain.txt");
265
+ });
266
+
267
+ it("pins what a large multi-path clipboard looks like: the editor's own paste collapse", async () => {
268
+ // The one place where the available insertion mechanism differs from pi's.
269
+ // pi writes `${leading}${paths}${trailing}` with insertTextAtCursor, which
270
+ // it does not expose to extensions; `ctx.ui.pasteToEditor` is a bracketed
271
+ // paste, and pi-tui's editor collapses a paste of more than 10 lines or
272
+ // 1000 characters to a marker (components/editor.js:1094-1109). Measured,
273
+ // not assumed: 12 paths render as `compare [paste #1 +12 lines]` and
274
+ // `getExpandedText()` — what pi submits — returns all twelve paths, so the
275
+ // draft the model receives is identical to pi's and only the display text
276
+ // differs. 5 paths, under the threshold, are inserted literally.
277
+ const h = createHarness({ editorText: "compare " });
278
+ mockReadClipboardImage.mockResolvedValue(null);
279
+ mockReadClipboardFilePaths.mockResolvedValue(
280
+ Array.from({ length: 12 }, (_unused, index) => `/tmp/path-${index}.txt`),
281
+ );
282
+
283
+ await dispatchClipboardPaste(h.ctx, h.queue);
284
+
285
+ // No leading space: the draft ends in whitespace, so pi's rule adds none.
286
+ expect(h.inserted).toHaveLength(1);
287
+ expect(h.inserted[0]).toMatch(/^\/tmp\/path-0\.txt\n/);
288
+ expect(h.editor.getText()).toBe("compare [paste #1 +12 lines]");
289
+ expect(editorText(h)).toBe(`compare ${h.inserted.join("")}`);
290
+ });
291
+
292
+ it("inserts a five-path clipboard literally, under the editor's collapse threshold", async () => {
293
+ const h = createHarness({ editorText: "a" });
294
+ mockReadClipboardImage.mockResolvedValue(null);
295
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/p0.txt", "/tmp/p1.txt", "/tmp/p2.txt", "/tmp/p3.txt", "/tmp/p4.txt"]);
296
+
297
+ await dispatchClipboardPaste(h.ctx, h.queue);
298
+
299
+ expect(editorText(h)).toBe("a /tmp/p0.txt\n/tmp/p1.txt\n/tmp/p2.txt\n/tmp/p3.txt\n/tmp/p4.txt");
300
+ });
301
+
302
+ it("pins the known cursor limitation: the extension API exposes no cursor, so a mid-line paste loses pi's trailing space", async () => {
303
+ // A live check, and the honest record of the gap: `ExtensionUIContext`
304
+ // offers pasteToEditor / getEditorText / setEditorText and no cursor
305
+ // accessor (dist/core/extensions/types.d.ts:132-137), so the spacing rule
306
+ // can only be computed against the end of the draft. Here the cursor sits
307
+ // mid-line (after "alph"), where pi would also add a trailing space; we
308
+ // cannot see it and produce the run-on "x.txta" where pi produces
309
+ // "x.txt a". The rule itself is exact — see buildPathInsertion's
310
+ // cursor-mid-line case in clipboard.test.ts, which passes the cursor.
311
+ const h = createHarness({ editorText: "alpha" });
312
+ // "alpha" typed one character at a time leaves the cursor after the "a";
313
+ // move it back one grapheme so the paste lands mid-line.
314
+ h.editor.handleInput("\x1b[D");
315
+ mockReadClipboardImage.mockResolvedValue(null);
316
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/x.txt"]);
317
+
318
+ await dispatchClipboardPaste(h.ctx, h.queue);
319
+
320
+ expect(h.inserted).toEqual([" /tmp/x.txt"]);
321
+ expect(editorText(h)).toBe("alph /tmp/x.txta");
322
+ });
323
+ });
324
+
325
+ describe("dispatchClipboardPaste: clipboard text that is not plain text", () => {
326
+ // The text branch hands the clipboard to `ctx.ui.pasteToEditor`, which is
327
+ // pi's bracketed-paste wiring (`editor.handleInput("\x1b[200~" + text +
328
+ // "\x1b[201~")`, interactive-mode.js:2075). pi-tui ends a paste at the first
329
+ // `\x1b[201~` it finds and re-feeds the remainder of the chunk to
330
+ // `handleInput` (components/editor.js:569-590), so an escape byte that came
331
+ // *from the clipboard* ends the paste early — and the tail of the clipboard
332
+ // is lost or reinterpreted. pi's own text branch cannot lose it: it writes
333
+ // bytes with `insertTextAtCursor` (:2528). These cases pin what the
334
+ // available insert route does with the payloads that difference separates.
335
+ const withText = (text: string): void => {
336
+ mockReadClipboardImage.mockResolvedValue(null);
337
+ mockReadClipboardFilePaths.mockResolvedValue(null);
338
+ mockReadClipboardText.mockResolvedValue(text);
339
+ };
340
+
341
+ it("keeps the whole clipboard when it carries a paste-framing escape sequence", async () => {
342
+ const h = createHarness();
343
+ withText("hi\u001b[201~!echo pwned\r");
344
+
345
+ await dispatchClipboardPaste(h.ctx, h.queue);
346
+
347
+ // Measured without the fix: the draft ended up as "hi\n" — everything
348
+ // after the escape byte was swallowed by the paste-boundary break, which
349
+ // pi does not do for the same clipboard.
350
+ const draft = editorText(h);
351
+ expect(draft).toContain("hi");
352
+ expect(draft).toContain("echo pwned");
353
+ expect(draft).not.toContain("\u001b");
354
+ // And the break must not reach the editor as a keystroke: the draft is
355
+ // inserted, never submitted.
356
+ expect(h.submitted).toEqual([]);
357
+ expect(h.notifications).toEqual([]);
358
+ });
359
+
360
+ it("keeps the visible text of a payload with a bare escape byte", async () => {
361
+ const h = createHarness();
362
+ withText("hi\u001b[31mred");
363
+
364
+ await dispatchClipboardPaste(h.ctx, h.queue);
365
+
366
+ // The escape byte goes (pi keeps it raw — it can, we cannot); no other
367
+ // character is lost, and the parameter tail stays as the plain text it is.
368
+ expect(editorText(h)).toBe("hi[31mred");
369
+ expect(h.submitted).toEqual([]);
370
+ });
371
+
372
+ it("drops NUL and BEL the way a terminal paste does, and inserts the rest", async () => {
373
+ const h = createHarness();
374
+ withText("hi\u0000wo\u0007");
375
+
376
+ await dispatchClipboardPaste(h.ctx, h.queue);
377
+
378
+ // pi-tui's own paste filter removes characters below 0x20 except \n
379
+ // (components/editor.js:1070-1074), so the buffer is clean without the
380
+ // dispatch having to pre-filter — the divergence from pi here is that pi
381
+ // stores the raw bytes ("hi\u0000wo\u0007") and we do not.
382
+ expect(editorText(h)).toBe("hiwo");
383
+ expect(h.editor.getText()).not.toMatch(/[\u0000-\u0008\u000b-\u001f]/);
384
+ });
385
+
386
+ it("normalises CRLF and a trailing CR to newlines, as pi's insert route does", async () => {
387
+ const h = createHarness();
388
+ withText("a\r\nb\r");
389
+
390
+ await dispatchClipboardPaste(h.ctx, h.queue);
391
+
392
+ // normalizeText in pi-tui (components/editor.js:953-955) is shared by both
393
+ // insert routes, so the two agree here: measured "a\nb\n" either way.
394
+ expect(editorText(h)).toBe("a\nb\n");
395
+ });
396
+
397
+ it("keeps a trailing newline rather than trimming it, as pi does", async () => {
398
+ const h = createHarness({ editorText: "summarise " });
399
+ withText("hello\n");
400
+
401
+ await dispatchClipboardPaste(h.ctx, h.queue);
402
+
403
+ // pi inserts the clipboard verbatim (:2528) and a trailing newline from
404
+ // xclip/xsel (no --no-newline in pi's candidate for X11) is part of it.
405
+ expect(editorText(h)).toBe("summarise hello\n");
406
+ });
407
+
408
+ it("collapses a 1MB clipboard to the editor's paste marker and submits the whole thing", async () => {
409
+ const h = createHarness();
410
+ const oneMb = "line of text\n".repeat(80_000); // ~1.04 MB
411
+ withText(oneMb);
412
+
413
+ await dispatchClipboardPaste(h.ctx, h.queue);
414
+
415
+ // The draft shows the marker; what pi submits is the expanded text, so no
416
+ // character is lost — measured against the same payload through pi's
417
+ // insertTextAtCursor route, which holds it inline instead.
418
+ expect(h.editor.getText()).toMatch(/^\[paste #1 \+\d+ lines\]$/);
419
+ expect(editorText(h)).toBe(oneMb);
420
+ expect(h.submitted).toEqual([]);
421
+ });
422
+ });
423
+
424
+ describe("dispatchClipboardPaste: screen-mode independence", () => {
425
+ // The paste path is screen-mode independent because it never holds or
426
+ // touches a renderer: the only surfaces it uses are the three screen-safe
427
+ // extension UI methods. This is the mechanical statement of that claim —
428
+ // anything reaching for `tui`, an overlay or a component fails it, and the
429
+ // suite-wide renderer-hoarding guard (core/src/screen-mode-discipline.test.ts)
430
+ // covers the stored-reference half.
431
+ //
432
+ // What is under test is what the dispatch *reads*, so `ctx.ui` is replaced
433
+ // with a Proxy that records every property access and throws on any name
434
+ // outside the allow-list. (An earlier version asserted `Object.keys(ctx.ui)`
435
+ // — the shape of this harness, which the dispatch cannot widen, so a dispatch
436
+ // that started calling `ctx.ui.setWidget` passed it. Greptile on PR #123;
437
+ // the mutation `(ctx.ui as …).setStatus?.("k", "v")` in the dispatch turns
438
+ // these red and left that assertion green.)
439
+ const SCREEN_SAFE_SURFACES = ["getEditorText", "notify", "pasteToEditor"];
440
+
441
+ /** Replace `h.ctx.ui` with a recorder/guard and return the access log. */
442
+ function guardUiSurfaces(h: Harness): string[] {
443
+ const accessed: string[] = [];
444
+ const inner = h.ctx.ui as unknown as Record<string, unknown>;
445
+ (h.ctx as unknown as { ui: unknown }).ui = new Proxy(inner, {
446
+ get(target, property: string | symbol): unknown {
447
+ if (typeof property !== "string") return undefined;
448
+ accessed.push(property);
449
+ if (!SCREEN_SAFE_SURFACES.includes(property)) {
450
+ throw new Error(`ctx.ui.${property} is not a screen-safe surface`);
451
+ }
452
+ return target[property];
453
+ },
454
+ });
455
+ return accessed;
456
+ }
457
+
458
+ function surfacesUsed(accessed: string[]): string[] {
459
+ return [...new Set(accessed)].sort();
460
+ }
461
+
462
+ function assertNoGuardTrip(h: Harness): void {
463
+ // A tripped guard is swallowed by the dispatch's own catch and reported as
464
+ // a paste failure, so a clean run is part of the assertion.
465
+ expect(h.notifications.filter(([message]) => message.startsWith("Clipboard paste failed"))).toEqual([]);
466
+ }
467
+
468
+ it("reads only pasteToEditor and notify on the image branch", async () => {
469
+ const h = createHarness();
470
+ const accessed = guardUiSurfaces(h);
471
+ mockReadClipboardImage.mockResolvedValue(PNG);
472
+
473
+ await dispatchClipboardPaste(h.ctx, h.queue);
474
+
475
+ expect(surfacesUsed(accessed)).toEqual(["notify", "pasteToEditor"]);
476
+ assertNoGuardTrip(h);
477
+ });
478
+
479
+ it("reads only getEditorText and pasteToEditor on the file-path branch", async () => {
480
+ const h = createHarness({ editorText: "compare" });
481
+ const accessed = guardUiSurfaces(h);
482
+ mockReadClipboardImage.mockResolvedValue(null);
483
+ mockReadClipboardFilePaths.mockResolvedValue(["/tmp/one.txt"]);
484
+
485
+ await dispatchClipboardPaste(h.ctx, h.queue);
486
+
487
+ expect(surfacesUsed(accessed)).toEqual(["getEditorText", "pasteToEditor"]);
488
+ assertNoGuardTrip(h);
489
+ });
490
+
491
+ it("reads only pasteToEditor on the text branch", async () => {
492
+ const h = createHarness();
493
+ const accessed = guardUiSurfaces(h);
494
+ mockReadClipboardImage.mockResolvedValue(null);
495
+ mockReadClipboardFilePaths.mockResolvedValue(null);
496
+ mockReadClipboardText.mockResolvedValue("plain text");
497
+
498
+ await dispatchClipboardPaste(h.ctx, h.queue);
499
+
500
+ expect(surfacesUsed(accessed)).toEqual(["pasteToEditor"]);
501
+ assertNoGuardTrip(h);
502
+ });
503
+
504
+ it("reads no UI surface at all when the clipboard holds nothing", async () => {
505
+ const h = createHarness();
506
+ const accessed = guardUiSurfaces(h);
507
+ mockReadClipboardImage.mockResolvedValue(null);
508
+ mockReadClipboardFilePaths.mockResolvedValue(null);
509
+ mockReadClipboardText.mockResolvedValue(null);
510
+
511
+ await dispatchClipboardPaste(h.ctx, h.queue);
512
+
513
+ expect(accessed).toEqual([]);
514
+ assertNoGuardTrip(h);
515
+ });
516
+ });
@@ -0,0 +1,165 @@
1
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // The mutex lives in the shortcut handler in `index.ts` and the readers live in
4
+ // `clipboard.ts`, so this file deliberately mocks *neither*: the real dispatch
5
+ // runs against a faked clipboard helper and a faked command runner, which is the
6
+ // only way to ask the question that matters to a user — does `ctrl+v` come back?
7
+ const { mockGetNativeClipboard, mockSpawnSync } = vi.hoisted(() => ({
8
+ mockGetNativeClipboard: vi.fn(),
9
+ mockSpawnSync: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("@earendil-works/pi-tui", async (importOriginal) => {
13
+ const real = await importOriginal<typeof import("@earendil-works/pi-tui")>();
14
+ return { ...real, getNativeClipboard: mockGetNativeClipboard };
15
+ });
16
+
17
+ vi.mock("node:child_process", async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import("node:child_process")>();
19
+ return { ...actual, spawnSync: mockSpawnSync };
20
+ });
21
+
22
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
+
24
+ import { initImagePasteSession, registerImagePaste } from "./index.js";
25
+ import { NATIVE_READ_TIMEOUT_MS } from "./clipboard.js";
26
+
27
+ /** Every CLI candidate is missing, so the native helper is the only reader. */
28
+ function noCLITools(): void {
29
+ mockSpawnSync.mockImplementation((_command: string, _args: string[]) => {
30
+ const error = Object.assign(new Error("spawnSync ENOENT"), { code: "ENOENT" });
31
+ return { error, status: null, signal: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
32
+ });
33
+ }
34
+
35
+ interface Harness {
36
+ ctx: ExtensionContext;
37
+ press: () => Promise<void>;
38
+ inserted: string[];
39
+ notifications: Array<[string, string | undefined]>;
40
+ }
41
+
42
+ function createHarness(): Harness {
43
+ const inserted: string[] = [];
44
+ const notifications: Array<[string, string | undefined]> = [];
45
+ const ctx = {
46
+ hasUI: true,
47
+ ui: {
48
+ pasteToEditor: (text: string) => {
49
+ inserted.push(text);
50
+ },
51
+ getEditorText: () => "",
52
+ notify: (message: string, type?: "info" | "warning" | "error") => {
53
+ notifications.push([message, type]);
54
+ },
55
+ },
56
+ } as unknown as ExtensionContext;
57
+
58
+ const handlers = new Map<string, (context: ExtensionContext) => Promise<void>>();
59
+ const pi = {
60
+ registerMessageRenderer: () => {},
61
+ registerShortcut: (key: string, options: { handler: (context: ExtensionContext) => Promise<void> }) => {
62
+ handlers.set(key, options.handler);
63
+ },
64
+ on: () => {},
65
+ } as unknown as ExtensionAPI;
66
+
67
+ registerImagePaste(pi);
68
+ initImagePasteSession(ctx);
69
+ const pressKey = handlers.get("ctrl+v")!;
70
+ return { ctx, press: () => pressKey(ctx), inserted, notifications };
71
+ }
72
+
73
+ /** A helper whose every read is started and never answered. */
74
+ function hangingHelper(): {
75
+ getImage: () => Promise<Uint8Array>;
76
+ getText: () => Promise<string>;
77
+ getFilePaths: () => Promise<string[]>;
78
+ } {
79
+ return {
80
+ getImage: () => new Promise<Uint8Array>(() => {}),
81
+ getText: () => new Promise<string>(() => {}),
82
+ getFilePaths: () => new Promise<string[]>(() => {}),
83
+ };
84
+ }
85
+
86
+ describe("the ctrl+v mutex against a clipboard that never answers", () => {
87
+ const savedWayland = process.env.WAYLAND_DISPLAY;
88
+ const savedDisplay = process.env.DISPLAY;
89
+
90
+ beforeEach(() => {
91
+ vi.clearAllMocks();
92
+ noCLITools();
93
+ // A graphical session, so the readers are attempted rather than skipped.
94
+ process.env.WAYLAND_DISPLAY = "wayland-1";
95
+ delete process.env.DISPLAY;
96
+ });
97
+
98
+ afterAll(() => {
99
+ process.env.WAYLAND_DISPLAY = savedWayland;
100
+ process.env.DISPLAY = savedDisplay;
101
+ });
102
+
103
+ it("settles the keypress and releases the key when the helper never answers", async () => {
104
+ vi.useFakeTimers();
105
+ try {
106
+ mockGetNativeClipboard.mockReturnValue(hangingHelper());
107
+ const h = createHarness();
108
+
109
+ const press = h.press();
110
+ // Nothing to wait for but our own deadline.
111
+ await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS / 2);
112
+ expect(h.inserted).toEqual([]);
113
+
114
+ // The user presses again while the first read is still outstanding: the
115
+ // mutex is allowed to swallow this one, but not forever.
116
+ const whileBusy = h.press();
117
+ await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS / 2);
118
+ await whileBusy;
119
+ expect(h.inserted).toEqual([]);
120
+
121
+ await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS * 3);
122
+ // RED without a deadline on the native read: this await never finishes,
123
+ // the promise stays pending, and every later press is dropped by the
124
+ // `_pasting` mutex — `ctrl+v` is dead until the session restarts, with no
125
+ // message saying so (measured live: first press still pending after
126
+ // 7.1 s, both later presses silently swallowed, nothing notified).
127
+ await expect(press).resolves.toBeUndefined();
128
+
129
+ // And the key works again: the next press reaches the readers.
130
+ mockGetNativeClipboard.mockReturnValue({
131
+ getImage: async () => null,
132
+ getText: async () => "text after the hang",
133
+ getFilePaths: async () => undefined,
134
+ });
135
+ await h.press();
136
+ expect(h.inserted).toEqual(["text after the hang"]);
137
+ } finally {
138
+ vi.useRealTimers();
139
+ }
140
+ }, 30_000);
141
+
142
+ it("reports a bounded failure rather than silence when the image read times out and nothing else answers", async () => {
143
+ vi.useFakeTimers();
144
+ try {
145
+ mockGetNativeClipboard.mockReturnValue(hangingHelper());
146
+ const h = createHarness();
147
+
148
+ const press = h.press();
149
+ await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS * 3);
150
+ await press;
151
+
152
+ // pi's dispatcher would have sat in that call forever; ours finishes.
153
+ // A helper that never answers is a failed read, not an empty clipboard,
154
+ // so the user hears about it — and hears something truer than "install a
155
+ // clipboard reader", which is what the missing-reader path says.
156
+ expect(h.inserted).toEqual([]);
157
+ expect(h.notifications).toHaveLength(1);
158
+ expect(h.notifications[0]?.[0]).toMatch(/timed out/i);
159
+ expect(h.notifications[0]?.[0]).not.toMatch(/No Linux clipboard image reader/);
160
+ expect(h.notifications[0]?.[1]).toBe("warning");
161
+ } finally {
162
+ vi.useRealTimers();
163
+ }
164
+ }, 30_000);
165
+ });