gentle-pi 4.3.0 → 4.4.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.
Files changed (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,201 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { TestContext } from "node:test";
6
+ import test from "node:test";
7
+ import { DesignCommentsError, MAX_DESIGN_COMMENTS, MAX_DESIGN_SCREENSHOT_BYTES, createDesignCommentStore } from "../lib/design-comments.ts";
8
+
9
+ // design/<slug>/comments.json has one owner: every read and write goes through
10
+ // lib/design-comments.ts, so the canvas server and the agent tool serialize.
11
+
12
+ function scratch(t: TestContext, prefix: string): string {
13
+ const dir = mkdtempSync(join(tmpdir(), prefix));
14
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
15
+ return dir;
16
+ }
17
+
18
+ function designRoot(t: TestContext) {
19
+ const root = join(scratch(t, "gentle-design-comments-"), "design");
20
+ mkdirSync(join(root, "alpha"), { recursive: true });
21
+ return root;
22
+ }
23
+
24
+ const INPUT = { file: "01-hero.html", selector: "body > h1", excerpt: "Hero", comment: "Make it bigger" };
25
+ const FIXED = new Date("2026-10-26T10:00:00.000Z");
26
+
27
+ function fixedStore(root: string) {
28
+ let next = 0;
29
+ return createDesignCommentStore(root, { now: () => FIXED, randomId: () => `id${String(next++).padStart(4, "0")}` });
30
+ }
31
+
32
+ test("a missing comments.json reads as an empty document, and add appends an open, unsent comment", async (t) => {
33
+ const root = designRoot(t);
34
+ const store = fixedStore(root);
35
+ assert.deepEqual(await store.read("alpha"), { version: 1, comments: [] });
36
+ const added = await store.add("alpha", INPUT);
37
+ assert.deepEqual(added, { id: "id0000", ...INPUT, createdAt: FIXED.toISOString(), status: "open", sentAt: null, resolvedAt: null });
38
+ const onDisk = JSON.parse(readFileSync(join(root, "alpha", "comments.json"), "utf8"));
39
+ assert.deepEqual(onDisk, { version: 1, comments: [added] });
40
+ assert.deepEqual(await store.read("alpha"), onDisk);
41
+ assert.deepEqual(readdirSync(join(root, "alpha")), ["comments.json"], "no temp file is left behind");
42
+ });
43
+
44
+ test("generated ids are short, URL-safe and unique within the design", async (t) => {
45
+ const root = designRoot(t);
46
+ const store = createDesignCommentStore(root);
47
+ const ids = new Set<string>();
48
+ for (let index = 0; index < 30; index++) ids.add((await store.add("alpha", INPUT)).id);
49
+ assert.equal(ids.size, 30);
50
+ for (const id of ids) assert.match(id, /^[a-z0-9]{6,12}$/);
51
+ // A colliding generator still yields unique ids.
52
+ const colliding = createDesignCommentStore(root, { randomId: () => [...ids][0] });
53
+ await assert.rejects(colliding.add("alpha", INPUT), (error: unknown) => error instanceof DesignCommentsError && error.code === "id-exhausted");
54
+ });
55
+
56
+ test("setStatus resolves and reopens by id, and reports unknown ids", async (t) => {
57
+ const root = designRoot(t);
58
+ const store = fixedStore(root);
59
+ const a = await store.add("alpha", INPUT);
60
+ const b = await store.add("alpha", { ...INPUT, file: "02-bold.html" });
61
+ const resolved = await store.setStatus("alpha", [a.id, "nope00"], "resolved");
62
+ assert.deepEqual(resolved.updated.map((comment) => [comment.id, comment.status, comment.resolvedAt]), [[a.id, "resolved", FIXED.toISOString()]]);
63
+ assert.deepEqual(resolved.unknown, ["nope00"]);
64
+ assert.deepEqual((await store.listOpen("alpha")).map((comment) => comment.id), [b.id]);
65
+ assert.deepEqual((await store.listOpen("alpha", "02-bold.html")).map((comment) => comment.id), [b.id]);
66
+ assert.deepEqual(await store.listOpen("alpha", "01-hero.html"), []);
67
+ const reopened = await store.setStatus("alpha", [a.id], "open");
68
+ assert.equal(reopened.updated[0].status, "open");
69
+ assert.equal(reopened.updated[0].resolvedAt, null);
70
+ await assert.rejects(store.setStatus("alpha", [a.id], "done" as never), (error: unknown) => error instanceof DesignCommentsError && error.code === "invalid-input");
71
+ });
72
+
73
+ test("markSent stamps sentAt once and leaves other comments alone", async (t) => {
74
+ const root = designRoot(t);
75
+ let clock = FIXED.getTime();
76
+ const store = createDesignCommentStore(root, { now: () => new Date(clock) });
77
+ const a = await store.add("alpha", INPUT);
78
+ const b = await store.add("alpha", INPUT);
79
+ await store.markSent("alpha", [a.id]);
80
+ clock += 60_000;
81
+ await store.markSent("alpha", [a.id]);
82
+ const { comments } = await store.read("alpha");
83
+ assert.equal(comments.find((comment) => comment.id === a.id)!.sentAt, FIXED.toISOString());
84
+ assert.equal(comments.find((comment) => comment.id === b.id)!.sentAt, null);
85
+ });
86
+
87
+ test("concurrent adds through separate stores keep every comment in valid JSON", async (t) => {
88
+ const root = designRoot(t);
89
+ // Two stores, as the server and the agent tool would have.
90
+ const server = createDesignCommentStore(root);
91
+ const tool = createDesignCommentStore(root);
92
+ const added = await Promise.all(Array.from({ length: 20 }, (_, index) => (index % 2 ? server : tool).add("alpha", { ...INPUT, comment: `c${index}` })));
93
+ const onDisk = JSON.parse(readFileSync(join(root, "alpha", "comments.json"), "utf8"));
94
+ assert.equal(onDisk.comments.length, 20);
95
+ assert.deepEqual(new Set(onDisk.comments.map((comment: { id: string }) => comment.id)), new Set(added.map((comment) => comment.id)));
96
+ await Promise.all([server.setStatus("alpha", [added[0].id], "resolved"), tool.markSent("alpha", [added[1].id]), server.add("alpha", INPUT)]);
97
+ const after = await tool.read("alpha");
98
+ assert.equal(after.comments.length, 21);
99
+ assert.equal(after.comments.find((comment) => comment.id === added[0].id)!.status, "resolved");
100
+ assert.notEqual(after.comments.find((comment) => comment.id === added[1].id)!.sentAt, null);
101
+ });
102
+
103
+ test("an unparsable or invalid comments.json is a typed error and is never overwritten", async (t) => {
104
+ const root = designRoot(t);
105
+ const store = createDesignCommentStore(root);
106
+ const path = join(root, "alpha", "comments.json");
107
+ for (const bytes of ["{ not json", JSON.stringify({ version: 2, comments: [] }), JSON.stringify({ version: 1, comments: [{ id: "x" }] }), "[]"]) {
108
+ writeFileSync(path, bytes);
109
+ await assert.rejects(store.read("alpha"), (error: unknown) => error instanceof DesignCommentsError && error.code === "invalid-file", bytes);
110
+ await assert.rejects(store.add("alpha", INPUT), (error: unknown) => error instanceof DesignCommentsError && error.code === "invalid-file");
111
+ await assert.rejects(store.setStatus("alpha", ["x"], "resolved"));
112
+ assert.equal(readFileSync(path, "utf8"), bytes, "bytes unchanged");
113
+ }
114
+ });
115
+
116
+ test("a design holds at most 500 comments; the next one is refused with a clear error", async (t) => {
117
+ const root = designRoot(t);
118
+ const store = createDesignCommentStore(root);
119
+ assert.equal(MAX_DESIGN_COMMENTS, 500);
120
+ const comments = Array.from({ length: 500 }, (_, index) => ({ id: `c${String(index).padStart(5, "0")}`, ...INPUT, createdAt: FIXED.toISOString(), status: "open", sentAt: null, resolvedAt: null }));
121
+ writeFileSync(join(root, "alpha", "comments.json"), JSON.stringify({ version: 1, comments }));
122
+ await assert.rejects(store.add("alpha", INPUT), (error: unknown) => error instanceof DesignCommentsError && error.code === "limit" && /500/.test(error.message));
123
+ assert.equal((await store.read("alpha")).comments.length, 500);
124
+ });
125
+
126
+ test("inputs are validated before anything is written", async (t) => {
127
+ const root = designRoot(t);
128
+ const store = createDesignCommentStore(root);
129
+ const invalid = [
130
+ { ...INPUT, file: "../beta/01.html" },
131
+ { ...INPUT, file: "styles.css" },
132
+ { ...INPUT, comment: "" },
133
+ { ...INPUT, comment: "x".repeat(2001) },
134
+ { ...INPUT, selector: "" },
135
+ { ...INPUT, selector: "a".repeat(501) },
136
+ { ...INPUT, excerpt: "e".repeat(301) },
137
+ ];
138
+ for (const input of invalid) await assert.rejects(store.add("alpha", input), (error: unknown) => error instanceof DesignCommentsError && error.code === "invalid-input");
139
+ for (const slug of ["Alpha", "../alpha", "", "missing"]) await assert.rejects(store.add(slug, INPUT), (error: unknown) => error instanceof DesignCommentsError && error.code === "not-found", slug);
140
+ assert.deepEqual(readdirSync(join(root, "alpha")), []);
141
+ });
142
+
143
+ test("a design directory or comments.json that escapes the root by symlink is refused", async (t) => {
144
+ const root = designRoot(t);
145
+ const outside = scratch(t, "gentle-design-comments-outside-");
146
+ writeFileSync(join(outside, "comments.json"), JSON.stringify({ version: 1, comments: [] }));
147
+ symlinkSync(outside, join(root, "escape"));
148
+ const store = createDesignCommentStore(root);
149
+ await assert.rejects(store.read("escape"), (error: unknown) => error instanceof DesignCommentsError && error.code === "not-found");
150
+ await assert.rejects(store.add("escape", INPUT), (error: unknown) => error instanceof DesignCommentsError && error.code === "not-found");
151
+ mkdirSync(join(root, "beta"));
152
+ symlinkSync(join(outside, "comments.json"), join(root, "beta", "comments.json"));
153
+ await assert.rejects(store.add("beta", INPUT), (error: unknown) => error instanceof DesignCommentsError && error.code === "not-found");
154
+ assert.equal(readFileSync(join(outside, "comments.json"), "utf8"), JSON.stringify({ version: 1, comments: [] }), "the outside file is untouched");
155
+ });
156
+
157
+ const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from("frame")]);
158
+
159
+ test("a comment with a screenshot stores the image beside comments.json and reads it back", async (t) => {
160
+ const root = designRoot(t);
161
+ const store = fixedStore(root);
162
+ const added = await store.add("alpha", { ...INPUT, screenshot: { mimeType: "image/png", data: PNG } });
163
+ assert.deepEqual(added, { id: "id0000", ...INPUT, screenshot: ".screenshots/id0000.png", createdAt: FIXED.toISOString(), status: "open", sentAt: null, resolvedAt: null });
164
+ assert.deepEqual(readFileSync(join(root, "alpha", ".screenshots", "id0000.png")), PNG);
165
+ assert.deepEqual(JSON.parse(readFileSync(join(root, "alpha", "comments.json"), "utf8")).comments, [added]);
166
+ assert.deepEqual(await store.read("alpha"), { version: 1, comments: [added] });
167
+ assert.deepEqual(await store.readScreenshot("alpha", "id0000"), { path: ".screenshots/id0000.png", mimeType: "image/png", data: PNG });
168
+ const plain = await store.add("alpha", INPUT);
169
+ assert.equal("screenshot" in plain, false, "a comment without a screenshot keeps the old shape");
170
+ assert.equal(await store.readScreenshot("alpha", plain.id), undefined);
171
+ assert.equal(await store.readScreenshot("alpha", "nope"), undefined);
172
+ assert.deepEqual(readdirSync(join(root, "alpha", ".screenshots")), ["id0000.png"], "no temp file is left behind");
173
+ });
174
+
175
+ test("a screenshot of an unknown type, a mismatched signature, or over the size limit is refused and writes nothing", async (t) => {
176
+ const root = designRoot(t);
177
+ const store = fixedStore(root);
178
+ const refused = [
179
+ { mimeType: "image/svg+xml", data: PNG },
180
+ { mimeType: "image/jpeg", data: PNG },
181
+ { mimeType: "image/png", data: Buffer.alloc(0) },
182
+ { mimeType: "image/png", data: Buffer.concat([PNG, Buffer.alloc(MAX_DESIGN_SCREENSHOT_BYTES)]) },
183
+ ];
184
+ for (const screenshot of refused) {
185
+ await assert.rejects(store.add("alpha", { ...INPUT, screenshot }), (error: unknown) => error instanceof DesignCommentsError && error.code === "invalid-input");
186
+ }
187
+ assert.deepEqual(readdirSync(join(root, "alpha")), []);
188
+ });
189
+
190
+ test("a stored screenshot path outside .screenshots makes comments.json invalid, and a linked .screenshots is refused", async (t) => {
191
+ const root = designRoot(t);
192
+ const store = fixedStore(root);
193
+ const entry = { id: "a1", ...INPUT, screenshot: "../secret.png", createdAt: FIXED.toISOString(), status: "open", sentAt: null, resolvedAt: null };
194
+ writeFileSync(join(root, "alpha", "comments.json"), JSON.stringify({ version: 1, comments: [entry] }));
195
+ await assert.rejects(store.read("alpha"), (error: unknown) => error instanceof DesignCommentsError && error.code === "invalid-file");
196
+ rmSync(join(root, "alpha", "comments.json"));
197
+ const outside = scratch(t, "gentle-design-shots-outside-");
198
+ symlinkSync(outside, join(root, "alpha", ".screenshots"));
199
+ await assert.rejects(store.add("alpha", { ...INPUT, screenshot: { mimeType: "image/png", data: PNG } }), (error: unknown) => error instanceof DesignCommentsError && error.code === "invalid-file");
200
+ assert.deepEqual(readdirSync(outside), []);
201
+ });
@@ -0,0 +1,282 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { TestContext } from "node:test";
7
+ import test from "node:test";
8
+ import { DESIGN_HANDOFF_NOTE_LIMIT, DesignHandoffsError, createDesignHandoffStore, designPreviewUrlProblem, sendDesignHandoff } from "../lib/design-handoffs.ts";
9
+
10
+ // design/<slug>/handoffs.json has one owner (S15): the canvas server, the
11
+ // command and the gentle_design tool all write through lib/design-handoffs.ts,
12
+ // in the same per-file queue as comments.json.
13
+
14
+ function scratch(t: TestContext, prefix: string): string {
15
+ const dir = mkdtempSync(join(tmpdir(), prefix));
16
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
17
+ return dir;
18
+ }
19
+
20
+ function designRoot(t: TestContext) {
21
+ const root = join(scratch(t, "gentle-design-handoffs-"), "design");
22
+ mkdirSync(join(root, "alpha"), { recursive: true });
23
+ return root;
24
+ }
25
+
26
+ /** A clock that advances one second per call, so every timestamp is distinct and predictable. */
27
+ function ticking() {
28
+ let seconds = 0;
29
+ return () => new Date(Date.UTC(2026, 9, 26, 10, 0, seconds++));
30
+ }
31
+
32
+ const at = (seconds: number) => new Date(Date.UTC(2026, 9, 26, 10, 0, seconds)).toISOString();
33
+ const onDisk = (root: string) => JSON.parse(readFileSync(join(root, "alpha", "handoffs.json"), "utf8"));
34
+ const hash = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex");
35
+ const isCode = (code: string) => (error: unknown) => error instanceof DesignHandoffsError && error.code === code;
36
+
37
+ test("a missing handoffs.json reads as an empty document with nothing chosen", async (t) => {
38
+ const root = designRoot(t);
39
+ assert.deepEqual(await createDesignHandoffStore(root).read("alpha"), { version: 1, chosen: null, handoffs: {} });
40
+ });
41
+
42
+ test("markSent records a sent handoff, sets chosen, and a second handoff moves chosen", async (t) => {
43
+ const root = designRoot(t);
44
+ const store = createDesignHandoffStore(root, { now: ticking() });
45
+ const first = await store.markSent("alpha", "01-a.html", { queued: true, feature: "odd/tasks/login.md" });
46
+ assert.deepEqual(first.entry, { status: "sent", queued: true, sentAt: at(0), startedAt: null, implementedAt: null, feature: "odd/tasks/login.md" });
47
+ assert.deepEqual(onDisk(root), { version: 1, chosen: "01-a.html", handoffs: { "01-a.html": first.entry } });
48
+ assert.deepEqual(readdirSync(join(root, "alpha")), ["handoffs.json"], "no temp file is left behind");
49
+
50
+ const second = await store.markSent("alpha", "02-b.html", { queued: false });
51
+ assert.equal(onDisk(root).chosen, "02-b.html", "one chosen artboard per design; the new handoff moves it");
52
+ assert.deepEqual(onDisk(root).handoffs["01-a.html"], first.entry, "the earlier handoff keeps its state");
53
+ assert.deepEqual(second.entry, { status: "sent", queued: false, sentAt: at(1), startedAt: null, implementedAt: null });
54
+ assert.deepEqual(await store.read("alpha"), onDisk(root));
55
+ });
56
+
57
+ test("setStatus moves a handoff to in_progress and implemented with timestamps, note and previewUrl", async (t) => {
58
+ const root = designRoot(t);
59
+ const store = createDesignHandoffStore(root, { now: ticking() });
60
+ await store.markSent("alpha", "01-a.html", { queued: false });
61
+ const started = await store.setStatus("alpha", "01-a.html", "in_progress", { note: " Starting with the header " });
62
+ assert.deepEqual(started, { status: "in_progress", queued: false, sentAt: at(0), startedAt: at(1), implementedAt: null, note: "Starting with the header" });
63
+ const done = await store.setStatus("alpha", "01-a.html", "implemented", { previewUrl: "http://localhost:5173/login" });
64
+ assert.deepEqual(done, { status: "implemented", queued: false, sentAt: at(0), startedAt: at(1), implementedAt: at(2), previewUrl: "http://localhost:5173/login" }, "note and previewUrl are exactly what each update passes");
65
+ assert.deepEqual(onDisk(root).handoffs["01-a.html"], done);
66
+
67
+ // A new handoff of the same artboard starts over.
68
+ const again = await store.markSent("alpha", "01-a.html", { queued: true });
69
+ assert.deepEqual(again.entry, { status: "sent", queued: true, sentAt: at(3), startedAt: null, implementedAt: null });
70
+ const restarted = await store.setStatus("alpha", "01-a.html", "in_progress", {});
71
+ assert.equal(restarted.implementedAt, null);
72
+ assert.equal(restarted.startedAt, at(4));
73
+ });
74
+
75
+ test("setStatus on an artboard never handed off records it and makes it chosen; it never moves chosen otherwise", async (t) => {
76
+ const root = designRoot(t);
77
+ const store = createDesignHandoffStore(root, { now: ticking() });
78
+ const entry = await store.setStatus("alpha", "02-b.html", "in_progress", {});
79
+ assert.deepEqual(entry, { status: "in_progress", queued: false, sentAt: at(0), startedAt: at(0), implementedAt: null });
80
+ assert.equal(onDisk(root).chosen, "02-b.html");
81
+ await store.markSent("alpha", "01-a.html", { queued: false });
82
+ await store.setStatus("alpha", "02-b.html", "implemented", {});
83
+ assert.equal(onDisk(root).chosen, "01-a.html", "an update of an earlier handoff keeps the latest handoff chosen");
84
+ });
85
+
86
+ test("setStatus and markSent refuse bad input with a typed error and write nothing", async (t) => {
87
+ const root = designRoot(t);
88
+ const store = createDesignHandoffStore(root);
89
+ await assert.rejects(store.setStatus("alpha", "01-a.html", "sent" as never, {}), isCode("invalid-input"));
90
+ await assert.rejects(store.setStatus("alpha", "01-a.html", "done" as never, {}), isCode("invalid-input"));
91
+ await assert.rejects(store.setStatus("alpha", "../x.html", "in_progress", {}), isCode("invalid-input"));
92
+ await assert.rejects(store.setStatus("alpha", "01-a.html", "in_progress", { note: "x".repeat(DESIGN_HANDOFF_NOTE_LIMIT + 1) }), isCode("invalid-input"));
93
+ await assert.rejects(store.setStatus("alpha", "01-a.html", "in_progress", { note: 7 as never }), isCode("invalid-input"));
94
+ await assert.rejects(store.markSent("alpha", "01-a.html", { queued: "yes" as never }), isCode("invalid-input"));
95
+ await assert.rejects(store.markSent("alpha", "01-a.html", { queued: false, feature: "../x.md" }), isCode("invalid-input"));
96
+ await assert.rejects(store.markSent("alpha", "notes.txt", { queued: false }), isCode("invalid-input"));
97
+ await assert.rejects(store.setStatus("Bad_Slug", "01-a.html", "in_progress", {}), isCode("not-found"));
98
+ await assert.rejects(store.setStatus("missing", "01-a.html", "in_progress", {}), isCode("not-found"));
99
+ const ok = await store.setStatus("alpha", "01-a.html", "in_progress", { note: "x".repeat(DESIGN_HANDOFF_NOTE_LIMIT) });
100
+ assert.equal(ok.note?.length, DESIGN_HANDOFF_NOTE_LIMIT, "a note at the limit is kept");
101
+ assert.equal(Object.keys(onDisk(root).handoffs).length, 1);
102
+ });
103
+
104
+ const ACCEPTED = [
105
+ "http://localhost:3000/",
106
+ "http://localhost:5173/login?tab=a#top",
107
+ "https://localhost:8443/",
108
+ "http://127.0.0.1:8080/app",
109
+ "https://127.0.0.1:1/",
110
+ "http://[::1]:3000/",
111
+ "https://[::1]:4443/x",
112
+ "http://localhost/",
113
+ "http://LOCALHOST:3000/",
114
+ ];
115
+
116
+ const REJECTED = [
117
+ "http://example.com",
118
+ "http://localhost.evil.test",
119
+ "http://localhost.evil.test:3000/",
120
+ "http://evil.localhost:3000/",
121
+ "http://user:pw@localhost:3000/",
122
+ "http://user@localhost:3000/",
123
+ "javascript:alert(1)",
124
+ "file:///etc/passwd",
125
+ "data:text/html,hi",
126
+ "ftp://localhost/",
127
+ "ws://localhost:3000/",
128
+ "http://127.0.0.2/",
129
+ "http://0.0.0.0:3000/",
130
+ "http://[::2]:3000/",
131
+ "http://localhost:3000/\nInjected",
132
+ " http://localhost:3000/",
133
+ "//localhost:3000/",
134
+ "localhost:3000",
135
+ "",
136
+ `http://localhost:3000/${"a".repeat(2048 - "http://localhost:3000/".length + 1)}`,
137
+ ];
138
+
139
+ test("previewUrl accepts only loopback http(s) URLs without credentials, at most 2048 characters", () => {
140
+ for (const url of ACCEPTED) assert.equal(designPreviewUrlProblem(url), undefined, url);
141
+ assert.equal(designPreviewUrlProblem(`http://localhost:3000/${"a".repeat(2048 - "http://localhost:3000/".length)}`), undefined, "exactly 2048 characters");
142
+ for (const url of REJECTED) assert.equal(typeof designPreviewUrlProblem(url), "string", url);
143
+ assert.equal(REJECTED.at(-1)!.length, 2049);
144
+ for (const value of [7, null, undefined, {}]) assert.equal(typeof designPreviewUrlProblem(value), "string", String(value));
145
+ });
146
+
147
+ test("setStatus stores a valid previewUrl and refuses an invalid one with a typed error", async (t) => {
148
+ const root = designRoot(t);
149
+ const store = createDesignHandoffStore(root);
150
+ await store.markSent("alpha", "01-a.html", { queued: false });
151
+ const before = hash(join(root, "alpha", "handoffs.json"));
152
+ for (const url of REJECTED) {
153
+ await assert.rejects(store.setStatus("alpha", "01-a.html", "implemented", { previewUrl: url }), isCode("invalid-preview-url"), url);
154
+ }
155
+ assert.equal(hash(join(root, "alpha", "handoffs.json")), before, "a refused update writes nothing");
156
+ for (const url of ACCEPTED) {
157
+ const entry = await store.setStatus("alpha", "01-a.html", "implemented", { previewUrl: url });
158
+ assert.equal(entry.previewUrl, new URL(url).href, url);
159
+ }
160
+ });
161
+
162
+ test("20 parallel markSent and setStatus calls leave valid JSON and lose nothing", async (t) => {
163
+ const root = designRoot(t);
164
+ const store = createDesignHandoffStore(root);
165
+ // A second store over the same root shares the process-wide queue.
166
+ const other = createDesignHandoffStore(root);
167
+ const files = Array.from({ length: 20 }, (_, index) => `${String(index).padStart(2, "0")}-a.html`);
168
+ await Promise.all(files.map((file, index) => index % 2 === 0
169
+ ? store.markSent("alpha", file, { queued: index % 4 === 0 })
170
+ : other.setStatus("alpha", file, "implemented", { previewUrl: `http://localhost:${3000 + index}/` })));
171
+ const document = onDisk(root);
172
+ assert.deepEqual(Object.keys(document.handoffs).sort(), files);
173
+ for (const [index, file] of files.entries()) {
174
+ const entry = document.handoffs[file];
175
+ if (index % 2 === 0) assert.equal(entry.status, "sent", file);
176
+ else assert.deepEqual([entry.status, entry.previewUrl], ["implemented", `http://localhost:${3000 + index}/`], file);
177
+ }
178
+ assert.ok(files.includes(document.chosen));
179
+ assert.deepEqual(readdirSync(join(root, "alpha")), ["handoffs.json"]);
180
+
181
+ // Racing updates of one artboard each land whole: the last one in queue order wins, never a mix.
182
+ const results = await Promise.all(Array.from({ length: 20 }, (_, index) => store.setStatus("alpha", "00-a.html", index % 2 ? "implemented" : "in_progress", { note: `n${index}` })));
183
+ const final = onDisk(root).handoffs["00-a.html"];
184
+ const index = Number(/^n(\d+)$/.exec(final.note)![1]);
185
+ assert.equal(final.status, index % 2 ? "implemented" : "in_progress");
186
+ assert.ok(results.some((entry) => JSON.stringify(entry) === JSON.stringify(final)), "the file holds one complete update");
187
+ });
188
+
189
+ test("an invalid handoffs.json is reported and never overwritten", async (t) => {
190
+ const root = designRoot(t);
191
+ const store = createDesignHandoffStore(root);
192
+ const path = join(root, "alpha", "handoffs.json");
193
+ const broken = [
194
+ "{ not json",
195
+ "[]",
196
+ JSON.stringify({ version: 2, chosen: null, handoffs: {} }),
197
+ JSON.stringify({ version: 1, chosen: null, handoffs: [] }),
198
+ JSON.stringify({ version: 1, chosen: "01-a.html", handoffs: {} }),
199
+ JSON.stringify({ version: 1, chosen: null, handoffs: { "../x.html": { status: "sent", queued: false, sentAt: "t", startedAt: null, implementedAt: null } } }),
200
+ JSON.stringify({ version: 1, chosen: null, handoffs: { "01-a.html": { status: "done", queued: false, sentAt: "t", startedAt: null, implementedAt: null } } }),
201
+ JSON.stringify({ version: 1, chosen: null, handoffs: { "01-a.html": { status: "sent", queued: false, sentAt: "t", startedAt: null, implementedAt: null, previewUrl: "http://example.com/" } } }),
202
+ JSON.stringify({ version: 1, chosen: null, handoffs: { "01-a.html": { status: "sent", queued: 1, sentAt: "t", startedAt: null, implementedAt: null } } }),
203
+ ];
204
+ for (const content of broken) {
205
+ writeFileSync(path, content);
206
+ const before = hash(path);
207
+ await assert.rejects(store.read("alpha"), isCode("invalid-file"), content);
208
+ await assert.rejects(store.markSent("alpha", "01-a.html", { queued: false }), isCode("invalid-file"), content);
209
+ await assert.rejects(store.setStatus("alpha", "01-a.html", "implemented", {}), isCode("invalid-file"), content);
210
+ assert.equal(hash(path), before, `${content} is never overwritten`);
211
+ }
212
+ assert.deepEqual(readdirSync(join(root, "alpha")), ["handoffs.json"]);
213
+ });
214
+
215
+ test("a symlinked handoffs.json is refused, and so is a design that escapes design/", async (t) => {
216
+ const root = designRoot(t);
217
+ const outside = scratch(t, "gentle-design-handoffs-outside-");
218
+ const target = join(outside, "handoffs.json");
219
+ writeFileSync(target, JSON.stringify({ version: 1, chosen: null, handoffs: {} }));
220
+ const before = hash(target);
221
+ symlinkSync(target, join(root, "alpha", "handoffs.json"));
222
+ const store = createDesignHandoffStore(root);
223
+ await assert.rejects(store.read("alpha"), isCode("not-found"));
224
+ await assert.rejects(store.markSent("alpha", "01-a.html", { queued: false }), isCode("not-found"));
225
+ await assert.rejects(store.setStatus("alpha", "01-a.html", "in_progress", {}), isCode("not-found"));
226
+ assert.equal(hash(target), before, "the link target is untouched");
227
+
228
+ mkdirSync(join(outside, "escaped"));
229
+ symlinkSync(join(outside, "escaped"), join(root, "escaped"));
230
+ await assert.rejects(store.markSent("escaped", "01-a.html", { queued: false }), isCode("not-found"));
231
+ for (const slug of ["..", ".", "../design", "alpha/..", ""]) await assert.rejects(store.read(slug), isCode("not-found"), slug);
232
+ assert.deepEqual(readdirSync(join(outside, "escaped")), [], "nothing is written outside design/");
233
+ });
234
+
235
+ test("sendDesignHandoff records the handoff before delivery, then the queued answer of the delivery", async (t) => {
236
+ const root = designRoot(t);
237
+ const store = createDesignHandoffStore(root, { now: ticking() });
238
+ const seen: unknown[] = [];
239
+ const result = await sendDesignHandoff(store, { slug: "alpha", file: "01-a.html", feature: "odd/tasks/login.md" }, () => {
240
+ seen.push(onDisk(root));
241
+ return { queued: true };
242
+ });
243
+ assert.deepEqual(result, { queued: true });
244
+ assert.deepEqual(seen, [{ version: 1, chosen: "01-a.html", handoffs: { "01-a.html": { status: "sent", queued: false, sentAt: at(0), startedAt: null, implementedAt: null, feature: "odd/tasks/login.md" } } }], "recorded before delivery");
245
+ assert.equal(onDisk(root).handoffs["01-a.html"].queued, true);
246
+
247
+ const idle = await sendDesignHandoff(store, { slug: "alpha", file: "02-b.html" }, () => undefined);
248
+ assert.deepEqual(idle, { queued: false });
249
+ assert.equal(onDisk(root).handoffs["02-b.html"].queued, false);
250
+ assert.equal(onDisk(root).chosen, "02-b.html");
251
+ });
252
+
253
+ test("a failed delivery takes the handoff back and restores the previous chosen artboard", async (t) => {
254
+ const root = designRoot(t);
255
+ const store = createDesignHandoffStore(root, { now: ticking() });
256
+ await sendDesignHandoff(store, { slug: "alpha", file: "01-a.html" }, () => ({ queued: false }));
257
+ const before = onDisk(root);
258
+ await assert.rejects(sendDesignHandoff(store, { slug: "alpha", file: "02-b.html" }, () => { throw new Error("boom"); }), /boom/);
259
+ assert.deepEqual(onDisk(root), before, "the failed handoff leaves no trace");
260
+
261
+ await store.setStatus("alpha", "01-a.html", "implemented", {});
262
+ const implemented = onDisk(root);
263
+ await assert.rejects(sendDesignHandoff(store, { slug: "alpha", file: "01-a.html" }, async () => { throw new Error("late"); }), /late/);
264
+ assert.deepEqual(onDisk(root), implemented, "a failed re-handoff restores the earlier state of that artboard");
265
+ });
266
+
267
+ test("a delivery answer never overwrites a state the agent already changed", async (t) => {
268
+ const root = designRoot(t);
269
+ const store = createDesignHandoffStore(root, { now: ticking() });
270
+ await sendDesignHandoff(store, { slug: "alpha", file: "01-a.html" }, async () => {
271
+ // The agent picks the handoff up before the delivery returns.
272
+ await store.setStatus("alpha", "01-a.html", "in_progress", {});
273
+ return { queued: true };
274
+ });
275
+ assert.equal(onDisk(root).handoffs["01-a.html"].status, "in_progress");
276
+ assert.equal(onDisk(root).handoffs["01-a.html"].queued, false);
277
+ await assert.rejects(sendDesignHandoff(store, { slug: "alpha", file: "02-b.html" }, async () => {
278
+ await store.setStatus("alpha", "02-b.html", "in_progress", {});
279
+ throw new Error("boom");
280
+ }), /boom/);
281
+ assert.equal(onDisk(root).handoffs["02-b.html"].status, "in_progress", "a failure after the agent took it over keeps the agent's state");
282
+ });
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import test from "node:test";
4
+ import { openInBrowser } from "../lib/design-open-browser.ts";
5
+
6
+ type Call = { command: string; args: readonly string[]; options: Record<string, unknown> };
7
+ const URL_ = "http://127.0.0.1:4321/abc/";
8
+
9
+ function fakeSpawn(outcome: "spawn" | "error" | "throw") {
10
+ const calls: Call[] = [];
11
+ let unrefs = 0;
12
+ const spawn = (command: string, args: readonly string[], options: Record<string, unknown>) => {
13
+ calls.push({ command, args, options });
14
+ if (outcome === "throw") throw new Error("spawn exploded");
15
+ const child = Object.assign(new EventEmitter(), { unref: () => { unrefs++; } });
16
+ queueMicrotask(() => outcome === "spawn" ? child.emit("spawn") : child.emit("error", Object.assign(new Error("spawn xdg-open ENOENT"), { code: "ENOENT" })));
17
+ return child;
18
+ };
19
+ return { spawn: spawn as never, calls, unrefs: () => unrefs };
20
+ }
21
+
22
+ const OPTIONS = { detached: true, stdio: "ignore", windowsHide: true, shell: false };
23
+
24
+ test("darwin opens the URL with /usr/bin/open, detached and hidden", async () => {
25
+ const fake = fakeSpawn("spawn");
26
+ assert.deepEqual(await openInBrowser(URL_, { platform: "darwin", spawn: fake.spawn }), { opened: true });
27
+ assert.deepEqual(fake.calls, [{ command: "/usr/bin/open", args: [URL_], options: OPTIONS }]);
28
+ assert.equal(fake.unrefs(), 1);
29
+ });
30
+
31
+ test("win32 uses the absolute rundll32 FileProtocolHandler", async () => {
32
+ const fake = fakeSpawn("spawn");
33
+ assert.deepEqual(await openInBrowser(URL_, { platform: "win32", spawn: fake.spawn, env: { SystemRoot: "C:\\Windows" } }), { opened: true });
34
+ assert.deepEqual(fake.calls, [{ command: "C:\\Windows\\System32\\rundll32.exe", args: ["url.dll,FileProtocolHandler", URL_], options: OPTIONS }]);
35
+ const missing = fakeSpawn("spawn");
36
+ const result = await openInBrowser(URL_, { platform: "win32", spawn: missing.spawn, env: {} });
37
+ assert.equal(result.opened, false);
38
+ assert.match(String(result.error), /SystemRoot/);
39
+ assert.deepEqual(missing.calls, []);
40
+ });
41
+
42
+ test("other platforms use xdg-open", async () => {
43
+ for (const platform of ["linux", "freebsd"] as const) {
44
+ const fake = fakeSpawn("spawn");
45
+ assert.deepEqual(await openInBrowser(URL_, { platform, spawn: fake.spawn }), { opened: true });
46
+ assert.deepEqual(fake.calls, [{ command: "xdg-open", args: [URL_], options: OPTIONS }]);
47
+ }
48
+ });
49
+
50
+ test("spawn failures resolve opened:false and never throw", async () => {
51
+ const failing = fakeSpawn("error");
52
+ const result = await openInBrowser(URL_, { platform: "linux", spawn: failing.spawn });
53
+ assert.equal(result.opened, false);
54
+ assert.match(String(result.error), /ENOENT/);
55
+ const throwing = fakeSpawn("throw");
56
+ const thrown = await openInBrowser(URL_, { platform: "darwin", spawn: throwing.spawn });
57
+ assert.equal(thrown.opened, false);
58
+ assert.match(String(thrown.error), /spawn exploded/);
59
+ });
60
+
61
+ test("only loopback http URLs are opened", async () => {
62
+ for (const url of ["https://example.com/", "file:///etc/passwd", "-flag", "http://evil.example:1/", "javascript:alert(1)"]) {
63
+ const fake = fakeSpawn("spawn");
64
+ const result = await openInBrowser(url, { platform: "linux", spawn: fake.spawn });
65
+ assert.equal(result.opened, false, url);
66
+ assert.deepEqual(fake.calls, [], url);
67
+ }
68
+ const localhost = fakeSpawn("spawn");
69
+ assert.equal((await openInBrowser("http://localhost:4321/t/", { platform: "linux", spawn: localhost.spawn })).opened, true);
70
+ });