@openparachute/hub 0.3.0-rc.1

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 (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,422 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readdirSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { migrate, migrateNotice, planArchive, safelistEntries } from "../commands/migrate.ts";
15
+
16
+ interface Harness {
17
+ configDir: string;
18
+ cleanup: () => void;
19
+ }
20
+
21
+ function makeHarness(): Harness {
22
+ const dir = mkdtempSync(join(tmpdir(), "pcli-migrate-"));
23
+ return { configDir: dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
24
+ }
25
+
26
+ function touch(path: string, content = ""): void {
27
+ mkdirSync(join(path, ".."), { recursive: true });
28
+ writeFileSync(path, content);
29
+ }
30
+
31
+ const APRIL_19 = new Date("2026-04-19T12:00:00Z");
32
+ const APRIL_20 = new Date("2026-04-20T09:00:00Z");
33
+
34
+ function seedSafelist(configDir: string): void {
35
+ // Realistic safelist items so we can assert they stay put across every test.
36
+ mkdirSync(join(configDir, "vault"), { recursive: true });
37
+ touch(join(configDir, "vault", "config.json"), "{}");
38
+ mkdirSync(join(configDir, "hub", "run"), { recursive: true });
39
+ mkdirSync(join(configDir, "well-known"), { recursive: true });
40
+ touch(join(configDir, "services.json"), '{"services":[]}');
41
+ touch(join(configDir, "expose-state.json"), "{}");
42
+ }
43
+
44
+ describe("safelistEntries", () => {
45
+ test("covers service dirs, hub, state files, and well-known", () => {
46
+ const s = safelistEntries();
47
+ // Service dirs from SERVICE_SPECS
48
+ expect(s.has("vault")).toBe(true);
49
+ expect(s.has("notes")).toBe(true);
50
+ expect(s.has("scribe")).toBe(true);
51
+ expect(s.has("channel")).toBe(true);
52
+ // Legacy — kept across the Notes→Lens→Notes rename round-trip
53
+ // (Apr 19 → Apr 22) so existing ~/.parachute/lens/ dirs from the
54
+ // brief Lens window don't get archived on upgrade.
55
+ expect(s.has("lens")).toBe(true);
56
+ // Internal
57
+ expect(s.has("hub")).toBe(true);
58
+ // CLI state
59
+ expect(s.has("services.json")).toBe(true);
60
+ expect(s.has("expose-state.json")).toBe(true);
61
+ expect(s.has("well-known")).toBe(true);
62
+ });
63
+ });
64
+
65
+ describe("planArchive", () => {
66
+ test("clean ecosystem root produces an empty plan", () => {
67
+ const h = makeHarness();
68
+ try {
69
+ seedSafelist(h.configDir);
70
+ const plan = planArchive(h.configDir, APRIL_19);
71
+ expect(plan.items).toEqual([]);
72
+ expect(plan.totalBytes).toBe(0);
73
+ expect(plan.archiveDirName).toBe(".archive-2026-04-19");
74
+ } finally {
75
+ h.cleanup();
76
+ }
77
+ });
78
+
79
+ test("pre-restructure cruft is identified and sized", () => {
80
+ const h = makeHarness();
81
+ try {
82
+ seedSafelist(h.configDir);
83
+ touch(join(h.configDir, "daily.db"), "X".repeat(100));
84
+ touch(join(h.configDir, "daily.db-shm"), "S");
85
+ touch(join(h.configDir, "server.yaml"), "port: 1940\n");
86
+ mkdirSync(join(h.configDir, "logs"), { recursive: true });
87
+ touch(join(h.configDir, "logs", "old.log"), "old-entry\n");
88
+ touch(join(h.configDir, "random-note.txt"), "mystery content");
89
+
90
+ const plan = planArchive(h.configDir, APRIL_19);
91
+ const names = plan.items.map((i) => i.name).sort();
92
+ expect(names).toEqual(["daily.db", "daily.db-shm", "logs", "random-note.txt", "server.yaml"]);
93
+ expect(plan.totalBytes).toBeGreaterThan(100);
94
+ // known-cruft annotation is attached
95
+ expect(plan.items.find((i) => i.name === "daily.db")?.annotation).toMatch(/daily/i);
96
+ expect(plan.items.find((i) => i.name === "logs")?.annotation).toMatch(/logs/i);
97
+ // unknown files get no annotation
98
+ expect(plan.items.find((i) => i.name === "random-note.txt")?.annotation).toBeUndefined();
99
+ } finally {
100
+ h.cleanup();
101
+ }
102
+ });
103
+
104
+ test("dotfiles at root are left alone", () => {
105
+ const h = makeHarness();
106
+ try {
107
+ seedSafelist(h.configDir);
108
+ touch(join(h.configDir, ".env"), "SECRET=x");
109
+ touch(join(h.configDir, ".DS_Store"), "");
110
+ mkdirSync(join(h.configDir, ".archive-2026-04-01"), { recursive: true });
111
+ touch(join(h.configDir, ".archive-2026-04-01", "daily.db"), "Y");
112
+
113
+ const plan = planArchive(h.configDir, APRIL_19);
114
+ expect(plan.items).toEqual([]);
115
+ } finally {
116
+ h.cleanup();
117
+ }
118
+ });
119
+
120
+ test("directory sizes are summed recursively", () => {
121
+ const h = makeHarness();
122
+ try {
123
+ seedSafelist(h.configDir);
124
+ const nested = join(h.configDir, "old-tree", "a", "b");
125
+ mkdirSync(nested, { recursive: true });
126
+ touch(join(nested, "inner.dat"), "Z".repeat(500));
127
+ touch(join(h.configDir, "old-tree", "top.dat"), "Q".repeat(300));
128
+
129
+ const plan = planArchive(h.configDir, APRIL_19);
130
+ const oldTree = plan.items.find((i) => i.name === "old-tree");
131
+ expect(oldTree).toBeDefined();
132
+ expect(oldTree?.kind).toBe("dir");
133
+ expect(oldTree?.bytes).toBe(800);
134
+ } finally {
135
+ h.cleanup();
136
+ }
137
+ });
138
+
139
+ test("symlinks at root are not followed when sizing", () => {
140
+ // Regression guard: Dirent.isDirectory() returns true for a symlink to a
141
+ // directory on macOS/Linux, so the pre-fix planner would descend sizeOf()
142
+ // into the link's target. A user pointing ~/.parachute/external-backup
143
+ // at a multi-GB external volume would see absurd byte totals and pay
144
+ // the cost of walking that tree.
145
+ const targetHarness = makeHarness();
146
+ const h = makeHarness();
147
+ try {
148
+ seedSafelist(h.configDir);
149
+ // Populate the target with a much-bigger-than-plausible file so if the
150
+ // planner does descend, the assertion on bytes=0 would obviously fail.
151
+ touch(join(targetHarness.configDir, "huge.bin"), "X".repeat(10_000));
152
+ touch(join(targetHarness.configDir, "more.bin"), "Y".repeat(5_000));
153
+ const linkPath = join(h.configDir, "external-backup");
154
+ symlinkSync(targetHarness.configDir, linkPath);
155
+
156
+ const plan = planArchive(h.configDir, APRIL_19);
157
+ const item = plan.items.find((i) => i.name === "external-backup");
158
+ expect(item).toBeDefined();
159
+ expect(item?.bytes).toBe(0);
160
+ expect(item?.kind).toBe("file");
161
+ expect(plan.totalBytes).toBe(0);
162
+ } finally {
163
+ h.cleanup();
164
+ targetHarness.cleanup();
165
+ }
166
+ });
167
+ });
168
+
169
+ describe("migrate", () => {
170
+ test("no-op on a clean root with exit 0", async () => {
171
+ const h = makeHarness();
172
+ try {
173
+ seedSafelist(h.configDir);
174
+ const logs: string[] = [];
175
+ const code = await migrate({
176
+ configDir: h.configDir,
177
+ now: () => APRIL_19,
178
+ log: (l) => logs.push(l),
179
+ prompt: async () => {
180
+ throw new Error("prompt must not be called");
181
+ },
182
+ });
183
+ expect(code).toBe(0);
184
+ expect(logs.join("\n")).toMatch(/nothing to archive/i);
185
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
186
+ } finally {
187
+ h.cleanup();
188
+ }
189
+ });
190
+
191
+ test("dry-run prints plan, makes no changes, no prompt", async () => {
192
+ const h = makeHarness();
193
+ try {
194
+ seedSafelist(h.configDir);
195
+ touch(join(h.configDir, "daily.db"), "X");
196
+ touch(join(h.configDir, "random"), "Y");
197
+
198
+ const logs: string[] = [];
199
+ const code = await migrate({
200
+ configDir: h.configDir,
201
+ now: () => APRIL_19,
202
+ log: (l) => logs.push(l),
203
+ prompt: async () => {
204
+ throw new Error("prompt must not be called");
205
+ },
206
+ dryRun: true,
207
+ });
208
+ expect(code).toBe(0);
209
+ expect(logs.join("\n")).toMatch(/dry-run/i);
210
+ expect(existsSync(join(h.configDir, "daily.db"))).toBe(true);
211
+ expect(existsSync(join(h.configDir, "random"))).toBe(true);
212
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
213
+ } finally {
214
+ h.cleanup();
215
+ }
216
+ });
217
+
218
+ test("--yes archives without prompting, safelist untouched", async () => {
219
+ const h = makeHarness();
220
+ try {
221
+ seedSafelist(h.configDir);
222
+ touch(join(h.configDir, "daily.db"), "X".repeat(50));
223
+ touch(join(h.configDir, "server.yaml"), "port: 1\n");
224
+ mkdirSync(join(h.configDir, "logs"), { recursive: true });
225
+ touch(join(h.configDir, "logs", "a.log"), "a");
226
+
227
+ const logs: string[] = [];
228
+ const code = await migrate({
229
+ configDir: h.configDir,
230
+ now: () => APRIL_19,
231
+ log: (l) => logs.push(l),
232
+ prompt: async () => {
233
+ throw new Error("prompt must not be called");
234
+ },
235
+ yes: true,
236
+ });
237
+ expect(code).toBe(0);
238
+ const archive = join(h.configDir, ".archive-2026-04-19");
239
+ expect(existsSync(archive)).toBe(true);
240
+ expect(existsSync(join(archive, "daily.db"))).toBe(true);
241
+ expect(existsSync(join(archive, "server.yaml"))).toBe(true);
242
+ expect(existsSync(join(archive, "logs", "a.log"))).toBe(true);
243
+ // safelist still in place
244
+ expect(existsSync(join(h.configDir, "vault", "config.json"))).toBe(true);
245
+ expect(existsSync(join(h.configDir, "services.json"))).toBe(true);
246
+ expect(existsSync(join(h.configDir, "well-known"))).toBe(true);
247
+ expect(existsSync(join(h.configDir, "hub"))).toBe(true);
248
+ // originals gone
249
+ expect(existsSync(join(h.configDir, "daily.db"))).toBe(false);
250
+ expect(existsSync(join(h.configDir, "server.yaml"))).toBe(false);
251
+ expect(existsSync(join(h.configDir, "logs"))).toBe(false);
252
+ } finally {
253
+ h.cleanup();
254
+ }
255
+ });
256
+
257
+ test("--yes archives a symlink by moving the link, not the target", async () => {
258
+ const targetHarness = makeHarness();
259
+ const h = makeHarness();
260
+ try {
261
+ seedSafelist(h.configDir);
262
+ touch(join(targetHarness.configDir, "huge.bin"), "X".repeat(10_000));
263
+ const linkPath = join(h.configDir, "external-backup");
264
+ symlinkSync(targetHarness.configDir, linkPath);
265
+
266
+ const code = await migrate({
267
+ configDir: h.configDir,
268
+ now: () => APRIL_19,
269
+ log: () => {},
270
+ prompt: async () => {
271
+ throw new Error("prompt must not be called");
272
+ },
273
+ yes: true,
274
+ });
275
+ expect(code).toBe(0);
276
+ const archivedLink = join(h.configDir, ".archive-2026-04-19", "external-backup");
277
+ expect(lstatSync(archivedLink).isSymbolicLink()).toBe(true);
278
+ // Target tree untouched
279
+ expect(existsSync(join(targetHarness.configDir, "huge.bin"))).toBe(true);
280
+ // Original link site is empty
281
+ expect(existsSync(linkPath)).toBe(false);
282
+ } finally {
283
+ h.cleanup();
284
+ targetHarness.cleanup();
285
+ }
286
+ });
287
+
288
+ test("prompt 'n' aborts with exit 1, no changes", async () => {
289
+ const h = makeHarness();
290
+ try {
291
+ seedSafelist(h.configDir);
292
+ touch(join(h.configDir, "daily.db"), "X");
293
+ const logs: string[] = [];
294
+ const code = await migrate({
295
+ configDir: h.configDir,
296
+ now: () => APRIL_19,
297
+ log: (l) => logs.push(l),
298
+ prompt: async () => "n",
299
+ });
300
+ expect(code).toBe(1);
301
+ expect(logs.join("\n")).toMatch(/aborted/i);
302
+ expect(existsSync(join(h.configDir, "daily.db"))).toBe(true);
303
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
304
+ } finally {
305
+ h.cleanup();
306
+ }
307
+ });
308
+
309
+ test("prompt 'y' (and 'yes') proceeds", async () => {
310
+ const h = makeHarness();
311
+ try {
312
+ seedSafelist(h.configDir);
313
+ touch(join(h.configDir, "cruft.txt"), "Z");
314
+ const code = await migrate({
315
+ configDir: h.configDir,
316
+ now: () => APRIL_19,
317
+ log: () => {},
318
+ prompt: async () => "y",
319
+ });
320
+ expect(code).toBe(0);
321
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19", "cruft.txt"))).toBe(true);
322
+ } finally {
323
+ h.cleanup();
324
+ }
325
+ });
326
+
327
+ test("re-run same day reuses the same .archive-<date>/", async () => {
328
+ const h = makeHarness();
329
+ try {
330
+ seedSafelist(h.configDir);
331
+ touch(join(h.configDir, "first.txt"), "1");
332
+ await migrate({
333
+ configDir: h.configDir,
334
+ now: () => APRIL_19,
335
+ log: () => {},
336
+ yes: true,
337
+ });
338
+ // Add more cruft and sweep again the same day
339
+ touch(join(h.configDir, "second.txt"), "2");
340
+ await migrate({
341
+ configDir: h.configDir,
342
+ now: () => APRIL_19,
343
+ log: () => {},
344
+ yes: true,
345
+ });
346
+ const archive = join(h.configDir, ".archive-2026-04-19");
347
+ expect(existsSync(join(archive, "first.txt"))).toBe(true);
348
+ expect(existsSync(join(archive, "second.txt"))).toBe(true);
349
+ // Only one archive dir at root
350
+ const archiveDirs = readdirSync(h.configDir).filter((n) => n.startsWith(".archive-"));
351
+ expect(archiveDirs).toEqual([".archive-2026-04-19"]);
352
+ } finally {
353
+ h.cleanup();
354
+ }
355
+ });
356
+
357
+ test("different day creates a second archive dir; prior one is left alone", async () => {
358
+ const h = makeHarness();
359
+ try {
360
+ seedSafelist(h.configDir);
361
+ touch(join(h.configDir, "day1.txt"), "1");
362
+ await migrate({ configDir: h.configDir, now: () => APRIL_19, log: () => {}, yes: true });
363
+ touch(join(h.configDir, "day2.txt"), "2");
364
+ await migrate({ configDir: h.configDir, now: () => APRIL_20, log: () => {}, yes: true });
365
+ expect(existsSync(join(h.configDir, ".archive-2026-04-19", "day1.txt"))).toBe(true);
366
+ expect(existsSync(join(h.configDir, ".archive-2026-04-20", "day2.txt"))).toBe(true);
367
+ } finally {
368
+ h.cleanup();
369
+ }
370
+ });
371
+
372
+ test("conflicting name in today's archive gets a .dup suffix", async () => {
373
+ const h = makeHarness();
374
+ try {
375
+ seedSafelist(h.configDir);
376
+ // Pre-existing archive with a same-name entry.
377
+ mkdirSync(join(h.configDir, ".archive-2026-04-19"), { recursive: true });
378
+ touch(join(h.configDir, ".archive-2026-04-19", "notes.md"), "old");
379
+ // New cruft with the same name.
380
+ touch(join(h.configDir, "notes.md"), "new");
381
+ await migrate({
382
+ configDir: h.configDir,
383
+ now: () => APRIL_19,
384
+ log: () => {},
385
+ yes: true,
386
+ });
387
+ const archive = join(h.configDir, ".archive-2026-04-19");
388
+ const contents = readdirSync(archive);
389
+ expect(contents).toContain("notes.md");
390
+ expect(contents.some((n) => n.startsWith("notes.md.dup-"))).toBe(true);
391
+ } finally {
392
+ h.cleanup();
393
+ }
394
+ });
395
+ });
396
+
397
+ describe("migrateNotice", () => {
398
+ test("undefined when nothing to archive", () => {
399
+ const h = makeHarness();
400
+ try {
401
+ seedSafelist(h.configDir);
402
+ expect(migrateNotice(h.configDir, APRIL_19)).toBeUndefined();
403
+ } finally {
404
+ h.cleanup();
405
+ }
406
+ });
407
+
408
+ test("returns a single line with the count when cruft exists", () => {
409
+ const h = makeHarness();
410
+ try {
411
+ seedSafelist(h.configDir);
412
+ touch(join(h.configDir, "daily.db"), "x");
413
+ touch(join(h.configDir, "stray"), "y");
414
+ const notice = migrateNotice(h.configDir, APRIL_19);
415
+ expect(notice).toBeDefined();
416
+ expect(notice).toMatch(/parachute migrate/);
417
+ expect(notice).toMatch(/2 unrecognized/);
418
+ } finally {
419
+ h.cleanup();
420
+ }
421
+ });
422
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { normalizeMount, notesFetch } from "../notes-serve.ts";
6
+
7
+ interface Harness {
8
+ dir: string;
9
+ cleanup: () => void;
10
+ }
11
+
12
+ function makeHarness(): Harness {
13
+ const dir = mkdtempSync(join(tmpdir(), "pcli-notes-serve-"));
14
+ writeFileSync(join(dir, "index.html"), "<html><body>notes spa</body></html>");
15
+ writeFileSync(join(dir, "sw.js"), "self.addEventListener('install', () => {});");
16
+ writeFileSync(join(dir, "manifest.webmanifest"), '{"name":"Notes","start_url":"/notes/"}');
17
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
18
+ }
19
+
20
+ function req(path: string): Request {
21
+ return new Request(`http://127.0.0.1${path}`);
22
+ }
23
+
24
+ describe("normalizeMount", () => {
25
+ test("strips trailing slashes", () => {
26
+ expect(normalizeMount("/notes/")).toBe("/notes");
27
+ expect(normalizeMount("/notes")).toBe("/notes");
28
+ expect(normalizeMount("/notes///")).toBe("/notes");
29
+ });
30
+
31
+ test("collapses root-equivalents to empty string", () => {
32
+ expect(normalizeMount("")).toBe("");
33
+ expect(normalizeMount("/")).toBe("");
34
+ });
35
+ });
36
+
37
+ describe("notesFetch with default /notes mount", () => {
38
+ test("GET /notes/sw.js serves the SW with JS content-type, not text/html", async () => {
39
+ const h = makeHarness();
40
+ try {
41
+ const res = notesFetch(h.dir, "/notes")(req("/notes/sw.js"));
42
+ expect(res.status).toBe(200);
43
+ const ct = res.headers.get("content-type") ?? "";
44
+ expect(ct).not.toContain("text/html");
45
+ expect(ct).toMatch(/javascript/);
46
+ expect(await res.text()).toContain("addEventListener");
47
+ } finally {
48
+ h.cleanup();
49
+ }
50
+ });
51
+
52
+ test("GET /notes/manifest.webmanifest serves application/manifest+json", async () => {
53
+ const h = makeHarness();
54
+ try {
55
+ const res = notesFetch(h.dir, "/notes")(req("/notes/manifest.webmanifest"));
56
+ expect(res.status).toBe(200);
57
+ expect(res.headers.get("content-type")).toBe("application/manifest+json");
58
+ expect(await res.text()).toContain('"name":"Notes"');
59
+ } finally {
60
+ h.cleanup();
61
+ }
62
+ });
63
+
64
+ test("GET /notes/ serves the SPA shell", async () => {
65
+ const h = makeHarness();
66
+ try {
67
+ const res = notesFetch(h.dir, "/notes")(req("/notes/"));
68
+ expect(res.status).toBe(200);
69
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
70
+ expect(await res.text()).toContain("notes spa");
71
+ } finally {
72
+ h.cleanup();
73
+ }
74
+ });
75
+
76
+ test("GET /notes (no trailing slash) serves the SPA shell", async () => {
77
+ const h = makeHarness();
78
+ try {
79
+ const res = notesFetch(h.dir, "/notes")(req("/notes"));
80
+ expect(res.status).toBe(200);
81
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
82
+ expect(await res.text()).toContain("notes spa");
83
+ } finally {
84
+ h.cleanup();
85
+ }
86
+ });
87
+
88
+ test("GET /notes/nonexistent/deep/route falls back to SPA shell", async () => {
89
+ const h = makeHarness();
90
+ try {
91
+ const res = notesFetch(h.dir, "/notes")(req("/notes/nonexistent/deep/route"));
92
+ expect(res.status).toBe(200);
93
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
94
+ expect(await res.text()).toContain("notes spa");
95
+ } finally {
96
+ h.cleanup();
97
+ }
98
+ });
99
+
100
+ test("GET /notesx/foo (mount-prefix collision) is not stripped", async () => {
101
+ // Guards against startsWith("/notes") matching unrelated /notesx routes.
102
+ const h = makeHarness();
103
+ try {
104
+ const res = notesFetch(h.dir, "/notes")(req("/notesx/foo"));
105
+ expect(res.status).toBe(200);
106
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
107
+ } finally {
108
+ h.cleanup();
109
+ }
110
+ });
111
+ });
112
+
113
+ describe("notesFetch with empty mount (root deployment)", () => {
114
+ test("GET /sw.js serves the SW directly", async () => {
115
+ const h = makeHarness();
116
+ try {
117
+ const res = notesFetch(h.dir, "")(req("/sw.js"));
118
+ expect(res.status).toBe(200);
119
+ expect(res.headers.get("content-type") ?? "").toMatch(/javascript/);
120
+ } finally {
121
+ h.cleanup();
122
+ }
123
+ });
124
+
125
+ test("GET / serves the SPA shell", async () => {
126
+ const h = makeHarness();
127
+ try {
128
+ const res = notesFetch(h.dir, "")(req("/"));
129
+ expect(res.status).toBe(200);
130
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
131
+ } finally {
132
+ h.cleanup();
133
+ }
134
+ });
135
+ });