@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,220 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // ── The shortcut gate: `archimedes.imagePaste.suppressNativePaste: false` ────
4
+ //
5
+ // ADR 0030's amendment promises that turning the flag off "hands the key back to
6
+ // pi". Removing our `app.clipboard.pasteImage` unbinding from
7
+ // ~/.pi/agent/keybindings.json is only half of that: pi 1.2.0 keeps an extension
8
+ // shortcut that collides with a NON-reserved built-in (`app.clipboard.pasteImage`
9
+ // is absent from `RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS`, so
10
+ // `restrictOverride === false` and `ExtensionRunner.getShortcuts` prints
11
+ // "… Using <ext>" and keeps ours — `dist/core/extensions/runner.js:467-474`), and
12
+ // `CustomEditor.handleInput` consults `onExtensionShortcut` BEFORE its
13
+ // `app.clipboard.pasteImage` branch and returns (`dist/modes/interactive/
14
+ // components/custom-editor.js:63-70`). So while the shortcut is registered the
15
+ // restored native binding is unreachable and the flag buys nothing but a shortcut
16
+ // conflict line in `[Extension issues]`. The other half of the promise is
17
+ // therefore ours: with the flag off, `registerImagePaste` must not claim the key.
18
+ //
19
+ // The mock replaces the whole pi module (vitest requires every used export):
20
+ // `getAgentDir` for the settings read, `convertToPng` for clipboard.ts.
21
+ // `core/settings-io` captures `join(getAgentDir(), "settings.json")` at module
22
+ // load (settings-io.ts:6), hence one scratch dir for the whole file, rewritten
23
+ // per case.
24
+
25
+ const { tempDir, fs, join } = vi.hoisted(() => {
26
+ const fs = require("node:fs");
27
+ const { join } = require("node:path");
28
+ const { tmpdir } = require("node:os");
29
+ const { randomUUID } = require("node:crypto");
30
+ const dir = join(tmpdir(), `image-paste-shortcut-gate-${randomUUID()}`);
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ return { tempDir: dir, fs, join };
33
+ });
34
+
35
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
36
+ getAgentDir: () => tempDir,
37
+ convertToPng: () => undefined,
38
+ }));
39
+
40
+ const { registerImagePaste } = await import("./index.js");
41
+ const defaultExport = (await import("./index.js")).default;
42
+
43
+ // ── fixtures ────────────────────────────────────────────────────────────────
44
+
45
+ const NAMESPACE = "archimedes.imagePaste";
46
+
47
+ /** `getImagePasteShortcuts()` as it stands today, per platform. */
48
+ const ALL_PASTE_SHORTCUTS: string[] =
49
+ process.platform === "win32" ? ["alt+v", "ctrl+alt+v"] : ["ctrl+v", "alt+v", "ctrl+alt+v"];
50
+
51
+ const settingsPath = (): string => join(tempDir, "settings.json");
52
+
53
+ function writeSettings(imagePaste: Record<string, unknown> | undefined): void {
54
+ const settings: Record<string, unknown> = {};
55
+ if (imagePaste !== undefined) settings[NAMESPACE] = imagePaste;
56
+ fs.writeFileSync(settingsPath(), JSON.stringify(settings), "utf-8");
57
+ }
58
+
59
+ interface FakePi {
60
+ on: ReturnType<typeof vi.fn>;
61
+ registerShortcut: ReturnType<typeof vi.fn>;
62
+ registerMessageRenderer: ReturnType<typeof vi.fn>;
63
+ registerCommand: ReturnType<typeof vi.fn>;
64
+ sendMessage: ReturnType<typeof vi.fn>;
65
+ }
66
+
67
+ function fakePi(): FakePi {
68
+ return {
69
+ on: vi.fn(),
70
+ registerShortcut: vi.fn(),
71
+ registerMessageRenderer: vi.fn(),
72
+ registerCommand: vi.fn(),
73
+ sendMessage: vi.fn(),
74
+ };
75
+ }
76
+
77
+ /** Every key `registerImagePaste` handed to `pi.registerShortcut`. */
78
+ function registeredKeys(pi: FakePi): string[] {
79
+ return pi.registerShortcut.mock.calls.map((call) => String(call[0]));
80
+ }
81
+
82
+ beforeEach(() => {
83
+ // No settings file at all is the "flag absent" default.
84
+ try {
85
+ fs.unlinkSync(settingsPath());
86
+ } catch {
87
+ // absent
88
+ }
89
+ });
90
+
91
+ // ── the gate ────────────────────────────────────────────────────────────────
92
+
93
+ describe("image-paste shortcut registration is gated on suppressNativePaste", () => {
94
+ it("registers NO shortcut at all when suppressNativePaste is false (the key goes back to pi)", () => {
95
+ writeSettings({ suppressNativePaste: false });
96
+ const pi = fakePi();
97
+
98
+ registerImagePaste(pi as never);
99
+
100
+ expect(registeredKeys(pi)).toEqual([]);
101
+ expect(pi.registerShortcut).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("registers every paste shortcut when the setting is absent (today's behaviour, unchanged)", () => {
105
+ const pi = fakePi();
106
+
107
+ registerImagePaste(pi as never);
108
+
109
+ expect(registeredKeys(pi).sort()).toEqual([...ALL_PASTE_SHORTCUTS].sort());
110
+ });
111
+
112
+ it("registers every paste shortcut when suppressNativePaste is true", () => {
113
+ writeSettings({ suppressNativePaste: true });
114
+ const pi = fakePi();
115
+
116
+ registerImagePaste(pi as never);
117
+
118
+ expect(registeredKeys(pi).sort()).toEqual([...ALL_PASTE_SHORTCUTS].sort());
119
+ });
120
+
121
+ it("treats a value that is not the boolean false as today's behaviour", () => {
122
+ // Same strict-comparison rule as the restore: `"false"` and `0` are not
123
+ // `false`, so they must not silently free the key.
124
+ for (const value of ["false", 0, null, "", "no", {}]) {
125
+ fs.rmSync(settingsPath(), { force: true });
126
+ writeSettings({ suppressNativePaste: value });
127
+ const pi = fakePi();
128
+
129
+ registerImagePaste(pi as never);
130
+
131
+ expect(registeredKeys(pi).sort(), `suppressNativePaste=${JSON.stringify(value)}`).toEqual(
132
+ [...ALL_PASTE_SHORTCUTS].sort(),
133
+ );
134
+ }
135
+ });
136
+
137
+ it("re-reads the setting on EVERY registration (per-session gate, never a module-level flag)", () => {
138
+ // pi caches the extension FACTORY (`extensionCache`, `dist/core/extensions/
139
+ // loader.js:461-489`) but re-invokes it for each session, and the composed
140
+ // suite calls `registerImagePaste` from its own `session_start` handler. So
141
+ // the decision must be taken inside the function: a value computed once at
142
+ // module scope (the `#106`-era bug) makes every later session inherit the
143
+ // first session's answer. Each call below stands for one session start.
144
+ writeSettings({ suppressNativePaste: false });
145
+ const off = fakePi();
146
+ registerImagePaste(off as never);
147
+ expect(registeredKeys(off)).toEqual([]);
148
+
149
+ writeSettings({ suppressNativePaste: true });
150
+ const on = fakePi();
151
+ registerImagePaste(on as never);
152
+ expect(registeredKeys(on).sort(), "a later session must get the shortcuts back").toEqual(
153
+ [...ALL_PASTE_SHORTCUTS].sort(),
154
+ );
155
+
156
+ writeSettings({ suppressNativePaste: false });
157
+ const offAgain = fakePi();
158
+ registerImagePaste(offAgain as never);
159
+ expect(registeredKeys(offAgain), "and must give them up again").toEqual([]);
160
+ });
161
+
162
+ it("leaves no other route from a key or command to the paste path when the flag is off", () => {
163
+ // "No half-states": nothing may still call our paste path on the freed key.
164
+ // The only entry point the package has is `pi.registerShortcut`, so the
165
+ // fence is (a) no shortcut for any of the paste keys and (b) no command
166
+ // alias registered at all.
167
+ writeSettings({ suppressNativePaste: false });
168
+ const pi = fakePi();
169
+
170
+ registerImagePaste(pi as never);
171
+
172
+ for (const key of ALL_PASTE_SHORTCUTS) {
173
+ const clash = pi.registerShortcut.mock.calls.filter((call) => call[0] === key);
174
+ expect(clash, `ctrl+v-class key ${key} still registered`).toEqual([]);
175
+ }
176
+ expect(pi.registerCommand).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it("keeps the rest of the feature registered while the key is handed back", () => {
180
+ // The goal is the key, not deleting the feature: the submit-time marker
181
+ // attachment and the preview renderer stay wired, and the per-session queue
182
+ // lifecycle keeps running (no orphaned state left pointing at a key that no
183
+ // longer exists).
184
+ writeSettings({ suppressNativePaste: false });
185
+ const pi = fakePi();
186
+
187
+ registerImagePaste(pi as never);
188
+
189
+ const events = pi.on.mock.calls.map((call) => String(call[0]));
190
+ expect(events).toContain("input");
191
+ expect(pi.registerMessageRenderer).toHaveBeenCalled();
192
+ });
193
+ });
194
+
195
+ describe("image-paste standalone factory — same gate, same call site", () => {
196
+ it("registers no shortcut from the default export when suppressNativePaste is false", () => {
197
+ // A standalone install goes through the default export, which calls
198
+ // `registerImagePaste`; gating only meta's call site would leave this path
199
+ // claiming the key.
200
+ writeSettings({ suppressNativePaste: false });
201
+ const pi = fakePi();
202
+
203
+ defaultExport(pi as never);
204
+
205
+ expect(pi.registerShortcut).not.toHaveBeenCalled();
206
+ expect(pi.registerMessageRenderer).toHaveBeenCalled();
207
+ const events = pi.on.mock.calls.map((call) => String(call[0]));
208
+ expect(events).toEqual(
209
+ expect.arrayContaining(["input", "session_start", "session_shutdown"]),
210
+ );
211
+ });
212
+
213
+ it("registers the shortcuts from the default export when the flag is absent", () => {
214
+ const pi = fakePi();
215
+
216
+ defaultExport(pi as never);
217
+
218
+ expect(registeredKeys(pi).sort()).toEqual([...ALL_PASTE_SHORTCUTS].sort());
219
+ });
220
+ });