@pi-archimedes/image-paste 2.10.3 → 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.
- package/README.md +11 -3
- package/package.json +4 -4
- package/src/clipboard.test.ts +497 -3
- package/src/clipboard.ts +311 -4
- package/src/default-export.test.ts +90 -2
- package/src/index.ts +176 -35
- package/src/keybinding-offer.test.ts +1671 -10
- package/src/keybinding-offer.ts +741 -38
- package/src/paste-dispatch.test.ts +516 -0
- package/src/paste-mutex.test.ts +165 -0
- package/src/shortcut-gate.test.ts +220 -0
|
@@ -43,18 +43,36 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
43
43
|
// re-exported from the package root.
|
|
44
44
|
type Mode = "tui" | "rpc" | "json" | "print";
|
|
45
45
|
|
|
46
|
-
const {
|
|
46
|
+
const {
|
|
47
|
+
offerKeybindingFix,
|
|
48
|
+
restoreNativePaste,
|
|
49
|
+
runNativePasteRestore,
|
|
50
|
+
suiteMayClaimPasteKey,
|
|
51
|
+
CREATED_NOTIFY,
|
|
52
|
+
CREATED_RELOAD_HINT,
|
|
53
|
+
RESTORED_NOTIFY,
|
|
54
|
+
RESTORED_RELOAD_HINT,
|
|
55
|
+
} = await import("./keybinding-offer.js");
|
|
56
|
+
|
|
57
|
+
import type {
|
|
58
|
+
NativePasteRestoreOutcome,
|
|
59
|
+
RestoreNativePasteStatus,
|
|
60
|
+
UntouchedReason,
|
|
61
|
+
} from "./keybinding-offer.js";
|
|
47
62
|
|
|
48
63
|
// ── fixtures ─────────────────────────────────────────────────────────────────
|
|
49
64
|
|
|
50
65
|
const NAMESPACE = "archimedes.imagePaste";
|
|
51
66
|
/** The exact snippet from packages/image-paste/README.md → "Paste shortcuts". */
|
|
52
|
-
const SNIPPET =
|
|
67
|
+
const SNIPPET =
|
|
68
|
+
'{ "$schema": "https://pi.dev/schemas/keybindings.schema.json", "app.clipboard.pasteImage": [] }';
|
|
53
69
|
/** What a concurrent process writes into keybindings.json at rename time. */
|
|
54
70
|
const CONCURRENT_CONTENT = '{ "app.clipboard.pasteImage": ["ctrl+v"] }';
|
|
55
71
|
|
|
56
72
|
|
|
57
73
|
const settingsPath = (): string => join(tempDir, "settings.json");
|
|
74
|
+
/** The title the offer passes to `ctx.ui.confirm` (title first, per Pi API). */
|
|
75
|
+
const CONFIRM_TITLE_FOR_TEST = "First run";
|
|
58
76
|
const keybindingsPath = (): string => join(tempDir, "keybindings.json");
|
|
59
77
|
const tmpPath = (): string => `${keybindingsPath()}.${process.pid}.tmp`;
|
|
60
78
|
|
|
@@ -77,7 +95,13 @@ function readFlag(): boolean {
|
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
function cleanupArtifacts(): void {
|
|
80
|
-
for (const p of [
|
|
98
|
+
for (const p of [
|
|
99
|
+
settingsPath(),
|
|
100
|
+
settingsPath() + ".tmp",
|
|
101
|
+
keybindingsPath(),
|
|
102
|
+
`${keybindingsPath()}.tmp`,
|
|
103
|
+
tmpPath(),
|
|
104
|
+
]) {
|
|
81
105
|
try {
|
|
82
106
|
fs.rmSync(p, { recursive: true, force: true });
|
|
83
107
|
} catch {
|
|
@@ -86,19 +110,20 @@ function cleanupArtifacts(): void {
|
|
|
86
110
|
}
|
|
87
111
|
}
|
|
88
112
|
|
|
89
|
-
function makeCtx(mode: Mode, opts?: { withReload?: boolean }) {
|
|
113
|
+
function makeCtx(mode: Mode, opts?: { withReload?: boolean; hasUI?: boolean }) {
|
|
90
114
|
const confirm = vi.fn();
|
|
91
115
|
const notify = vi.fn();
|
|
92
116
|
// Intentionally mirrors the base ExtensionContext the real session_start
|
|
93
|
-
// handler receives: no command-only methods like `reload
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
117
|
+
// handler receives: no command-only methods like `reload`. Measured on Pi
|
|
118
|
+
// 1.2.0 in a real session — the notify the restore produced was the
|
|
119
|
+
// "run /reload to apply" hint, i.e. the event ctx had no `reload` (it is
|
|
120
|
+
// attached to the command context only, `runner.js` `createCommandContext`).
|
|
121
|
+
// The auto-reload branch is covered separately by passing { withReload: true },
|
|
122
|
+
// which simulates a future Pi version that puts reload() on the event ctx.
|
|
98
123
|
const reload = opts?.withReload ? vi.fn(async () => {}) : undefined;
|
|
99
124
|
const ctx = {
|
|
100
125
|
mode,
|
|
101
|
-
hasUI: true,
|
|
126
|
+
hasUI: opts?.hasUI ?? true,
|
|
102
127
|
ui: { confirm, notify },
|
|
103
128
|
...(reload ? { reload } : {}),
|
|
104
129
|
} as unknown as ExtensionContext;
|
|
@@ -313,6 +338,30 @@ describe("file write throws → flag stays false (self-heals next session)", ()
|
|
|
313
338
|
});
|
|
314
339
|
});
|
|
315
340
|
|
|
341
|
+
describe("the offer text tells the truth about the clash", () => {
|
|
342
|
+
it("does not promise a double-fire: with both bindings live Pi runs ONLY the extension shortcut", async () => {
|
|
343
|
+
// Measured on Pi 1.2.0 in a scratch agent dir with a stubbed clipboard
|
|
344
|
+
// (`keybindings.json` = `{}`, i.e. the built-in live, our shortcut
|
|
345
|
+
// registered): one `ctrl+v` produced exactly one insertion — ours
|
|
346
|
+
// (`[Image #1]` + "Image attached from clipboard.", no `pi-clipboard-*.png`
|
|
347
|
+
// temp file) — plus the `[Extension issues]` line "… Using <ext>".
|
|
348
|
+
// `CustomEditor.handleInput` consults `onExtensionShortcut` and returns
|
|
349
|
+
// before its `app.clipboard.pasteImage` branch (`custom-editor.js:63-70`),
|
|
350
|
+
// so nothing ever double-fired and the dialog must not say it does.
|
|
351
|
+
const { ctx, confirm } = makeCtx("tui");
|
|
352
|
+
confirm.mockResolvedValue(false);
|
|
353
|
+
|
|
354
|
+
await offerKeybindingFix(ctx);
|
|
355
|
+
|
|
356
|
+
expect(confirm).toHaveBeenCalledTimes(1);
|
|
357
|
+
const [title, message] = confirm.mock.calls[0] as [string, string];
|
|
358
|
+
expect(title).toBe(CONFIRM_TITLE_FOR_TEST);
|
|
359
|
+
expect(message).not.toMatch(/double-?fir/i);
|
|
360
|
+
expect(message).toMatch(/never runs its own action/i);
|
|
361
|
+
expect(message).toContain("Extension issues");
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
316
365
|
describe("flag write throws → notify, no crash (file write already succeeded)", () => {
|
|
317
366
|
it("yes with an unwritable settings target: file kept, flag unset, both notifies, no throw", async () => {
|
|
318
367
|
// Make settings.json an unwritable target: a directory (rename + direct
|
|
@@ -537,3 +586,1615 @@ describe("concurrent creation is never clobbered", () => {
|
|
|
537
586
|
).toBe(false);
|
|
538
587
|
});
|
|
539
588
|
});
|
|
589
|
+
|
|
590
|
+
// ── the $schema line in the generated artefact ───────────────────────────
|
|
591
|
+
//
|
|
592
|
+
// What the file is FOR, not just that it was written. `$schema` points at pi's
|
|
593
|
+
// published schema (https://pi.dev/schemas/keybindings.schema.json, which
|
|
594
|
+
// declares `"$schema": { "type": "string" }` and an `$id` of the same URL), so
|
|
595
|
+
// an editor or a standalone validator can check the file. It is
|
|
596
|
+
// forward-compatibility and tooling, NOT startup validation: pi 1.2.0 validates
|
|
597
|
+
// *themes* at startup (`setThemeJsonValidator`, dist/main.js:452) and ships
|
|
598
|
+
// `KeybindingsSchema` (dist/core/keybindings-schema.js:13) with nothing
|
|
599
|
+
// importing it. pi's own loader skips `$schema` outright
|
|
600
|
+
// (`if (key === "$schema") continue;`, dist/core/keybindings.js:247-248), which
|
|
601
|
+
// is what makes the extra key safe to write: the unbinding still applies.
|
|
602
|
+
|
|
603
|
+
describe("generated snippet carries pi's published $schema", () => {
|
|
604
|
+
it("the written file parses to $schema plus the unbinding, and nothing else", async () => {
|
|
605
|
+
writeSettings({ [NAMESPACE]: { enabled: true } });
|
|
606
|
+
const { ctx, confirm } = makeCtx("tui");
|
|
607
|
+
confirm.mockResolvedValue(true);
|
|
608
|
+
|
|
609
|
+
await offerKeybindingFix(ctx);
|
|
610
|
+
|
|
611
|
+
const raw = fs.readFileSync(keybindingsPath(), "utf-8");
|
|
612
|
+
expect(JSON.parse(raw)).toEqual({
|
|
613
|
+
$schema: "https://pi.dev/schemas/keybindings.schema.json",
|
|
614
|
+
"app.clipboard.pasteImage": [],
|
|
615
|
+
});
|
|
616
|
+
// Byte-exact, because the README quotes this line verbatim and a reader
|
|
617
|
+
// copy-pasting it should get exactly what the suite writes.
|
|
618
|
+
expect(raw).toBe(SNIPPET);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
623
|
+
// restoreNativePaste() — ADR 0030's Amendment (2026-10-13)
|
|
624
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
625
|
+
//
|
|
626
|
+
// The user who accepted the first-run offer holds a keybindings.json that
|
|
627
|
+
// leaves pi's native `app.clipboard.pasteImage` UNBOUND. `suppressNativePaste:
|
|
628
|
+
// false` has to give the key back, which means deleting our own unbinding from
|
|
629
|
+
// a file that already exists — the narrow exception to gate 4's "never rewrite
|
|
630
|
+
// an existing user keybindings file" rule.
|
|
631
|
+
//
|
|
632
|
+
// ACCEPT-SET DECISION, recorded here in the test names (the amendment demands an
|
|
633
|
+
// explicit choice): the accept-set is exactly the two artefacts this suite has
|
|
634
|
+
// ever written, and nothing else —
|
|
635
|
+
// * pre-#123: { "app.clipboard.pasteImage": [] }
|
|
636
|
+
// * since #123: { "$schema": "https://pi.dev/schemas/keybindings.schema.json",
|
|
637
|
+
// "app.clipboard.pasteImage": [] }
|
|
638
|
+
// `$schema` counts as ours ONLY when it carries pi's published URL (that is
|
|
639
|
+
// literally what the suite writes); any other second key is foreign. The set is
|
|
640
|
+
// never widened to "any object containing app.clipboard.pasteImage: []".
|
|
641
|
+
|
|
642
|
+
const UNBIND_KEY = "app.clipboard.pasteImage";
|
|
643
|
+
const SCHEMA_URL = "https://pi.dev/schemas/keybindings.schema.json";
|
|
644
|
+
/** What the suite wrote before PR #123 added `$schema`. */
|
|
645
|
+
const ARTEFACT_PRE_123 = `{ "${UNBIND_KEY}": [] }`;
|
|
646
|
+
/** Byte-for-byte what `offerKeybindingFix` writes today (SNIPPET above). */
|
|
647
|
+
const ARTEFACT_POST_123 = `{ "$schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [] }`;
|
|
648
|
+
|
|
649
|
+
/** The `globalThis` guard key the surfacing rule mandates (contract clause 5). */
|
|
650
|
+
const NOTICE_KEY = Symbol.for("pi-archimedes:image-paste:restore-notified");
|
|
651
|
+
|
|
652
|
+
function clearNoticeGuard(): void {
|
|
653
|
+
delete (globalThis as Record<symbol, unknown>)[NOTICE_KEY];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Ask for the key back: `suppressNativePaste: false`. */
|
|
657
|
+
function askForNativePaste(): void {
|
|
658
|
+
writeSettings({ [NAMESPACE]: { suppressNativePaste: false } });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function writeKeybindings(content: string): void {
|
|
662
|
+
fs.writeFileSync(keybindingsPath(), content, "utf-8");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Narrow a status, failing loudly with the kind actually seen. */
|
|
666
|
+
function asUntouched(s: RestoreNativePasteStatus | NativePasteRestoreOutcome) {
|
|
667
|
+
if (s.kind !== "untouched") {
|
|
668
|
+
return expect.fail(`expected status "untouched", got "${s.kind}"`);
|
|
669
|
+
}
|
|
670
|
+
return s as Extract<RestoreNativePasteStatus, { kind: "untouched" }>;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
describe("restoreNativePaste — the accept-set (only our own artefact is rewritten)", () => {
|
|
674
|
+
beforeEach(clearNoticeGuard);
|
|
675
|
+
|
|
676
|
+
it("accept-set INCLUDES the pre-#123 artefact { app.clipboard.pasteImage: [] }: rewritten to {} and a reload is required", () => {
|
|
677
|
+
writeKeybindings(ARTEFACT_PRE_123);
|
|
678
|
+
|
|
679
|
+
const status = restoreNativePaste();
|
|
680
|
+
|
|
681
|
+
expect(status.kind).toBe("restored");
|
|
682
|
+
expect(status.path).toBe(keybindingsPath());
|
|
683
|
+
// The rewrite only ever REMOVES the key: the file is left as an empty object.
|
|
684
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
685
|
+
// Nothing asked for yet — the caller owns the reload, so the status is what
|
|
686
|
+
// tells it that pi must re-read keybindings.json.
|
|
687
|
+
expect(
|
|
688
|
+
(status as Extract<RestoreNativePasteStatus, { kind: "restored" }>)
|
|
689
|
+
.reloadRequired,
|
|
690
|
+
).toBe(true);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("accept-set INCLUDES the $schema + app.clipboard.pasteImage: [] pair — byte-for-byte what the suite writes since PR #123", () => {
|
|
694
|
+
// The decision the amendment asks for explicitly: `$schema` is OUR key when
|
|
695
|
+
// it points at pi's published schema, because that is the file we wrote.
|
|
696
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
697
|
+
|
|
698
|
+
const status = restoreNativePaste();
|
|
699
|
+
|
|
700
|
+
expect(status.kind).toBe("restored");
|
|
701
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("accept-set does NOT widen: a $schema carrying any other URL is a foreign key, so the file is untouched byte-for-byte", () => {
|
|
705
|
+
const foreign = `{ "$schema": "https://example.com/keybindings.schema.json", "${UNBIND_KEY}": [] }`;
|
|
706
|
+
writeKeybindings(foreign);
|
|
707
|
+
|
|
708
|
+
const status = asUntouched(restoreNativePaste());
|
|
709
|
+
|
|
710
|
+
expect(status.reason).toBe("extra-key");
|
|
711
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("accept-set does NOT widen to 'any object containing app.clipboard.pasteImage: []': an extra user key means untouched, and the status carries the path plus the one-line manual edit", () => {
|
|
715
|
+
const foreign = `{ "$schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
|
|
716
|
+
writeKeybindings(foreign);
|
|
717
|
+
|
|
718
|
+
const status = asUntouched(restoreNativePaste());
|
|
719
|
+
|
|
720
|
+
// The status is actionable on its own: path + the exact edit to make.
|
|
721
|
+
expect(status.path).toBe(keybindingsPath());
|
|
722
|
+
expect(status.manualEdit).toContain(keybindingsPath());
|
|
723
|
+
expect(status.manualEdit).toContain(UNBIND_KEY);
|
|
724
|
+
expect(status.manualEdit).not.toContain("\n");
|
|
725
|
+
// Byte-for-byte untouched — no rewrite, no reformatting, no trailing newline.
|
|
726
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
|
|
727
|
+
// And the never-touch path leaves no write artefact behind.
|
|
728
|
+
expect(fs.existsSync(`${keybindingsPath()}.tmp`)).toBe(false);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("a non-empty app.clipboard.pasteImage value is the user's binding, not our artefact: untouched", () => {
|
|
732
|
+
const owned = `{ "${UNBIND_KEY}": ["ctrl+v"] }`;
|
|
733
|
+
writeKeybindings(owned);
|
|
734
|
+
|
|
735
|
+
const status = asUntouched(restoreNativePaste());
|
|
736
|
+
|
|
737
|
+
expect(status.reason).toBe("binding-not-empty");
|
|
738
|
+
expect(status.manualEdit).toContain(UNBIND_KEY);
|
|
739
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(owned);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("content that does not parse is left byte-for-byte alone and never throws", () => {
|
|
743
|
+
const raw = `{ "${UNBIND_KEY}": [] // hand-edited, not valid JSON`;
|
|
744
|
+
writeKeybindings(raw);
|
|
745
|
+
|
|
746
|
+
let status: RestoreNativePasteStatus | undefined;
|
|
747
|
+
expect(() => {
|
|
748
|
+
status = restoreNativePaste();
|
|
749
|
+
}).not.toThrow();
|
|
750
|
+
|
|
751
|
+
expect(asUntouched(status!).reason).toBe("unparsable");
|
|
752
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(raw);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("JSON that is not an object (an array) is not our artefact: untouched", () => {
|
|
756
|
+
const raw = `["${UNBIND_KEY}"]`;
|
|
757
|
+
writeKeybindings(raw);
|
|
758
|
+
|
|
759
|
+
expect(asUntouched(restoreNativePaste()).reason).toBe("not-an-object");
|
|
760
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(raw);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("a missing keybindings.json is a no-op: nothing written, status 'missing'", () => {
|
|
764
|
+
expect(fs.existsSync(keybindingsPath())).toBe(false);
|
|
765
|
+
|
|
766
|
+
const status = restoreNativePaste();
|
|
767
|
+
|
|
768
|
+
expect(status.kind).toBe("missing");
|
|
769
|
+
expect(fs.existsSync(keybindingsPath())).toBe(false);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("a keybindings.json that cannot be read (a directory where the file should be) is 'untouched (unreadable)', never a throw", () => {
|
|
773
|
+
// The one branch the contract did not name: the extension must not crash on
|
|
774
|
+
// it and must not try to write over it.
|
|
775
|
+
fs.mkdirSync(keybindingsPath(), { recursive: true });
|
|
776
|
+
|
|
777
|
+
let status: RestoreNativePasteStatus | undefined;
|
|
778
|
+
expect(() => {
|
|
779
|
+
status = restoreNativePaste();
|
|
780
|
+
}).not.toThrow();
|
|
781
|
+
|
|
782
|
+
const untouched = asUntouched(status!);
|
|
783
|
+
expect(untouched.reason).toBe("unreadable");
|
|
784
|
+
// Issue #127: the file could not be read, so nothing in it can be named.
|
|
785
|
+
// The guidance names the file and hands the reading back to the user — it
|
|
786
|
+
// does not order them to delete an entry we never saw.
|
|
787
|
+
expect(untouched.manualEdit).toContain(keybindingsPath());
|
|
788
|
+
expect(untouched.manualEdit).not.toContain(UNBIND_KEY);
|
|
789
|
+
expect(untouched.manualEdit).not.toMatch(/remove|delete/i);
|
|
790
|
+
expect(fs.statSync(keybindingsPath()).isDirectory()).toBe(true);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("restoreNativePaste is silent by itself — it takes no ctx (nothing to notify with) and writes no log line", () => {
|
|
794
|
+
// Contract clause 2: "It returns a status; it never logs or notifies
|
|
795
|
+
// itself." Surfacing belongs to the caller, so one session cannot produce
|
|
796
|
+
// two messages and a headless caller stays silent on its own.
|
|
797
|
+
expect(restoreNativePaste.length).toBe(0);
|
|
798
|
+
|
|
799
|
+
const logFile = join(tempDir, `diag-${process.pid}-silent.log`);
|
|
800
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
801
|
+
process.env.PI_ARCHIMEDES_LOG = logFile;
|
|
802
|
+
let status: RestoreNativePasteStatus | undefined;
|
|
803
|
+
try {
|
|
804
|
+
status = restoreNativePaste();
|
|
805
|
+
} finally {
|
|
806
|
+
delete process.env.PI_ARCHIMEDES_LOG;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// It did the work …
|
|
810
|
+
expect(status!.kind).toBe("restored");
|
|
811
|
+
// … and recorded nothing: the log sink stays untouched (it is never created).
|
|
812
|
+
expect(fs.existsSync(logFile)).toBe(false);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("a file already reading {} is 'already-restored' and is NOT rewritten — bytes and mtime both untouched", () => {
|
|
816
|
+
// Deliberately NOT the bytes we would write, so a rewrite that produces the
|
|
817
|
+
// same *value* is still caught, and a fixed old mtime catches a rewrite that
|
|
818
|
+
// would produce identical bytes.
|
|
819
|
+
const raw = "{\n}\n";
|
|
820
|
+
writeKeybindings(raw);
|
|
821
|
+
const stamp = new Date("2020-01-01T12:00:00.000Z");
|
|
822
|
+
fs.utimesSync(keybindingsPath(), stamp, stamp);
|
|
823
|
+
|
|
824
|
+
const status = restoreNativePaste();
|
|
825
|
+
|
|
826
|
+
expect(status.kind).toBe("already-restored");
|
|
827
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(raw);
|
|
828
|
+
expect(fs.statSync(keybindingsPath()).mtimeMs).toBe(stamp.getTime());
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("a symlinked keybindings.json stays a symlink: the link survives, its target is replaced atomically (new inode, {} content), and no tmp file lands beside the link", () => {
|
|
832
|
+
// The amendment's consequence: the write must go through
|
|
833
|
+
// writeAtomicPreservingSymlinks, not the offer's plain tmp+rename-onto-path
|
|
834
|
+
// (which would replace the link with a regular file in the agent dir) and
|
|
835
|
+
// not a plain writeFileSync (which would write in place, same inode).
|
|
836
|
+
const realDir = join(tempDir, `link-target-${process.pid}`);
|
|
837
|
+
const realPath = join(realDir, "keybindings.json");
|
|
838
|
+
fs.mkdirSync(realDir, { recursive: true });
|
|
839
|
+
fs.writeFileSync(realPath, ARTEFACT_POST_123, "utf-8");
|
|
840
|
+
fs.symlinkSync(realPath, keybindingsPath());
|
|
841
|
+
const inodeBefore = fs.statSync(realPath).ino;
|
|
842
|
+
|
|
843
|
+
const status = restoreNativePaste();
|
|
844
|
+
|
|
845
|
+
expect(status.kind).toBe("restored");
|
|
846
|
+
// Still a symlink, still pointing where the user pointed it.
|
|
847
|
+
expect(fs.lstatSync(keybindingsPath()).isSymbolicLink()).toBe(true);
|
|
848
|
+
expect(fs.readlinkSync(keybindingsPath())).toBe(realPath);
|
|
849
|
+
// The content change landed on the TARGET, and via rename (a new inode),
|
|
850
|
+
// i.e. atomically — not as an in-place write.
|
|
851
|
+
expect(fs.readFileSync(realPath, "utf-8")).toBe("{}");
|
|
852
|
+
expect(fs.statSync(realPath).ino).not.toBe(inodeBefore);
|
|
853
|
+
expect(fs.existsSync(`${realPath}.tmp`)).toBe(false);
|
|
854
|
+
expect(fs.existsSync(`${keybindingsPath()}.tmp`)).toBe(false);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("a write that fails yields 'failed' with the path and the manual edit, never throws, and leaves nothing behind", () => {
|
|
858
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
859
|
+
// Deterministic write failure, for every uid: a DIRECTORY sits where the
|
|
860
|
+
// atomic writer puts its temp file, so `writeFileSync(tmp)` throws EISDIR,
|
|
861
|
+
// the rename is never attempted, and the live file must survive.
|
|
862
|
+
//
|
|
863
|
+
// This replaced `chmodSync(tempDir, 0o555)`, which is uid-dependent: a root
|
|
864
|
+
// process ignores the mode bit (CAP_DAC_OVERRIDE), so under root the write
|
|
865
|
+
// SUCCEEDED and this test failed against correct code with
|
|
866
|
+
// `expected status "failed", got "restored"` — reproduced by neutralising
|
|
867
|
+
// the chmod to 0o755 and watching exactly that failure.
|
|
868
|
+
const blockedTmp = `${keybindingsPath()}.tmp`;
|
|
869
|
+
fs.mkdirSync(blockedTmp, { recursive: true });
|
|
870
|
+
let status: RestoreNativePasteStatus | undefined;
|
|
871
|
+
try {
|
|
872
|
+
expect(() => {
|
|
873
|
+
status = restoreNativePaste();
|
|
874
|
+
}).not.toThrow();
|
|
875
|
+
} finally {
|
|
876
|
+
fs.rmSync(blockedTmp, { recursive: true, force: true });
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const failed = status!;
|
|
880
|
+
if (failed.kind !== "failed") {
|
|
881
|
+
return expect.fail(`expected status "failed", got "${failed.kind}"`);
|
|
882
|
+
}
|
|
883
|
+
expect(failed.path).toBe(keybindingsPath());
|
|
884
|
+
expect(failed.error.length).toBeGreaterThan(0);
|
|
885
|
+
expect(failed.manualEdit).toContain(keybindingsPath());
|
|
886
|
+
expect(failed.manualEdit).toContain(UNBIND_KEY);
|
|
887
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(ARTEFACT_POST_123);
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// ── the accept-set, probed at its edges ─────────────────────────────────────
|
|
892
|
+
//
|
|
893
|
+
// These are the inputs an adversarial (or merely hand-editing) user can put in
|
|
894
|
+
// front of the restore. Each was measured, not reasoned about: the accept-set is
|
|
895
|
+
// a parse-based test, so the questions are what the parse yields and what the
|
|
896
|
+
// bytes look like afterwards.
|
|
897
|
+
|
|
898
|
+
describe("restoreNativePaste — accept-set edges (measured)", () => {
|
|
899
|
+
beforeEach(clearNoticeGuard);
|
|
900
|
+
|
|
901
|
+
it("the $schema URL is compared exactly: a different case, scheme or trailing slash is a foreign key", () => {
|
|
902
|
+
for (const url of [
|
|
903
|
+
"HTTPS://PI.DEV/schemas/keybindings.schema.json",
|
|
904
|
+
"http://pi.dev/schemas/keybindings.schema.json",
|
|
905
|
+
`${SCHEMA_URL}/`,
|
|
906
|
+
`${SCHEMA_URL}?v=2`,
|
|
907
|
+
]) {
|
|
908
|
+
const foreign = `{ "$schema": "${url}", "${UNBIND_KEY}": [] }`;
|
|
909
|
+
writeKeybindings(foreign);
|
|
910
|
+
|
|
911
|
+
expect(asUntouched(restoreNativePaste()).reason, url).toBe("extra-key");
|
|
912
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8"), url).toBe(foreign);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("a differently-cased $Schema key is not our key either", () => {
|
|
917
|
+
const foreign = `{ "$Schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [] }`;
|
|
918
|
+
writeKeybindings(foreign);
|
|
919
|
+
|
|
920
|
+
expect(asUntouched(restoreNativePaste()).reason).toBe("extra-key");
|
|
921
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("a nested object as the second key is foreign, however innocent it looks", () => {
|
|
925
|
+
const foreign = `{ "${UNBIND_KEY}": [], "nested": { "a": { "b": 1 } } }`;
|
|
926
|
+
writeKeybindings(foreign);
|
|
927
|
+
|
|
928
|
+
expect(asUntouched(restoreNativePaste()).reason).toBe("extra-key");
|
|
929
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("a $schema with no binding at all is not an artefact (pi's paste is already live)", () => {
|
|
933
|
+
const onlySchema = `{ "$schema": "${SCHEMA_URL}" }`;
|
|
934
|
+
writeKeybindings(onlySchema);
|
|
935
|
+
|
|
936
|
+
expect(asUntouched(restoreNativePaste()).reason).toBe("extra-key");
|
|
937
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(onlySchema);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("a UTF-8 BOM leaves the file untouched: JSON.parse rejects it, so the restore is STRICTER than pi", () => {
|
|
941
|
+
// Worth naming the divergence: pi strips a BOM before parsing
|
|
942
|
+
// (`stripBom`, dist/core/keybindings.js `loadRawConfig`), so to PI this
|
|
943
|
+
// file is a live unbinding — but the restore will not act on bytes it did
|
|
944
|
+
// not write. The safe direction: never delete what we cannot identify.
|
|
945
|
+
const bommed = `\uFEFF${ARTEFACT_POST_123}`;
|
|
946
|
+
writeKeybindings(bommed);
|
|
947
|
+
|
|
948
|
+
const status = asUntouched(restoreNativePaste());
|
|
949
|
+
|
|
950
|
+
expect(status.reason).toBe("unparsable");
|
|
951
|
+
// Same as the unreadable case (issue #127): bytes that do not parse cannot
|
|
952
|
+
// tell us what the key is bound to, so the guidance says "check this file"
|
|
953
|
+
// rather than naming a line to delete.
|
|
954
|
+
expect(status.manualEdit).toContain(keybindingsPath());
|
|
955
|
+
expect(status.manualEdit).not.toContain(UNBIND_KEY);
|
|
956
|
+
expect(status.manualEdit).not.toMatch(/remove|delete/i);
|
|
957
|
+
// Byte-for-byte, BOM included.
|
|
958
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(bommed);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("JSON that parses to null / false / 0 / a string is 'not-an-object' and is never rewritten", () => {
|
|
962
|
+
for (const raw of ["null", "false", "0", `"${UNBIND_KEY}"`, "[[]]"]) {
|
|
963
|
+
writeKeybindings(raw);
|
|
964
|
+
|
|
965
|
+
expect(asUntouched(restoreNativePaste()).reason, raw).toBe("not-an-object");
|
|
966
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8"), raw).toBe(raw);
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("a __proto__ key is foreign and is not treated as a prototype (no mutation, no rewrite)", () => {
|
|
971
|
+
const weird = `{ "__proto__": [], "${UNBIND_KEY}": [] }`;
|
|
972
|
+
writeKeybindings(weird);
|
|
973
|
+
|
|
974
|
+
expect(asUntouched(restoreNativePaste()).reason).toBe("extra-key");
|
|
975
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(weird);
|
|
976
|
+
expect(({} as Record<string, unknown>)[UNBIND_KEY]).toBeUndefined();
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("the accept-set is parse-based, so whitespace, layout and duplicate keys still count as ours", () => {
|
|
980
|
+
// JSON.parse takes the LAST duplicate, which is exactly what pi's own
|
|
981
|
+
// loader sees, so acting on the parsed value is consistent with pi.
|
|
982
|
+
const spaced = `{ "$schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [ ] } \n\n`;
|
|
983
|
+
writeKeybindings(spaced);
|
|
984
|
+
expect(restoreNativePaste().kind).toBe("restored");
|
|
985
|
+
|
|
986
|
+
writeKeybindings(`{\n "$schema": "${SCHEMA_URL}",\n "${UNBIND_KEY}": [\n ]\n}\n`);
|
|
987
|
+
expect(restoreNativePaste().kind).toBe("restored");
|
|
988
|
+
|
|
989
|
+
writeKeybindings(`{ "$schema": "${SCHEMA_URL}", "${UNBIND_KEY}": [], "${UNBIND_KEY}": [] }`);
|
|
990
|
+
expect(restoreNativePaste().kind).toBe("restored");
|
|
991
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("the one sharp edge of a parse-based accept-set: a duplicate key carrying a real binding is shadowed, and pi reads it the same way", () => {
|
|
995
|
+
// `{ pasteImage: ["ctrl+v"], pasteImage: [] }` parses to `[]` for US and
|
|
996
|
+
// for PI alike (last duplicate wins), so the `ctrl+v` in the raw bytes is
|
|
997
|
+
// already dead before we run: rewriting to `{}` removes a binding pi was
|
|
998
|
+
// never going to honour. Documented here because it is the only case where
|
|
999
|
+
// bytes containing a non-empty binding are replaced.
|
|
1000
|
+
const shadowed = `{ "${UNBIND_KEY}": ["ctrl+v"], "${UNBIND_KEY}": [] }`;
|
|
1001
|
+
writeKeybindings(shadowed);
|
|
1002
|
+
|
|
1003
|
+
expect(restoreNativePaste().kind).toBe("restored");
|
|
1004
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it("a keybindings.json symlinked OUTSIDE the agent dir stays a symlink and the target is replaced atomically", () => {
|
|
1008
|
+
// The dotfiles-checkout case: `~/.pi/agent/keybindings.json ->
|
|
1009
|
+
// ~/dotfiles/pi/keybindings.json`, a path with nothing to do with the agent
|
|
1010
|
+
// dir. The link must survive and the write must land on the target.
|
|
1011
|
+
const dotfiles = join(tempDir, `..-dotfiles-${process.pid}`);
|
|
1012
|
+
const realPath = join(dotfiles, "keybindings.json");
|
|
1013
|
+
fs.mkdirSync(dotfiles, { recursive: true });
|
|
1014
|
+
try {
|
|
1015
|
+
fs.writeFileSync(realPath, ARTEFACT_POST_123, "utf-8");
|
|
1016
|
+
fs.symlinkSync(realPath, keybindingsPath());
|
|
1017
|
+
const inodeBefore = fs.statSync(realPath).ino;
|
|
1018
|
+
|
|
1019
|
+
const status = restoreNativePaste();
|
|
1020
|
+
|
|
1021
|
+
expect(status.kind).toBe("restored");
|
|
1022
|
+
expect(fs.lstatSync(keybindingsPath()).isSymbolicLink()).toBe(true);
|
|
1023
|
+
expect(fs.readlinkSync(keybindingsPath())).toBe(realPath);
|
|
1024
|
+
expect(fs.readFileSync(realPath, "utf-8")).toBe("{}");
|
|
1025
|
+
// Written beside the target and renamed onto it — not in the agent dir.
|
|
1026
|
+
expect(fs.statSync(realPath).ino).not.toBe(inodeBefore);
|
|
1027
|
+
expect(fs.existsSync(`${realPath}.tmp`)).toBe(false);
|
|
1028
|
+
expect(fs.existsSync(`${keybindingsPath()}.tmp`)).toBe(false);
|
|
1029
|
+
// No stray file was created in the agent dir either.
|
|
1030
|
+
expect(fs.readdirSync(tempDir).filter((f: string) => f.startsWith("keybindings"))).toEqual([
|
|
1031
|
+
"keybindings.json",
|
|
1032
|
+
]);
|
|
1033
|
+
} finally {
|
|
1034
|
+
fs.rmSync(dotfiles, { recursive: true, force: true });
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it("a DANGLING symlink is 'missing': nothing is written through the link and the link is left alone", () => {
|
|
1039
|
+
// `existsSync` follows the link, so an unresolvable target — including a
|
|
1040
|
+
// symlink LOOP — is `missing`, not `failed` and not a write-through.
|
|
1041
|
+
const outside = join(tempDir, `dangling-${process.pid}`);
|
|
1042
|
+
const target = join(outside, "keybindings.json");
|
|
1043
|
+
fs.mkdirSync(outside, { recursive: true });
|
|
1044
|
+
try {
|
|
1045
|
+
fs.symlinkSync(target, keybindingsPath());
|
|
1046
|
+
|
|
1047
|
+
const status = restoreNativePaste();
|
|
1048
|
+
|
|
1049
|
+
expect(status.kind).toBe("missing");
|
|
1050
|
+
expect(status.path).toBe(keybindingsPath());
|
|
1051
|
+
expect(fs.lstatSync(keybindingsPath()).isSymbolicLink()).toBe(true);
|
|
1052
|
+
// The restore must not resurrect a file the user's link points at.
|
|
1053
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
1054
|
+
expect(fs.readdirSync(outside)).toEqual([]);
|
|
1055
|
+
} finally {
|
|
1056
|
+
fs.rmSync(keybindingsPath(), { force: true });
|
|
1057
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("a symlink to a DIRECTORY is 'unreadable' and the directory is left alone", () => {
|
|
1062
|
+
const dir = join(tempDir, `kb-is-dir-${process.pid}`);
|
|
1063
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1064
|
+
try {
|
|
1065
|
+
fs.symlinkSync(dir, keybindingsPath());
|
|
1066
|
+
|
|
1067
|
+
expect(asUntouched(restoreNativePaste()).reason).toBe("unreadable");
|
|
1068
|
+
expect(fs.statSync(dir).isDirectory()).toBe(true);
|
|
1069
|
+
} finally {
|
|
1070
|
+
fs.rmSync(keybindingsPath(), { force: true });
|
|
1071
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
describe("runNativePasteRestore — the caller surfaces the outcome (once per process, reload-stable)", () => {
|
|
1077
|
+
beforeEach(clearNoticeGuard);
|
|
1078
|
+
|
|
1079
|
+
it("the DEFAULT (suppressNativePaste absent) path writes nothing and notifies nothing", async () => {
|
|
1080
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
1081
|
+
const { ctx, notify } = makeCtx("tui", { withReload: true });
|
|
1082
|
+
|
|
1083
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1084
|
+
|
|
1085
|
+
expect(outcome.kind).toBe("suppressed");
|
|
1086
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(ARTEFACT_POST_123);
|
|
1087
|
+
expect(notify).not.toHaveBeenCalled();
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("an explicit suppressNativePaste: true behaves exactly like the default", async () => {
|
|
1091
|
+
writeSettings({ [NAMESPACE]: { suppressNativePaste: true } });
|
|
1092
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
1093
|
+
const { ctx, notify } = makeCtx("tui", { withReload: true });
|
|
1094
|
+
|
|
1095
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1096
|
+
|
|
1097
|
+
expect(outcome.kind).toBe("suppressed");
|
|
1098
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(ARTEFACT_POST_123);
|
|
1099
|
+
expect(notify).not.toHaveBeenCalled();
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it("the suppressed path does not even READ keybindings.json — a directory in its place is still 'suppressed'", async () => {
|
|
1103
|
+
// Bytes-plus-mtime can only prove "no write". This proves "no read": if the
|
|
1104
|
+
// suppressed path opened the path at all it would hit EISDIR and report
|
|
1105
|
+
// `untouched (unreadable)`; reporting `suppressed` means the settings check
|
|
1106
|
+
// returned before any filesystem access, so a default install never touches
|
|
1107
|
+
// the user's keybinding file, in any shape it happens to be in.
|
|
1108
|
+
fs.mkdirSync(keybindingsPath(), { recursive: true });
|
|
1109
|
+
const { ctx, notify } = makeCtx("tui", { withReload: true });
|
|
1110
|
+
|
|
1111
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1112
|
+
|
|
1113
|
+
expect(outcome.kind).toBe("suppressed");
|
|
1114
|
+
expect(notify).not.toHaveBeenCalled();
|
|
1115
|
+
expect(fs.statSync(keybindingsPath()).isDirectory()).toBe(true);
|
|
1116
|
+
fs.rmSync(keybindingsPath(), { recursive: true, force: true });
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it("a value that is not strictly false does not ask for the key back (\"false\", 0, null, \"off\")", async () => {
|
|
1120
|
+
// Same rule as `isConfigEnabled`: strict comparison, so a stringified
|
|
1121
|
+
// "false" from a hand-edited settings.json keeps the default behaviour.
|
|
1122
|
+
for (const value of ["false", 0, null, "off"]) {
|
|
1123
|
+
writeSettings({ [NAMESPACE]: { suppressNativePaste: value } });
|
|
1124
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
1125
|
+
const stamp = new Date("2020-06-07T08:09:10.000Z");
|
|
1126
|
+
fs.utimesSync(keybindingsPath(), stamp, stamp);
|
|
1127
|
+
const { ctx, notify } = makeCtx("tui", { withReload: true });
|
|
1128
|
+
|
|
1129
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1130
|
+
|
|
1131
|
+
expect(outcome.kind, JSON.stringify(value)).toBe("suppressed");
|
|
1132
|
+
expect(notify, JSON.stringify(value)).not.toHaveBeenCalled();
|
|
1133
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8"), JSON.stringify(value)).toBe(
|
|
1134
|
+
ARTEFACT_POST_123,
|
|
1135
|
+
);
|
|
1136
|
+
expect(fs.statSync(keybindingsPath()).mtimeMs, JSON.stringify(value)).toBe(stamp.getTime());
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
it("the suite-owned artefact becomes {} and a reload is requested (reload on the ctx → reload() runs and the status no longer requires /reload)", async () => {
|
|
1141
|
+
askForNativePaste();
|
|
1142
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
1143
|
+
const { ctx, notify, reload } = makeCtx("tui", { withReload: true });
|
|
1144
|
+
|
|
1145
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1146
|
+
|
|
1147
|
+
expect(outcome.kind).toBe("restored");
|
|
1148
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
1149
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
1150
|
+
if (outcome.kind !== "restored") return expect.fail("expected restored");
|
|
1151
|
+
// The reload ran, so pi has re-read keybindings.json: nothing left to ask.
|
|
1152
|
+
expect(outcome.reloadRequired).toBe(false);
|
|
1153
|
+
expect(
|
|
1154
|
+
callArgs(notify).some((c) => c[0] === RESTORED_NOTIFY && c[1] === "info"),
|
|
1155
|
+
).toBe(true);
|
|
1156
|
+
expect(CREATED_NOTIFY).toContain("reloading now");
|
|
1157
|
+
expect(RESTORED_NOTIFY).toContain("reloading now");
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it("on a ctx WITHOUT reload the restored status says /reload is required and the hint is notified", async () => {
|
|
1161
|
+
askForNativePaste();
|
|
1162
|
+
writeKeybindings(ARTEFACT_PRE_123);
|
|
1163
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1164
|
+
|
|
1165
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1166
|
+
|
|
1167
|
+
if (outcome.kind !== "restored") return expect.fail(`expected restored, got ${outcome.kind}`);
|
|
1168
|
+
expect(outcome.reloadRequired).toBe(true);
|
|
1169
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
1170
|
+
expect(
|
|
1171
|
+
callArgs(notify).some((c) => c[0] === RESTORED_RELOAD_HINT && c[1] === "info"),
|
|
1172
|
+
).toBe(true);
|
|
1173
|
+
expect(RESTORED_RELOAD_HINT).toContain("/reload");
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it("an untouched file is notified exactly once, with a message naming the file path and app.clipboard.pasteImage", async () => {
|
|
1177
|
+
askForNativePaste();
|
|
1178
|
+
const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
|
|
1179
|
+
writeKeybindings(foreign);
|
|
1180
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1181
|
+
|
|
1182
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1183
|
+
|
|
1184
|
+
expect(outcome.kind).toBe("untouched");
|
|
1185
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
1186
|
+
const [message, level] = notify.mock.calls[0] as [string, string];
|
|
1187
|
+
expect(level).toBe("warning");
|
|
1188
|
+
expect(message).toContain(keybindingsPath());
|
|
1189
|
+
expect(message).toContain(UNBIND_KEY);
|
|
1190
|
+
// Byte-for-byte unchanged, and no rewrite attempt of any kind.
|
|
1191
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("the untouched warning also says the paste key is left unbound, because the flag took our shortcut with it", async () => {
|
|
1195
|
+
// Since the shortcut gate exists, an untouched file is not merely "we did
|
|
1196
|
+
// not help you": with `suppressNativePaste: false` image-paste registers no
|
|
1197
|
+
// paste shortcut either, so between the warning and the user's edit the key
|
|
1198
|
+
// does NOTHING. Saying only "remove the entry and run /reload" would let
|
|
1199
|
+
// the user conclude that image-paste broke the key.
|
|
1200
|
+
//
|
|
1201
|
+
// Scoped deliberately to the refusal where that sentence is TRUE — a file
|
|
1202
|
+
// that still carries our `[]` unbinding. The no-entry refusals assert the
|
|
1203
|
+
// opposite wording (see "the refuse warning says what this file actually
|
|
1204
|
+
// holds").
|
|
1205
|
+
askForNativePaste();
|
|
1206
|
+
const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
|
|
1207
|
+
writeKeybindings(foreign);
|
|
1208
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1209
|
+
|
|
1210
|
+
await runNativePasteRestore(ctx);
|
|
1211
|
+
|
|
1212
|
+
const [message] = notify.mock.calls[0] as [string, string];
|
|
1213
|
+
expect(message).toMatch(/not binding the paste key/i);
|
|
1214
|
+
expect(message).toMatch(/does nothing/i);
|
|
1215
|
+
expect(message).toContain(UNBIND_KEY);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it("a plain second session_start in the same process stays silent for the same untouched file", async () => {
|
|
1219
|
+
askForNativePaste();
|
|
1220
|
+
const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
|
|
1221
|
+
writeKeybindings(foreign);
|
|
1222
|
+
|
|
1223
|
+
const first = makeCtx("tui");
|
|
1224
|
+
await runNativePasteRestore(first.ctx);
|
|
1225
|
+
expect(first.notify).toHaveBeenCalledTimes(1);
|
|
1226
|
+
|
|
1227
|
+
const second = makeCtx("tui");
|
|
1228
|
+
const outcome = await runNativePasteRestore(second.ctx);
|
|
1229
|
+
|
|
1230
|
+
expect(outcome.kind).toBe("untouched");
|
|
1231
|
+
expect(second.notify).not.toHaveBeenCalled();
|
|
1232
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it("the once-guard is RELOAD-STABLE, not merely per-session: after vi.resetModules() + re-import (what /reload does to the module registry) the caller still does not notify again", async () => {
|
|
1236
|
+
// A module-level `let` passes the "second session_start" test above and
|
|
1237
|
+
// STILL re-warns here, because pi re-evaluates the extension module on
|
|
1238
|
+
// every load: loadExtensionModule() builds a fresh jiti with
|
|
1239
|
+
// `moduleCache: false` (pi 1.2.0 dist/core/extensions/loader.js:477-481) and
|
|
1240
|
+
// ctx.reload() clears the cached factory first (dist/core/resource-loader.js:359
|
|
1241
|
+
// → clearExtensionCache(), dist/core/extensions/loader.js:89-93).
|
|
1242
|
+
// `globalThis` is the only thing that survives it — so the guard lives under
|
|
1243
|
+
// Symbol.for("pi-archimedes:image-paste:restore-notified").
|
|
1244
|
+
askForNativePaste();
|
|
1245
|
+
const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
|
|
1246
|
+
writeKeybindings(foreign);
|
|
1247
|
+
|
|
1248
|
+
const first = makeCtx("tui");
|
|
1249
|
+
await runNativePasteRestore(first.ctx);
|
|
1250
|
+
expect(first.notify).toHaveBeenCalledTimes(1);
|
|
1251
|
+
|
|
1252
|
+
// /reload: a brand-new module instance, same process, same globalThis.
|
|
1253
|
+
vi.resetModules();
|
|
1254
|
+
const reloaded = await import("./keybinding-offer.js");
|
|
1255
|
+
expect(reloaded.runNativePasteRestore).not.toBe(runNativePasteRestore);
|
|
1256
|
+
|
|
1257
|
+
const second = makeCtx("tui");
|
|
1258
|
+
await reloaded.runNativePasteRestore(second.ctx);
|
|
1259
|
+
|
|
1260
|
+
expect(second.notify).not.toHaveBeenCalled();
|
|
1261
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it("a reload that re-fires session_start does NOT recurse: the second pass reports already-restored and never reloads again", async () => {
|
|
1265
|
+
// pi's own reload path: `AgentSession.reload()` emits `session_start`
|
|
1266
|
+
// (`reason: "reload"`, pi 1.2.0 dist/core/agent-session.js:2998) and
|
|
1267
|
+
// interactive mode calls `keybindings.reload()` only after it returns
|
|
1268
|
+
// (dist/modes/interactive/interactive-mode.js:5406). So a restore that
|
|
1269
|
+
// reloads re-enters this call — and must terminate. It does, because the
|
|
1270
|
+
// first pass leaves `{}`, which is reported without a rewrite.
|
|
1271
|
+
askForNativePaste();
|
|
1272
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
1273
|
+
|
|
1274
|
+
const reloads: number[] = [];
|
|
1275
|
+
const passes: string[] = [];
|
|
1276
|
+
const runPass = async (depth: number): Promise<void> => {
|
|
1277
|
+
const notify = vi.fn();
|
|
1278
|
+
const ctx = {
|
|
1279
|
+
mode: "tui",
|
|
1280
|
+
hasUI: true,
|
|
1281
|
+
ui: { confirm: vi.fn(), notify },
|
|
1282
|
+
reload: async () => {
|
|
1283
|
+
reloads.push(depth);
|
|
1284
|
+
await runPass(depth + 1);
|
|
1285
|
+
},
|
|
1286
|
+
} as unknown as ExtensionContext;
|
|
1287
|
+
|
|
1288
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1289
|
+
passes.push(`${outcome.kind}:${notify.mock.calls.length}`);
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
await runPass(0);
|
|
1293
|
+
|
|
1294
|
+
// Exactly one reload, exactly two passes, one rewrite, no notify on the
|
|
1295
|
+
// second pass (the inner pass reports first — the reload is awaited inside
|
|
1296
|
+
// the outer call). A restore that rewrote `{}` instead of recognising it
|
|
1297
|
+
// would reload forever and never reach this assertion.
|
|
1298
|
+
expect(reloads).toEqual([0]);
|
|
1299
|
+
expect(passes).toEqual(["already-restored:0", "restored:1"]);
|
|
1300
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
it("the guard lives on globalThis under the mandated symbol key", async () => {
|
|
1304
|
+
askForNativePaste();
|
|
1305
|
+
writeKeybindings(`{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`);
|
|
1306
|
+
expect((globalThis as Record<symbol, unknown>)[NOTICE_KEY]).toBeUndefined();
|
|
1307
|
+
|
|
1308
|
+
await runNativePasteRestore(makeCtx("tui").ctx);
|
|
1309
|
+
|
|
1310
|
+
expect((globalThis as Record<symbol, unknown>)[NOTICE_KEY]).toBe(true);
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
it("no notify at all when ctx.hasUI is false — and a headless run does not spend the guard", async () => {
|
|
1314
|
+
askForNativePaste();
|
|
1315
|
+
const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
|
|
1316
|
+
writeKeybindings(foreign);
|
|
1317
|
+
|
|
1318
|
+
const headless = makeCtx("rpc", { hasUI: false });
|
|
1319
|
+
const headlessOutcome = await runNativePasteRestore(headless.ctx);
|
|
1320
|
+
expect(headlessOutcome.kind).toBe("untouched");
|
|
1321
|
+
expect(headless.notify).not.toHaveBeenCalled();
|
|
1322
|
+
|
|
1323
|
+
// A later TUI session still gets its one warning.
|
|
1324
|
+
const tui = makeCtx("tui");
|
|
1325
|
+
await runNativePasteRestore(tui.ctx);
|
|
1326
|
+
expect(tui.notify).toHaveBeenCalledTimes(1);
|
|
1327
|
+
expect((tui.notify.mock.calls[0] as [string, string])[1]).toBe("warning");
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
// The silence promise is stated for EVERY outcome, not just "untouched":
|
|
1331
|
+
// "No notify at all when ctx.hasUI is false". The `restored` branch goes
|
|
1332
|
+
// through the shared reload helper, which notified unconditionally. Measured
|
|
1333
|
+
// against pi 1.2.0: `hasUI` is `uiContext !== noOpUIContext`
|
|
1334
|
+
// (dist/core/extensions/runner.js:404), so print/json hosts hand out a
|
|
1335
|
+
// no-op `notify` and rpc reports `hasUI: true` — meaning the guard is what
|
|
1336
|
+
// makes the documented promise TRUE rather than accidentally true via a
|
|
1337
|
+
// host-side no-op, and it is the same rule the `untouched` branch already
|
|
1338
|
+
// applies.
|
|
1339
|
+
it("a headless session that RESTORES the file never notifies, and the log still carries the outcome", async () => {
|
|
1340
|
+
askForNativePaste();
|
|
1341
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
1342
|
+
const logFile = join(tempDir, `diag-${process.pid}-headless-restored.log`);
|
|
1343
|
+
const headless = makeCtx("rpc", { hasUI: false, withReload: true });
|
|
1344
|
+
process.env.PI_ARCHIMEDES_LOG = logFile;
|
|
1345
|
+
let outcome: NativePasteRestoreOutcome | undefined;
|
|
1346
|
+
try {
|
|
1347
|
+
outcome = await runNativePasteRestore(headless.ctx);
|
|
1348
|
+
} finally {
|
|
1349
|
+
delete process.env.PI_ARCHIMEDES_LOG;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// The work still happens — silence is not inaction.
|
|
1353
|
+
expect(outcome!.kind).toBe("restored");
|
|
1354
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
1355
|
+
// …with the headless ctx (which has a reload) reporting no /reload is needed.
|
|
1356
|
+
if (outcome!.kind !== "restored") return expect.fail(`expected restored, got ${outcome!.kind}`);
|
|
1357
|
+
expect(outcome!.reloadRequired).toBe(false);
|
|
1358
|
+
// …and NOT one notify emitted, and the once-per-process warning budget is
|
|
1359
|
+
// still unspent for whatever comes next.
|
|
1360
|
+
expect(headless.notify).not.toHaveBeenCalled();
|
|
1361
|
+
expect((globalThis as Record<symbol, unknown>)[NOTICE_KEY]).toBeUndefined();
|
|
1362
|
+
// …while the detailed channel still records it.
|
|
1363
|
+
expect(fs.existsSync(logFile)).toBe(true);
|
|
1364
|
+
expect(fs.readFileSync(logFile, "utf-8")).toContain(keybindingsPath());
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
it("a headless session with no reload on the ctx notifies nothing, and says /reload is required", async () => {
|
|
1368
|
+
askForNativePaste();
|
|
1369
|
+
writeKeybindings(ARTEFACT_PRE_123);
|
|
1370
|
+
const headless = makeCtx("print", { hasUI: false });
|
|
1371
|
+
|
|
1372
|
+
const outcome = await runNativePasteRestore(headless.ctx);
|
|
1373
|
+
|
|
1374
|
+
if (outcome.kind !== "restored") return expect.fail(`expected restored, got ${outcome.kind}`);
|
|
1375
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
1376
|
+
expect(outcome.reloadRequired).toBe(true);
|
|
1377
|
+
expect(headless.notify).not.toHaveBeenCalled();
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
it("a second session on the already-restored {} file notifies nothing and writes nothing", async () => {
|
|
1381
|
+
askForNativePaste();
|
|
1382
|
+
writeKeybindings("{}");
|
|
1383
|
+
const stamp = new Date("2020-01-01T12:00:00.000Z");
|
|
1384
|
+
fs.utimesSync(keybindingsPath(), stamp, stamp);
|
|
1385
|
+
|
|
1386
|
+
const first = makeCtx("tui", { withReload: true });
|
|
1387
|
+
const outcome = await runNativePasteRestore(first.ctx);
|
|
1388
|
+
|
|
1389
|
+
expect(outcome.kind).toBe("already-restored");
|
|
1390
|
+
expect(first.notify).not.toHaveBeenCalled();
|
|
1391
|
+
expect(first.reload).not.toHaveBeenCalled();
|
|
1392
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe("{}");
|
|
1393
|
+
expect(fs.statSync(keybindingsPath()).mtimeMs).toBe(stamp.getTime());
|
|
1394
|
+
|
|
1395
|
+
const second = makeCtx("tui", { withReload: true });
|
|
1396
|
+
expect((await runNativePasteRestore(second.ctx)).kind).toBe("already-restored");
|
|
1397
|
+
expect(second.notify).not.toHaveBeenCalled();
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it("a failed write is surfaced as one warning naming the path and the key, and never throws", async () => {
|
|
1401
|
+
askForNativePaste();
|
|
1402
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
1403
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1404
|
+
// Same uid-independent injection as the `restoreNativePaste` case: a
|
|
1405
|
+
// directory at the temp path makes the temp write fail for any uid, where
|
|
1406
|
+
// `chmod 0o555` is simply ignored by root.
|
|
1407
|
+
const blockedTmp = `${keybindingsPath()}.tmp`;
|
|
1408
|
+
fs.mkdirSync(blockedTmp, { recursive: true });
|
|
1409
|
+
let outcome: NativePasteRestoreOutcome | undefined;
|
|
1410
|
+
try {
|
|
1411
|
+
outcome = await runNativePasteRestore(ctx);
|
|
1412
|
+
} finally {
|
|
1413
|
+
fs.rmSync(blockedTmp, { recursive: true, force: true });
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
expect(outcome!.kind).toBe("failed");
|
|
1417
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
1418
|
+
const [message, level] = notify.mock.calls[0] as [string, string];
|
|
1419
|
+
expect(level).toBe("warning");
|
|
1420
|
+
expect(message).toContain(keybindingsPath());
|
|
1421
|
+
expect(message).toContain(UNBIND_KEY);
|
|
1422
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(ARTEFACT_POST_123);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
it("diag carries the detail, so the outcome is recorded even where notify cannot run", async () => {
|
|
1426
|
+
// `diag` is opt-in (PI_ARCHIMEDES_LOG names the file), which is exactly why
|
|
1427
|
+
// notify is mandatory for the actionable case — but with the log on, the
|
|
1428
|
+
// path and the key have to be in it.
|
|
1429
|
+
const logFile = join(tempDir, `diag-${process.pid}-untouched.log`);
|
|
1430
|
+
askForNativePaste();
|
|
1431
|
+
writeKeybindings(`{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`);
|
|
1432
|
+
process.env.PI_ARCHIMEDES_LOG = logFile;
|
|
1433
|
+
try {
|
|
1434
|
+
await runNativePasteRestore(makeCtx("rpc", { hasUI: false }).ctx);
|
|
1435
|
+
} finally {
|
|
1436
|
+
delete process.env.PI_ARCHIMEDES_LOG;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
expect(fs.existsSync(logFile)).toBe(true);
|
|
1440
|
+
const log = fs.readFileSync(logFile, "utf-8");
|
|
1441
|
+
expect(log).toContain("image-paste");
|
|
1442
|
+
expect(log).toContain(keybindingsPath());
|
|
1443
|
+
expect(log).toContain(UNBIND_KEY);
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it("every session's untouched outcome is logged while notify stays silenced after the first", async () => {
|
|
1447
|
+
// `diag` is the detailed channel and may fire every session; the notify is
|
|
1448
|
+
// the once-per-process surface. Together they are the surfacing rule.
|
|
1449
|
+
askForNativePaste();
|
|
1450
|
+
const foreign = `{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`;
|
|
1451
|
+
writeKeybindings(foreign);
|
|
1452
|
+
const logFile = join(tempDir, `diag-${process.pid}-twice.log`);
|
|
1453
|
+
process.env.PI_ARCHIMEDES_LOG = logFile;
|
|
1454
|
+
const first = makeCtx("tui");
|
|
1455
|
+
const second = makeCtx("tui");
|
|
1456
|
+
try {
|
|
1457
|
+
await runNativePasteRestore(first.ctx);
|
|
1458
|
+
await runNativePasteRestore(second.ctx);
|
|
1459
|
+
} finally {
|
|
1460
|
+
delete process.env.PI_ARCHIMEDES_LOG;
|
|
1461
|
+
}
|
|
1462
|
+
const log: string = fs.readFileSync(logFile, "utf-8");
|
|
1463
|
+
expect(
|
|
1464
|
+
log.split("\n").filter((line: string) => line.includes(keybindingsPath())).length,
|
|
1465
|
+
).toBeGreaterThanOrEqual(2);
|
|
1466
|
+
expect(first.notify).toHaveBeenCalledTimes(1);
|
|
1467
|
+
expect(second.notify).not.toHaveBeenCalled();
|
|
1468
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(foreign);
|
|
1469
|
+
});
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1473
|
+
// The wording of the refuse warning, per refusal (issue #127)
|
|
1474
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1475
|
+
//
|
|
1476
|
+
// ONE warning covers five refusals, and they are five different files. Only two
|
|
1477
|
+
// of them actually hold an `app.clipboard.pasteImage` entry the user could
|
|
1478
|
+
// delete:
|
|
1479
|
+
//
|
|
1480
|
+
// * `extra-key` WITH our `[]` still sitting beside a foreign key — delete it
|
|
1481
|
+
// and pi's paste comes back, and until then nothing answers the key, because
|
|
1482
|
+
// the entry is empty AND `suppressNativePaste: false` took our shortcut;
|
|
1483
|
+
// * `binding-not-empty` — the entry is there but it is the USER's binding, so
|
|
1484
|
+
// the wording has to say whose it is, and cannot claim the key is dead.
|
|
1485
|
+
//
|
|
1486
|
+
// The other refusals hold no such entry at all: foreign keys only, `$schema`
|
|
1487
|
+
// only (`extra-key`), JSON that is not an object, JSON that does not parse, and
|
|
1488
|
+
// a file that cannot be read. There the instruction "remove the
|
|
1489
|
+
// `app.clipboard.pasteImage` entry and run /reload" sends the user looking for a
|
|
1490
|
+
// line that is not in the file, in a file that needs no edit at all — and for the
|
|
1491
|
+
// two readable ones among them pi is already answering the paste key. So every
|
|
1492
|
+
// refusal is pinned on its own: the warning always names the file, and it only
|
|
1493
|
+
// tells the user to delete something when there is something to delete.
|
|
1494
|
+
|
|
1495
|
+
describe("runNativePasteRestore — the refuse warning says what this file actually holds", () => {
|
|
1496
|
+
beforeEach(clearNoticeGuard);
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Refuse `raw`, and return the one warning the caller notified.
|
|
1500
|
+
*
|
|
1501
|
+
* The shared shape of EVERY refusal lives here, because it is the same for all
|
|
1502
|
+
* five: exactly one notify, at `"warning"`, naming the file path, on one line,
|
|
1503
|
+
* and the status behind it carrying the reason the wording branches on.
|
|
1504
|
+
*/
|
|
1505
|
+
async function warningFor(raw: string, reason: UntouchedReason): Promise<string> {
|
|
1506
|
+
askForNativePaste();
|
|
1507
|
+
writeKeybindings(raw);
|
|
1508
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1509
|
+
|
|
1510
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1511
|
+
|
|
1512
|
+
if (outcome.kind !== "untouched") {
|
|
1513
|
+
return expect.fail(`expected "untouched", got "${outcome.kind}"`);
|
|
1514
|
+
}
|
|
1515
|
+
expect(outcome.reason, "the reason the wording branches on").toBe(reason);
|
|
1516
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
1517
|
+
const [message, level] = notify.mock.calls[0] as [string, string];
|
|
1518
|
+
expect(level).toBe("warning");
|
|
1519
|
+
expect(message).toContain(keybindingsPath());
|
|
1520
|
+
expect(message).not.toContain("\n");
|
|
1521
|
+
expect(outcome.manualEdit).toContain(keybindingsPath());
|
|
1522
|
+
return message;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// ── the two refusals where an entry really is in the file ────────────────
|
|
1526
|
+
|
|
1527
|
+
it("extra-key WITH our [] entry: the one case where naming the entry to delete is true, and the key is genuinely dead until it goes", async () => {
|
|
1528
|
+
const message = await warningFor(
|
|
1529
|
+
`{ "${UNBIND_KEY}": [], "app.editor.newLine": ["ctrl+enter"] }`,
|
|
1530
|
+
"extra-key",
|
|
1531
|
+
);
|
|
1532
|
+
|
|
1533
|
+
expect(message).toContain(UNBIND_KEY);
|
|
1534
|
+
expect(message).toMatch(/remove the .*entry/i);
|
|
1535
|
+
expect(message).toMatch(/\/reload/);
|
|
1536
|
+
expect(message).toMatch(/not binding the paste key/i);
|
|
1537
|
+
expect(message).toMatch(/does nothing/i);
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
it("binding-not-empty: the entry is named, but as the user's OWN binding, and the key is not called dead", async () => {
|
|
1541
|
+
// The user wrote `"app.clipboard.pasteImage": ["ctrl+v"]`. Handing the key
|
|
1542
|
+
// to pi still means deleting that line, so the instruction is right — but it
|
|
1543
|
+
// is their line, not ours, and something (their own binding) does answer the
|
|
1544
|
+
// key today, so "does nothing until you do" would be false here.
|
|
1545
|
+
const message = await warningFor(`{ "${UNBIND_KEY}": ["ctrl+v"] }`, "binding-not-empty");
|
|
1546
|
+
|
|
1547
|
+
expect(message).toContain(UNBIND_KEY);
|
|
1548
|
+
expect(message).toMatch(/your/i);
|
|
1549
|
+
expect(message).toMatch(/remove/i);
|
|
1550
|
+
expect(message).toMatch(/\/reload/);
|
|
1551
|
+
expect(message).not.toMatch(/does nothing/i);
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
// ── the refusals where there is nothing of ours to remove ────────────────
|
|
1555
|
+
|
|
1556
|
+
it("extra keys but no app.clipboard.pasteImage entry: nothing of ours to remove, nothing to edit, pi already answers the key", async () => {
|
|
1557
|
+
const message = await warningFor(
|
|
1558
|
+
`{ "app.editor.newLine": ["ctrl+enter"] }`,
|
|
1559
|
+
"extra-key",
|
|
1560
|
+
);
|
|
1561
|
+
|
|
1562
|
+
// No instruction to delete anything — there is nothing to delete.
|
|
1563
|
+
expect(message).not.toMatch(/remove|delete/i);
|
|
1564
|
+
expect(message).toMatch(/nothing for you to edit/i);
|
|
1565
|
+
expect(message).toMatch(/pi's own paste answers that key/i);
|
|
1566
|
+
// And no reload is demanded: a file that needs no edit needs no reload.
|
|
1567
|
+
expect(message).not.toMatch(/\/reload/);
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
it("a $schema-only file is the same refusal: no entry of ours, so no errand", async () => {
|
|
1571
|
+
const message = await warningFor(`{ "$schema": "${SCHEMA_URL}" }`, "extra-key");
|
|
1572
|
+
|
|
1573
|
+
expect(message).not.toMatch(/remove|delete/i);
|
|
1574
|
+
expect(message).toMatch(/nothing for you to edit/i);
|
|
1575
|
+
expect(message).toMatch(/pi's own paste answers that key/i);
|
|
1576
|
+
expect(message).not.toMatch(/\/reload/);
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
// ── the refusals where the file cannot be read for advice ────────────────
|
|
1580
|
+
|
|
1581
|
+
it("JSON that is not an object: left untouched, the file named, the user asked to check it — no entry named", async () => {
|
|
1582
|
+
const message = await warningFor(`["${UNBIND_KEY}"]`, "not-an-object");
|
|
1583
|
+
|
|
1584
|
+
expect(message).toMatch(/not a JSON object/i);
|
|
1585
|
+
expect(message).toMatch(/check .*yourself/i);
|
|
1586
|
+
expect(message).toMatch(/\/reload/);
|
|
1587
|
+
// Neither an entry nor a deletion is named: we cannot see one to name.
|
|
1588
|
+
expect(message).not.toContain(UNBIND_KEY);
|
|
1589
|
+
expect(message).not.toMatch(/remove|delete/i);
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
it("JSON that does not parse: left untouched, the file named, the user asked to check it — no entry named", async () => {
|
|
1593
|
+
const message = await warningFor(
|
|
1594
|
+
`{ "${UNBIND_KEY}": [] // hand-edited, not valid JSON`,
|
|
1595
|
+
"unparsable",
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1598
|
+
expect(message).toMatch(/does not parse/i);
|
|
1599
|
+
expect(message).toMatch(/check .*yourself/i);
|
|
1600
|
+
expect(message).toMatch(/\/reload/);
|
|
1601
|
+
expect(message).not.toContain(UNBIND_KEY);
|
|
1602
|
+
expect(message).not.toMatch(/remove|delete/i);
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
it("a file that cannot be read: left untouched, the file named, the user asked to check it — no entry named", async () => {
|
|
1606
|
+
// The helper writes a file, so this refusal is set up by hand — same shape
|
|
1607
|
+
// of assertion: one `"warning"` naming the path, no entry, no deletion.
|
|
1608
|
+
askForNativePaste();
|
|
1609
|
+
fs.mkdirSync(keybindingsPath(), { recursive: true });
|
|
1610
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1611
|
+
try {
|
|
1612
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1613
|
+
|
|
1614
|
+
if (outcome.kind !== "untouched") {
|
|
1615
|
+
return expect.fail(`expected "untouched", got "${outcome.kind}"`);
|
|
1616
|
+
}
|
|
1617
|
+
expect(outcome.reason).toBe("unreadable");
|
|
1618
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
1619
|
+
const [message, level] = notify.mock.calls[0] as [string, string];
|
|
1620
|
+
expect(level).toBe("warning");
|
|
1621
|
+
expect(message).toContain(keybindingsPath());
|
|
1622
|
+
expect(message).toMatch(/could not be read/i);
|
|
1623
|
+
expect(message).toMatch(/check .*yourself/i);
|
|
1624
|
+
expect(message).not.toContain(UNBIND_KEY);
|
|
1625
|
+
expect(message).not.toMatch(/remove|delete/i);
|
|
1626
|
+
} finally {
|
|
1627
|
+
fs.rmSync(keybindingsPath(), { recursive: true, force: true });
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
it("a failed write still names the entry: the artefact is ours, it is in there, and it is dead until it goes", async () => {
|
|
1632
|
+
// The `failed` status shares the wording with the case above because the
|
|
1633
|
+
// file it refused IS our artefact — the entry is present and pi's paste is
|
|
1634
|
+
// the thing waiting on it.
|
|
1635
|
+
askForNativePaste();
|
|
1636
|
+
writeKeybindings(ARTEFACT_POST_123);
|
|
1637
|
+
const blockedTmp = `${keybindingsPath()}.tmp`;
|
|
1638
|
+
fs.mkdirSync(blockedTmp, { recursive: true });
|
|
1639
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1640
|
+
try {
|
|
1641
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1642
|
+
expect(outcome.kind).toBe("failed");
|
|
1643
|
+
|
|
1644
|
+
const [message, level] = notify.mock.calls[0] as [string, string];
|
|
1645
|
+
expect(level).toBe("warning");
|
|
1646
|
+
expect(message).toContain(keybindingsPath());
|
|
1647
|
+
expect(message).toContain(UNBIND_KEY);
|
|
1648
|
+
expect(message).toMatch(/remove the .*entry/i);
|
|
1649
|
+
expect(message).toMatch(/does nothing/i);
|
|
1650
|
+
} finally {
|
|
1651
|
+
fs.rmSync(blockedTmp, { recursive: true, force: true });
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1657
|
+
// PR #129 review — the remaining clauses, checked against pi's own loader
|
|
1658
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1659
|
+
//
|
|
1660
|
+
// The #127 fix split one sentence into four and checked each clause against the
|
|
1661
|
+
// shapes the split names. Three claims survived that are not facts about the
|
|
1662
|
+
// file in front of us, and one promise turned out to be untested. Every claim
|
|
1663
|
+
// below was measured against pi's OWN loader — `KeybindingsManager.create()`
|
|
1664
|
+
// from `dist/core/keybindings.js`, run against these exact files on pi 1.2.0 and
|
|
1665
|
+
// pi 0.87.0, which carry the same code — not reasoned about from our side:
|
|
1666
|
+
//
|
|
1667
|
+
// * **The old name still bites.** pi renames `pasteImage` to
|
|
1668
|
+
// `app.clipboard.pasteImage` before it applies anything
|
|
1669
|
+
// (`KEYBINDING_NAME_MIGRATIONS`, `dist/core/keybindings.js:225`, applied by
|
|
1670
|
+
// `migrateKeybindingsConfig` at `:254-269`), so
|
|
1671
|
+
// `{ "pasteImage": [] }` resolves the paste action to `[]`: the key is
|
|
1672
|
+
// DEAD. Our reader only looks for the modern key, so it filed that file
|
|
1673
|
+
// under "nothing in it clears pi's paste binding — pi's own paste answers
|
|
1674
|
+
// that key", which is the exact opposite of what pi does with it.
|
|
1675
|
+
// pi's startup migration (`migrations.js:139-157`) rewrites the file on
|
|
1676
|
+
// disk before extensions load, so this is the narrow case where it did not:
|
|
1677
|
+
// a read-only agent dir (its `catch {}` swallows the failure), or a file
|
|
1678
|
+
// replaced by a dotfiles sync / editor after the migration and then
|
|
1679
|
+
// `/reload`ed — `keybindings.reload()` (`interactive-mode.js:5406`) only
|
|
1680
|
+
// re-reads, it never migrates the file.
|
|
1681
|
+
// * **We cannot see who wrote the file.** `{ "$schema": <pi's URL> }` is
|
|
1682
|
+
// byte-for-byte the line `offerKeybindingFix` writes, and it is the state a
|
|
1683
|
+
// user is in after following our own "remove the entry" instruction on that
|
|
1684
|
+
// file, so "nothing in this file was written by this suite" is not something
|
|
1685
|
+
// the refusal is allowed to assert.
|
|
1686
|
+
// * **An unreadable file does not leave the binding unknown.** pi DISCARDS the
|
|
1687
|
+
// whole file when it cannot read or parse it (`loadRawConfig` returns
|
|
1688
|
+
// `undefined` → `loadFromFile` returns `{}`, `keybindings.js:285-297`) and
|
|
1689
|
+
// every action falls back to its default, silently, with no diagnostic on
|
|
1690
|
+
// any path. So the paste key IS answered by pi's default and none of the
|
|
1691
|
+
// user's own bindings in that file are in force — "we cannot tell what the
|
|
1692
|
+
// paste key is bound to" tells the user to go read a file that pi is
|
|
1693
|
+
// ignoring.
|
|
1694
|
+
|
|
1695
|
+
describe("runNativePasteRestore — the refuse warning vs pi's own keybindings loader (PR #129 review)", () => {
|
|
1696
|
+
beforeEach(clearNoticeGuard);
|
|
1697
|
+
|
|
1698
|
+
/** Refuse `raw` and return the one warning, with the shared refusal shape. */
|
|
1699
|
+
async function warning(raw: string): Promise<string> {
|
|
1700
|
+
// The notice guard is once-per-PROCESS, and one test below asks for two
|
|
1701
|
+
// refusals in a row, so the helper re-arms it rather than relying on
|
|
1702
|
+
// `beforeEach` alone.
|
|
1703
|
+
clearNoticeGuard();
|
|
1704
|
+
askForNativePaste();
|
|
1705
|
+
writeKeybindings(raw);
|
|
1706
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1707
|
+
|
|
1708
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1709
|
+
|
|
1710
|
+
if (outcome.kind !== "untouched") {
|
|
1711
|
+
return expect.fail(`expected "untouched", got "${outcome.kind}"`);
|
|
1712
|
+
}
|
|
1713
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
1714
|
+
const [message, level] = notify.mock.calls[0] as [string, string];
|
|
1715
|
+
expect(level).toBe("warning");
|
|
1716
|
+
expect(message).toContain(keybindingsPath());
|
|
1717
|
+
expect(message).not.toContain("\n");
|
|
1718
|
+
return message;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
it("the OLD name pasteImage: [] is an unbinding too: pi's paste is dead, and the entry to name is the old one", async () => {
|
|
1722
|
+
// Measured: pi resolves app.clipboard.pasteImage to [] for this file, so it
|
|
1723
|
+
// belongs with the "there is an entry, remove it, the key is dead until you
|
|
1724
|
+
// do" refusals — and naming the modern key would send the user looking for
|
|
1725
|
+
// a line that is not in the file (issue #127 again, in the other direction).
|
|
1726
|
+
const message = await warning(`{ "pasteImage": [] }`);
|
|
1727
|
+
|
|
1728
|
+
expect(message).toMatch(/"pasteImage"/);
|
|
1729
|
+
expect(message).toMatch(/remove .*"pasteImage"/i);
|
|
1730
|
+
expect(message).toMatch(/\/reload/);
|
|
1731
|
+
expect(message).toMatch(/does nothing/i);
|
|
1732
|
+
// The two claims this file refutes.
|
|
1733
|
+
expect(message).not.toMatch(/pi's own paste answers that key/i);
|
|
1734
|
+
expect(message).not.toMatch(/nothing for you to edit/i);
|
|
1735
|
+
// Not told to delete an entry under a name the file does not contain.
|
|
1736
|
+
expect(message).not.toMatch(/remove the "app\.clipboard\.pasteImage" entry/i);
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it("a $schema carrying pi's own URL is not evidence the file is foreign: no claim about who wrote it", async () => {
|
|
1740
|
+
// Still the no-errand refusal — pi keeps its default binding here.
|
|
1741
|
+
const message = await warning(`{ "$schema": "${SCHEMA_URL}" }`);
|
|
1742
|
+
|
|
1743
|
+
expect(message).toMatch(/nothing for you to edit/i);
|
|
1744
|
+
expect(message).toMatch(/pi's own paste answers that key/i);
|
|
1745
|
+
expect(message).not.toMatch(/\/reload/);
|
|
1746
|
+
expect(message).not.toMatch(/remove|delete/i);
|
|
1747
|
+
// But the refusal must not assert authorship it cannot see: this exact line
|
|
1748
|
+
// is what the first-run offer writes.
|
|
1749
|
+
expect(message).not.toMatch(/was written by this suite/i);
|
|
1750
|
+
expect(message).not.toMatch(/nothing in .* was written/i);
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
it("a file pi cannot read is a file pi ignores: the warning says pi falls back to its defaults", async () => {
|
|
1754
|
+
for (const [label, raw] of [
|
|
1755
|
+
["unparsable", `{ "${UNBIND_KEY}": [] // hand-edited, not valid JSON`],
|
|
1756
|
+
["not-a-JSON-object", `["${UNBIND_KEY}"]`],
|
|
1757
|
+
] as const) {
|
|
1758
|
+
const message = await warning(raw);
|
|
1759
|
+
|
|
1760
|
+
expect(message, label).not.toMatch(/cannot tell what the paste key is bound to/i);
|
|
1761
|
+
expect(message, label).toMatch(/default/i);
|
|
1762
|
+
// Still: the file is named, the cause is named, the reading is handed back.
|
|
1763
|
+
expect(message, label).toMatch(/check .*yourself/i);
|
|
1764
|
+
expect(message, label).not.toContain(UNBIND_KEY);
|
|
1765
|
+
expect(message, label).not.toMatch(/remove|delete/i);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// The third shape cannot come from `warning()` (it writes a file), so it is
|
|
1769
|
+
// set up by hand: a directory where keybindings.json should be.
|
|
1770
|
+
clearNoticeGuard();
|
|
1771
|
+
askForNativePaste();
|
|
1772
|
+
// Clear whatever the loop above left at the path, then make it a directory.
|
|
1773
|
+
fs.rmSync(keybindingsPath(), { recursive: true, force: true });
|
|
1774
|
+
fs.mkdirSync(keybindingsPath(), { recursive: true });
|
|
1775
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1776
|
+
try {
|
|
1777
|
+
await runNativePasteRestore(ctx);
|
|
1778
|
+
const [message] = notify.mock.calls[0] as [string, string];
|
|
1779
|
+
|
|
1780
|
+
expect(message).toMatch(/could not be read/i);
|
|
1781
|
+
expect(message).toMatch(/default/i);
|
|
1782
|
+
expect(message).not.toMatch(/cannot tell what the paste key is bound to/i);
|
|
1783
|
+
expect(message).not.toContain(UNBIND_KEY);
|
|
1784
|
+
expect(message).not.toMatch(/remove|delete/i);
|
|
1785
|
+
} finally {
|
|
1786
|
+
fs.rmSync(keybindingsPath(), { recursive: true, force: true });
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
it("a modern binding with a legacy [] behind it: clearing only the modern entry would re-dead the key", async () => {
|
|
1791
|
+
// Measured: pi drops the legacy entry while the modern key exists
|
|
1792
|
+
// (`keybindings.js:260-264`), so the old name is invisible today and takes
|
|
1793
|
+
// the binding over the moment the modern entry is deleted — i.e. exactly
|
|
1794
|
+
// after the user does what this warning tells them to do.
|
|
1795
|
+
const message = await warning(`{ "${UNBIND_KEY}": ["ctrl+v"], "pasteImage": [] }`);
|
|
1796
|
+
|
|
1797
|
+
expect(message).toMatch(/your/i);
|
|
1798
|
+
expect(message).toMatch(/pasteImage/);
|
|
1799
|
+
expect(message).toMatch(/too\b/i);
|
|
1800
|
+
// Their own binding answers the key right now, so the key is not dead.
|
|
1801
|
+
expect(message).not.toMatch(/does nothing/i);
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
it("diag logs the SAME guidance the warning gave — the log line is not a second wording", async () => {
|
|
1805
|
+
// Written against a surviving mutant: replacing `status.manualEdit` with the
|
|
1806
|
+
// generic "remove the entry" guidance inside `describeRestoreStatus` passed
|
|
1807
|
+
// all 163 tests, because the log assertions only demanded "path + key name"
|
|
1808
|
+
// — both of which the OLD one-size sentence also contains. `manualEdit` is
|
|
1809
|
+
// the single source for the status, the notify and the log, so the log is
|
|
1810
|
+
// asserted against the refusal it belongs to.
|
|
1811
|
+
askForNativePaste();
|
|
1812
|
+
writeKeybindings(`{ "app.editor.newLine": ["ctrl+enter"] }`);
|
|
1813
|
+
const logFile = join(tempDir, `diag-${process.pid}-single-source.log`);
|
|
1814
|
+
process.env.PI_ARCHIMEDES_LOG = logFile;
|
|
1815
|
+
let notified = "";
|
|
1816
|
+
try {
|
|
1817
|
+
const { ctx, notify } = makeCtx("tui");
|
|
1818
|
+
const outcome = await runNativePasteRestore(ctx);
|
|
1819
|
+
expect(outcome.kind).toBe("untouched");
|
|
1820
|
+
notified = (notify.mock.calls[0] as [string])[0];
|
|
1821
|
+
} finally {
|
|
1822
|
+
delete process.env.PI_ARCHIMEDES_LOG;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
const log = fs.readFileSync(logFile, "utf-8");
|
|
1826
|
+
expect(log).toContain(keybindingsPath());
|
|
1827
|
+
expect(log).toContain("pi's own paste answers that key");
|
|
1828
|
+
expect(log).not.toMatch(/remove the .*entry/i);
|
|
1829
|
+
expect(notified).toContain("pi's own paste answers that key");
|
|
1830
|
+
});
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1834
|
+
// The paste-key gate on the OFFER — issue #133
|
|
1835
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1836
|
+
//
|
|
1837
|
+
// `suiteMayClaimPasteKey()` is the single predicate that decides whether the
|
|
1838
|
+
// suite holds pi's paste key this session, and until #133 the offer was not one
|
|
1839
|
+
// of its readers: `packages/image-paste/src/index.ts:201` (shortcut
|
|
1840
|
+
// registration) and `runNativePasteRestore` (`keybinding-offer.ts:778`) were its
|
|
1841
|
+
// only two callers. So a fresh profile with `suppressNativePaste: false` — the
|
|
1842
|
+
// escape hatch open, our shortcut deliberately NOT registered — still got the
|
|
1843
|
+
// offer, and accepting it wrote the very unbinding that leaves `ctrl+v` bound to
|
|
1844
|
+
// nobody: pi's action is cleared by the file and our handler is not there to
|
|
1845
|
+
// take the key. The next session's restore then emptied the file the offer had
|
|
1846
|
+
// just asked permission to create, and announced that it had.
|
|
1847
|
+
//
|
|
1848
|
+
// Two things the gate owes the user, both pinned below:
|
|
1849
|
+
//
|
|
1850
|
+
// * **Nothing to offer.** With the hatch open there is no shared key to
|
|
1851
|
+
// disentangle, so the offer does not ask for permission to create a file this
|
|
1852
|
+
// install has already decided it does not want, and emits no UI text at all.
|
|
1853
|
+
// * **Still armed.** The skip must not spend `keybindingsPromptDone`. The hatch
|
|
1854
|
+
// is reversible; a user who closes it again must meet the offer in a later
|
|
1855
|
+
// session, which is the whole point of an escape hatch you can undo.
|
|
1856
|
+
|
|
1857
|
+
/** The two notices that must never describe the same file — see the invariant. */
|
|
1858
|
+
const CREATED_TEXT = /Created ~\/\.pi\/agent\/keybindings\.json/;
|
|
1859
|
+
const RESTORED_TEXT = /Restored pi's native clipboard paste/;
|
|
1860
|
+
|
|
1861
|
+
describe("offerKeybindingFix — the paste-key gate (issue #133)", () => {
|
|
1862
|
+
it("the hatch is open: no confirm, no file, no tmp, no notify, resolves false", async () => {
|
|
1863
|
+
askForNativePaste();
|
|
1864
|
+
const { ctx, confirm, notify } = makeCtx("tui");
|
|
1865
|
+
|
|
1866
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
|
|
1867
|
+
|
|
1868
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
1869
|
+
expect(fs.existsSync(keybindingsPath())).toBe(false);
|
|
1870
|
+
expect(fs.existsSync(tmpPath())).toBe(false);
|
|
1871
|
+
// Not one UI message of any kind — in particular no "Created …" text.
|
|
1872
|
+
expect(notify).not.toHaveBeenCalled();
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
it("the skip does NOT spend keybindingsPromptDone, and closing the hatch fires the offer on the same profile", async () => {
|
|
1876
|
+
// The reversible-hatch case end to end, on ONE profile: skip → settings
|
|
1877
|
+
// byte-identical → the user closes the hatch → the offer fires and accepting
|
|
1878
|
+
// it works. The middle assertion is what makes this a proof rather than a
|
|
1879
|
+
// hope: an implementation that marked the prompt done on the skip would
|
|
1880
|
+
// leave the offer dead for every later session.
|
|
1881
|
+
askForNativePaste();
|
|
1882
|
+
const skipped = makeCtx("tui");
|
|
1883
|
+
expect(await offerKeybindingFix(skipped.ctx)).toBe(false);
|
|
1884
|
+
|
|
1885
|
+
expect(readFlag()).toBe(false);
|
|
1886
|
+
// Nothing was added to the namespace: the hatch-off object is untouched.
|
|
1887
|
+
expect(readSettings()).toEqual({ [NAMESPACE]: { suppressNativePaste: false } });
|
|
1888
|
+
|
|
1889
|
+
writeSettings({ [NAMESPACE]: { suppressNativePaste: true } });
|
|
1890
|
+
const armed = makeCtx("tui");
|
|
1891
|
+
armed.confirm.mockResolvedValue(true);
|
|
1892
|
+
await offerKeybindingFix(armed.ctx);
|
|
1893
|
+
|
|
1894
|
+
expect(armed.confirm).toHaveBeenCalledTimes(1);
|
|
1895
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
1896
|
+
expect(readFlag()).toBe(true);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
it("deleting the setting instead of closing it also re-arms the offer on the same profile", async () => {
|
|
1900
|
+
// The other way back to the default: the key disappears entirely, and the
|
|
1901
|
+
// offer that the skip withheld is delivered.
|
|
1902
|
+
askForNativePaste();
|
|
1903
|
+
await expect(offerKeybindingFix(makeCtx("tui").ctx)).resolves.toBe(false);
|
|
1904
|
+
expect(readFlag()).toBe(false);
|
|
1905
|
+
|
|
1906
|
+
cleanupArtifacts();
|
|
1907
|
+
writeSettings({ [NAMESPACE]: { enabled: true } });
|
|
1908
|
+
const armed = makeCtx("tui");
|
|
1909
|
+
armed.confirm.mockResolvedValue(true);
|
|
1910
|
+
await offerKeybindingFix(armed.ctx);
|
|
1911
|
+
|
|
1912
|
+
expect(armed.confirm).toHaveBeenCalledTimes(1);
|
|
1913
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
it("gate 1 still wins: extension disabled + the hatch open → no confirm, no file, no flag", async () => {
|
|
1917
|
+
// The gate matrix must not shift: the ADR 0012 in-package `enabled` read
|
|
1918
|
+
// stays the first thing the offer consults, whichever way the hatch is set.
|
|
1919
|
+
writeSettings({ [NAMESPACE]: { enabled: false, suppressNativePaste: false } });
|
|
1920
|
+
const settingsBefore = readSettings();
|
|
1921
|
+
const { ctx, confirm, notify } = makeCtx("tui");
|
|
1922
|
+
|
|
1923
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
|
|
1924
|
+
|
|
1925
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
1926
|
+
expect(fs.existsSync(keybindingsPath())).toBe(false);
|
|
1927
|
+
expect(readSettings()).toEqual(settingsBefore);
|
|
1928
|
+
expect(readFlag()).toBe(false);
|
|
1929
|
+
expect(notify).not.toHaveBeenCalled();
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it("the gate uses the restore's strict rule: only a strict false withholds the offer (\"false\", 0, null, \"off\", true all still ask)", async () => {
|
|
1933
|
+
// The two readers of the setting must not disagree about what "off" means:
|
|
1934
|
+
// `runNativePasteRestore` refuses to act on a stringified "false", so the
|
|
1935
|
+
// offer must not withhold itself on one either — otherwise the paste key
|
|
1936
|
+
// would be left with neither an offer nor a restore.
|
|
1937
|
+
for (const value of ["false", 0, null, "off", true]) {
|
|
1938
|
+
cleanupArtifacts();
|
|
1939
|
+
writeSettings({ [NAMESPACE]: { suppressNativePaste: value } });
|
|
1940
|
+
const { ctx, confirm } = makeCtx("tui");
|
|
1941
|
+
confirm.mockResolvedValue(false);
|
|
1942
|
+
|
|
1943
|
+
await offerKeybindingFix(ctx);
|
|
1944
|
+
|
|
1945
|
+
expect(confirm, JSON.stringify(value)).toHaveBeenCalledTimes(1);
|
|
1946
|
+
// A decline still spends the flag, exactly as the gate matrix records.
|
|
1947
|
+
expect(readFlag(), JSON.stringify(value)).toBe(true);
|
|
1948
|
+
}
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
it("no skip path emits a Created text — and a hatch-off skip never touches the keybindings.json path", async () => {
|
|
1952
|
+
// Two claims, both asserted rather than narrated:
|
|
1953
|
+
//
|
|
1954
|
+
// * No skip announces anything. Every row resolves `false`, asks nothing and
|
|
1955
|
+
// notifies nothing, so no "Created …" can reach a user on any way out of
|
|
1956
|
+
// the offer.
|
|
1957
|
+
// * The hatch-off rows never look at `keybindings.json` at all. Gate 4's
|
|
1958
|
+
// check IS `existsSync(keybindingsPath)`, and `node:fs`'s `existsSync` is
|
|
1959
|
+
// spied in this file, so the rows below assert that the path is never
|
|
1960
|
+
// named: gate 1b demonstrably runs before gate 4, not merely somewhere
|
|
1961
|
+
// before the dialog. A mutation that moves the paste-key check after gate
|
|
1962
|
+
// 4 fails here (verified: the spy then records the path for every
|
|
1963
|
+
// hatch-off row) — the shape of regression this sentence is for.
|
|
1964
|
+
//
|
|
1965
|
+
// Gate 3's flag read is a different story and is NOT claimed here: it reads
|
|
1966
|
+
// settings.json through the same helper gate 1b itself uses, so its order is
|
|
1967
|
+
// not observable from outside. What matters about gate 3 is asserted
|
|
1968
|
+
// elsewhere — the reversible-hatch test above, and the gate matrix's rule
|
|
1969
|
+
// that a non-asking row leaves settings.json byte-identical.
|
|
1970
|
+
const skipPaths: Array<[string, Record<string, unknown>, Mode, boolean]> = [
|
|
1971
|
+
["hatch open, tui", { suppressNativePaste: false }, "tui", false],
|
|
1972
|
+
["hatch open, rpc", { suppressNativePaste: false }, "rpc", false],
|
|
1973
|
+
["hatch open, json", { suppressNativePaste: false }, "json", false],
|
|
1974
|
+
["hatch open, print", { suppressNativePaste: false }, "print", false],
|
|
1975
|
+
[
|
|
1976
|
+
"hatch open + flag already set",
|
|
1977
|
+
{ suppressNativePaste: false, keybindingsPromptDone: true },
|
|
1978
|
+
"tui",
|
|
1979
|
+
false,
|
|
1980
|
+
],
|
|
1981
|
+
[
|
|
1982
|
+
"hatch open + extension disabled",
|
|
1983
|
+
{ enabled: false, suppressNativePaste: false },
|
|
1984
|
+
"tui",
|
|
1985
|
+
false,
|
|
1986
|
+
],
|
|
1987
|
+
["hatch open + a directory where the file would be", { suppressNativePaste: false }, "tui", true],
|
|
1988
|
+
];
|
|
1989
|
+
|
|
1990
|
+
for (const [label, ns, mode, asDirectory] of skipPaths) {
|
|
1991
|
+
cleanupArtifacts();
|
|
1992
|
+
writeSettings({ [NAMESPACE]: ns });
|
|
1993
|
+
if (asDirectory) fs.mkdirSync(keybindingsPath(), { recursive: true });
|
|
1994
|
+
const { ctx, confirm, notify } = makeCtx(mode);
|
|
1995
|
+
confirm.mockResolvedValue(true);
|
|
1996
|
+
existsSyncSpy.mockClear();
|
|
1997
|
+
|
|
1998
|
+
await expect(offerKeybindingFix(ctx), label).resolves.toBe(false);
|
|
1999
|
+
|
|
2000
|
+
// Gate 4's check IS `existsSync(keybindingsPath)` and that call is spied,
|
|
2001
|
+
// so a hatch-off skip naming the path at all would mean the paste-key gate
|
|
2002
|
+
// ran after it. It must not be named — not for the absent file, not for the
|
|
2003
|
+
// directory stand-in.
|
|
2004
|
+
const namedPath = existsSyncSpy.mock.calls
|
|
2005
|
+
.map((c) => String(c[0]))
|
|
2006
|
+
.filter((p) => p === keybindingsPath());
|
|
2007
|
+
expect(namedPath, label).toEqual([]);
|
|
2008
|
+
|
|
2009
|
+
expect(confirm, label).not.toHaveBeenCalled();
|
|
2010
|
+
expect(
|
|
2011
|
+
callArgs(notify).some((c) => CREATED_TEXT.test(String(c[0]))),
|
|
2012
|
+
label,
|
|
2013
|
+
).toBe(false);
|
|
2014
|
+
expect(notify, label).not.toHaveBeenCalled();
|
|
2015
|
+
if (asDirectory) {
|
|
2016
|
+
expect(fs.statSync(keybindingsPath()).isDirectory(), label).toBe(true);
|
|
2017
|
+
fs.rmSync(keybindingsPath(), { recursive: true, force: true });
|
|
2018
|
+
} else {
|
|
2019
|
+
expect(fs.existsSync(keybindingsPath()), label).toBe(false);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
// ── the invariant: offer and restore are mutually exclusive ────────────────
|
|
2026
|
+
//
|
|
2027
|
+
// Stated in `docs/features/first-run-keybinding-offer.md` and in the code: for
|
|
2028
|
+
// one setting value only one of the two flows may act — the offer runs only when
|
|
2029
|
+
// `suiteMayClaimPasteKey()` is true, the restore only when it is false. So no
|
|
2030
|
+
// session creates `keybindings.json` and empties it again, and one run never
|
|
2031
|
+
// tells the user both "Created ~/.pi/agent/keybindings.json …" and "Restored
|
|
2032
|
+
// pi's native clipboard paste …" about one file. The sequenced pair across a
|
|
2033
|
+
// settings change is NOT covered here and is not a defect: accept the offer under
|
|
2034
|
+
// the default, close the hatch later, and the restore hands the key back — that
|
|
2035
|
+
// is the reversible hatch, exactly what the setting promises.
|
|
2036
|
+
//
|
|
2037
|
+
// This drives BOTH entry points in the order `meta/src/index.ts` runs them (the
|
|
2038
|
+
// offer from the merged first-run handler, the restore last in the registration
|
|
2039
|
+
// handler because a successful restore can call `ctx.reload()`) against real
|
|
2040
|
+
// files, so the invariant is an artefact rather than a comment.
|
|
2041
|
+
|
|
2042
|
+
describe("the invariant — the offer and the restore cannot both act on keybindings.json", () => {
|
|
2043
|
+
beforeEach(clearNoticeGuard);
|
|
2044
|
+
|
|
2045
|
+
/** What one session did, from the outside. */
|
|
2046
|
+
interface Session {
|
|
2047
|
+
offered: boolean;
|
|
2048
|
+
/** Whether the offer got as far as asking the user. */
|
|
2049
|
+
asked: boolean;
|
|
2050
|
+
restore: NativePasteRestoreOutcome;
|
|
2051
|
+
notices: string[];
|
|
2052
|
+
file: string | null;
|
|
2053
|
+
flag: boolean;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* One session, both flows, meta's order. Base-shape ctxs (no `reload`), which
|
|
2058
|
+
* is what the measured Pi versions hand a `session_start` handler, so neither
|
|
2059
|
+
* flow reloads the runtime out from under the other and the file/notice
|
|
2060
|
+
* effects are all that is left to judge.
|
|
2061
|
+
*
|
|
2062
|
+
* The namespace is MERGED into whatever settings the profile already holds,
|
|
2063
|
+
* because that is what a user does: they edit one key in `settings.json`. So a
|
|
2064
|
+
* second call inside one test is a later session on the same profile, and it
|
|
2065
|
+
* keeps the flag an earlier session spent — replacing the namespace here would
|
|
2066
|
+
* quietly reset the state the sequenced-pair test depends on.
|
|
2067
|
+
*/
|
|
2068
|
+
async function runSession(
|
|
2069
|
+
ns: Record<string, unknown>,
|
|
2070
|
+
opts?: { confirmAnswer?: boolean; existingFile?: string },
|
|
2071
|
+
): Promise<Session> {
|
|
2072
|
+
const before = readSettings();
|
|
2073
|
+
const beforeNs = (before[NAMESPACE] ?? {}) as Record<string, unknown>;
|
|
2074
|
+
writeSettings({ ...before, [NAMESPACE]: { ...beforeNs, ...ns } });
|
|
2075
|
+
if (opts?.existingFile !== undefined) writeKeybindings(opts.existingFile);
|
|
2076
|
+
|
|
2077
|
+
const offer = makeCtx("tui");
|
|
2078
|
+
offer.confirm.mockResolvedValue(opts?.confirmAnswer ?? true);
|
|
2079
|
+
const offered = await offerKeybindingFix(offer.ctx);
|
|
2080
|
+
|
|
2081
|
+
const restore = makeCtx("tui");
|
|
2082
|
+
const outcome = await runNativePasteRestore(restore.ctx);
|
|
2083
|
+
|
|
2084
|
+
return {
|
|
2085
|
+
offered,
|
|
2086
|
+
asked: offer.confirm.mock.calls.length > 0,
|
|
2087
|
+
restore: outcome,
|
|
2088
|
+
notices: [
|
|
2089
|
+
...callArgs(offer.notify).map((c) => String(c[0])),
|
|
2090
|
+
...callArgs(restore.notify).map((c) => String(c[0])),
|
|
2091
|
+
],
|
|
2092
|
+
file: fs.existsSync(keybindingsPath())
|
|
2093
|
+
? fs.readFileSync(keybindingsPath(), "utf-8")
|
|
2094
|
+
: null,
|
|
2095
|
+
flag: readFlag(),
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/** The hatch, open: the setting that flips which flow is allowed to act. */
|
|
2100
|
+
const HATCH_OFF: Record<string, unknown> = { suppressNativePaste: false };
|
|
2101
|
+
const hatchOn: Array<Record<string, unknown>> = [{}, { suppressNativePaste: true }];
|
|
2102
|
+
|
|
2103
|
+
it("hatch open, fresh profile: the session is silent end to end — no Created, no Restored, no file, still armed", async () => {
|
|
2104
|
+
const s = await runSession(HATCH_OFF);
|
|
2105
|
+
|
|
2106
|
+
expect(s.offered).toBe(false);
|
|
2107
|
+
expect(s.restore.kind).toBe("missing");
|
|
2108
|
+
expect(s.file).toBe(null);
|
|
2109
|
+
expect(s.flag).toBe(false);
|
|
2110
|
+
// Before #133 this frame held exactly two notices: the offer wrote the file
|
|
2111
|
+
// and said "Created …", the restore emptied it and said "Restored …". The
|
|
2112
|
+
// hatch-off install never sees either one now.
|
|
2113
|
+
expect(s.notices).toEqual([]);
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
it("hatch open with our artefact already on disk: only the restore acts, and the offer never speaks about that file", async () => {
|
|
2117
|
+
const s = await runSession(HATCH_OFF, { existingFile: ARTEFACT_POST_123 });
|
|
2118
|
+
|
|
2119
|
+
expect(s.offered).toBe(false);
|
|
2120
|
+
expect(s.restore.kind).toBe("restored");
|
|
2121
|
+
expect(s.file).toBe("{}");
|
|
2122
|
+
expect(s.notices.filter((t) => CREATED_TEXT.test(t))).toEqual([]);
|
|
2123
|
+
expect(s.notices.filter((t) => RESTORED_TEXT.test(t))).toHaveLength(1);
|
|
2124
|
+
// The offer stayed armed: the file it would have offered to create is the
|
|
2125
|
+
// one the restore just handed back to pi.
|
|
2126
|
+
expect(s.flag).toBe(false);
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
it.each([
|
|
2130
|
+
["the default (key absent)", {}],
|
|
2131
|
+
["hatch closed (suppressNativePaste: true)", { suppressNativePaste: true }],
|
|
2132
|
+
] as Array<[string, Record<string, unknown>]>)(
|
|
2133
|
+
"%s: only the offer acts, and the restore reports suppressed",
|
|
2134
|
+
async (_label, ns) => {
|
|
2135
|
+
const s = await runSession(ns);
|
|
2136
|
+
|
|
2137
|
+
expect(s.offered).toBe(false); // base-shape ctx: no reload was available
|
|
2138
|
+
expect(s.restore.kind).toBe("suppressed");
|
|
2139
|
+
expect(s.file).toBe(SNIPPET); // the offer wrote the artefact …
|
|
2140
|
+
expect(s.flag).toBe(true); // … and the accept spent the flag
|
|
2141
|
+
expect(s.notices.filter((t) => RESTORED_TEXT.test(t))).toEqual([]);
|
|
2142
|
+
expect(s.notices.filter((t) => CREATED_TEXT.test(t))).toHaveLength(1);
|
|
2143
|
+
},
|
|
2144
|
+
);
|
|
2145
|
+
|
|
2146
|
+
it("the sequenced pair across a settings change is the hatch, not the breach: Created in session one, Restored in session two", async () => {
|
|
2147
|
+
// Greptile's read of the invariant, answered with a test rather than an
|
|
2148
|
+
// argument. Session one, the default setting: the offer fires, writes the
|
|
2149
|
+
// artefact and says "Created …".
|
|
2150
|
+
const first = await runSession({});
|
|
2151
|
+
expect(first.file).toBe(SNIPPET);
|
|
2152
|
+
expect(first.notices.filter((t) => CREATED_TEXT.test(t))).toHaveLength(1);
|
|
2153
|
+
expect(first.notices.filter((t) => RESTORED_TEXT.test(t))).toEqual([]);
|
|
2154
|
+
|
|
2155
|
+
// Session two on the SAME profile, with the user having closed the hatch: the
|
|
2156
|
+
// restore gives the key back and says "Restored …". So one install does carry
|
|
2157
|
+
// both texts — in order, one per session, each describing what that session
|
|
2158
|
+
// did. That is the reversible hatch from ADR 0030, and the mutual exclusion
|
|
2159
|
+
// above must not be read to forbid it: it binds a single configuration, and
|
|
2160
|
+
// this case has two.
|
|
2161
|
+
clearNoticeGuard();
|
|
2162
|
+
const second = await runSession(HATCH_OFF);
|
|
2163
|
+
expect(second.restore.kind).toBe("restored");
|
|
2164
|
+
expect(second.file).toBe("{}");
|
|
2165
|
+
expect(second.notices.filter((t) => RESTORED_TEXT.test(t))).toHaveLength(1);
|
|
2166
|
+
// The offer stayed out of session two on both counts: the flag was spent in
|
|
2167
|
+
// session one, and the hatch is open.
|
|
2168
|
+
expect(second.asked).toBe(false);
|
|
2169
|
+
expect(second.notices.filter((t) => CREATED_TEXT.test(t))).toEqual([]);
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
it("no configuration makes both flows act on one file", async () => {
|
|
2173
|
+
for (const ns of [HATCH_OFF, ...hatchOn, { enabled: true, suppressNativePaste: false }]) {
|
|
2174
|
+
cleanupArtifacts();
|
|
2175
|
+
clearNoticeGuard();
|
|
2176
|
+
const s = await runSession(ns);
|
|
2177
|
+
const label = JSON.stringify(ns);
|
|
2178
|
+
|
|
2179
|
+
// The notices: never two verdicts about one file.
|
|
2180
|
+
const created = s.notices.some((t) => CREATED_TEXT.test(t));
|
|
2181
|
+
const restored = s.notices.some((t) => RESTORED_TEXT.test(t));
|
|
2182
|
+
expect(created && restored, `${label}: both notices: ${JSON.stringify(s.notices)}`).toBe(
|
|
2183
|
+
false,
|
|
2184
|
+
);
|
|
2185
|
+
|
|
2186
|
+
// The same exclusivity one level down, on the flows themselves: the
|
|
2187
|
+
// predicate that admits one must exclude the other.
|
|
2188
|
+
if (suiteMayClaimPasteKey()) {
|
|
2189
|
+
// The offer's configuration — the restore must have touched nothing.
|
|
2190
|
+
expect(s.restore.kind, label).toBe("suppressed");
|
|
2191
|
+
} else {
|
|
2192
|
+
// The restore's configuration — the offer must not have asked about,
|
|
2193
|
+
// written, or announced the file the restore is entitled to rewrite.
|
|
2194
|
+
expect(s.asked, label).toBe(false);
|
|
2195
|
+
expect(created, label).toBe(false);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
});
|
|
2199
|
+
});
|
|
2200
|
+
|