pi-lean-dimension 0.5.0 → 0.6.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 (104) hide show
  1. package/README.md +5 -3
  2. package/node_modules/pi-lean-host/AGENTS.md +13 -6
  3. package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
  4. package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
  5. package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
  6. package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
  7. package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
  8. package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
  9. package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
  10. package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
  11. package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
  12. package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
  13. package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
  14. package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
  15. package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
  16. package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
  17. package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
  18. package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
  19. package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
  20. package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
  21. package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
  22. package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
  23. package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
  24. package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
  25. package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
  26. package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
  27. package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
  28. package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
  29. package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
  30. package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
  31. package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
  32. package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
  33. package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
  34. package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
  35. package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
  36. package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
  37. package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
  38. package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
  39. package/node_modules/pi-lean-host/core/auth.ts +2 -1
  40. package/node_modules/pi-lean-host/core/helpers.ts +29 -35
  41. package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
  42. package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
  43. package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
  44. package/node_modules/pi-lean-host/core/transport.ts +1 -1
  45. package/node_modules/pi-lean-host/package.json +1 -1
  46. package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
  47. package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
  48. package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
  49. package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
  50. package/node_modules/pi-lean-host/tools/utils.ts +69 -0
  51. package/node_modules/pi-lean-portal/AGENTS.md +4 -3
  52. package/node_modules/pi-lean-portal/README.md +9 -9
  53. package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
  54. package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
  55. package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
  56. package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
  57. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
  58. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
  59. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
  60. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  62. package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
  63. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
  64. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
  65. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
  66. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
  67. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
  68. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
  69. package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
  70. package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
  71. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  72. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
  77. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
  84. package/node_modules/pi-lean-portal/browser-install.ts +454 -0
  85. package/node_modules/pi-lean-portal/browser-status.ts +21 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
  87. package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
  88. package/node_modules/pi-lean-portal/core/router.ts +7 -32
  89. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
  90. package/node_modules/pi-lean-portal/index.ts +1 -18
  91. package/node_modules/pi-lean-portal/package.json +2 -1
  92. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
  93. package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
  94. package/node_modules/pi-lean-search/AGENTS.md +5 -3
  95. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
  96. package/node_modules/pi-lean-search/index.ts +0 -3
  97. package/node_modules/pi-lean-search/package.json +1 -1
  98. package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
  99. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  100. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  101. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  102. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  103. package/node_modules/yaml/package.json +1 -1
  104. package/package.json +4 -4
@@ -16,7 +16,7 @@
16
16
  * regression guard for that exact boundary.
17
17
  */
18
18
 
19
- import { describe, it, expect } from "vitest";
19
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
20
20
  import {
21
21
  waitForRetry,
22
22
  redactSecretParams,
@@ -150,6 +150,32 @@ describe("fetchUrl content-encoding handling", () => {
150
150
  });
151
151
  });
152
152
 
153
+ // ═══════════════════════════════════════════════════════════════
154
+ // Body-size ceiling — a response body past MAX_BODY_BYTES aborts the
155
+ // collect loop with BodyTooLargeError, which fetchUrl's retry logic
156
+ // classifies as non-transient (exactly one request, no retry).
157
+ // ═══════════════════════════════════════════════════════════════
158
+
159
+ describe("fetchUrl body-size ceiling", () => {
160
+ it("throws BodyTooLargeError on an oversized body without retrying", async () => {
161
+ let requests = 0;
162
+ const server = createServer((_req, res) => {
163
+ requests++;
164
+ res.writeHead(200, { "content-type": "application/json" });
165
+ res.end(Buffer.alloc(10 * 1024 * 1024 + 1));
166
+ });
167
+ const port = await listenAsync(server);
168
+ try {
169
+ await expect(
170
+ fetchUrl(`http://127.0.0.1:${port}/huge`, { fresh: true }),
171
+ ).rejects.toMatchObject({ name: "BodyTooLargeError" });
172
+ expect(requests).toBe(1);
173
+ } finally {
174
+ server.close();
175
+ }
176
+ });
177
+ });
178
+
153
179
  // ═══════════════════════════════════════════════════════════════════
154
180
  // Grant-based caching — a body is served from cache only when the
155
181
  // server granted freshness: a time grant (Cache-Control: max-age) or a
@@ -207,6 +233,15 @@ async function startCacheServer(
207
233
  };
208
234
  }
209
235
 
236
+ /** Cache-suite teardown: restore the real clock and close the server in
237
+ * one step, so a test can't reset one and forget the other. */
238
+ function stopCacheServer(
239
+ srv: Awaited<ReturnType<typeof startCacheServer>>,
240
+ ): Promise<void> {
241
+ _setClockForTest(() => Date.now());
242
+ return srv.close();
243
+ }
244
+
210
245
  /** Fixed test epoch so all clock math is deterministic. */
211
246
  const T0 = 1_700_000_000_000;
212
247
 
@@ -236,8 +271,7 @@ describe("grant-based caching", () => {
236
271
  expect(r3.cached).toBe(false);
237
272
  expect(srv.requestCount()).toBe(2);
238
273
  } finally {
239
- _setClockForTest(() => Date.now());
240
- await srv.close();
274
+ await stopCacheServer(srv);
241
275
  }
242
276
  });
243
277
 
@@ -271,8 +305,7 @@ describe("grant-based caching", () => {
271
305
  expect(JSON.parse(r3.body)).toEqual({ n: 3, changed: true });
272
306
  expect(srv.ifNoneMatches[2]).toBe('"v1"');
273
307
  } finally {
274
- _setClockForTest(() => Date.now());
275
- await srv.close();
308
+ await stopCacheServer(srv);
276
309
  }
277
310
  });
278
311
 
@@ -292,8 +325,7 @@ describe("grant-based caching", () => {
292
325
  expect(r3.cached).toBe(false);
293
326
  expect(srv.requestCount()).toBe(3);
294
327
  } finally {
295
- _setClockForTest(() => Date.now());
296
- await srv.close();
328
+ await stopCacheServer(srv);
297
329
  }
298
330
  });
299
331
 
@@ -314,8 +346,7 @@ describe("grant-based caching", () => {
314
346
  expect(srv.ifNoneMatches[1]).toBeUndefined();
315
347
  expect(srv.requestCount()).toBe(2);
316
348
  } finally {
317
- _setClockForTest(() => Date.now());
318
- await srv.close();
349
+ await stopCacheServer(srv);
319
350
  }
320
351
  });
321
352
 
@@ -344,8 +375,7 @@ describe("grant-based caching", () => {
344
375
  expect(r3.cached).toBe(true);
345
376
  expect(srv.requestCount()).toBe(3);
346
377
  } finally {
347
- _setClockForTest(() => Date.now());
348
- await srv.close();
378
+ await stopCacheServer(srv);
349
379
  }
350
380
  });
351
381
 
@@ -370,8 +400,7 @@ describe("grant-based caching", () => {
370
400
  expect(srv.requestCount()).toBe(2);
371
401
  expect(srv.ifNoneMatches[1]).toBeUndefined();
372
402
  } finally {
373
- _setClockForTest(() => Date.now());
374
- await srv.close();
403
+ await stopCacheServer(srv);
375
404
  }
376
405
  },
377
406
  );
@@ -395,8 +424,7 @@ describe("grant-based caching", () => {
395
424
  expect(srv.requestCount()).toBe(2);
396
425
  expect(srv.ifNoneMatches[1]).toBe('"ncx"');
397
426
  } finally {
398
- _setClockForTest(() => Date.now());
399
- await srv.close();
427
+ await stopCacheServer(srv);
400
428
  }
401
429
  });
402
430
 
@@ -424,8 +452,7 @@ describe("grant-based caching", () => {
424
452
  expect(r3.cached).toBe(true);
425
453
  expect(srv.requestCount()).toBe(2);
426
454
  } finally {
427
- _setClockForTest(() => Date.now());
428
- await srv.close();
455
+ await stopCacheServer(srv);
429
456
  }
430
457
  });
431
458
 
@@ -455,8 +482,7 @@ describe("grant-based caching", () => {
455
482
  expect(r3.cached).toBe(true);
456
483
  expect(srv.requestCount()).toBe(2);
457
484
  } finally {
458
- _setClockForTest(() => Date.now());
459
- await srv.close();
485
+ await stopCacheServer(srv);
460
486
  }
461
487
  });
462
488
 
@@ -502,8 +528,7 @@ describe("grant-based caching", () => {
502
528
  expect(srv.requestCount()).toBe(5); // conditional GET, not a header hit
503
529
  expect(srv.ifNoneMatches[4]).toBe('"nc304"');
504
530
  } finally {
505
- _setClockForTest(() => Date.now());
506
- await srv.close();
531
+ await stopCacheServer(srv);
507
532
  }
508
533
  });
509
534
 
@@ -530,8 +555,7 @@ describe("grant-based caching", () => {
530
555
  expect(srv.requestCount()).toBe(3);
531
556
  expect(srv.ifNoneMatches[2]).toBeUndefined();
532
557
  } finally {
533
- _setClockForTest(() => Date.now());
534
- await srv.close();
558
+ await stopCacheServer(srv);
535
559
  }
536
560
  });
537
561
 
@@ -583,8 +607,7 @@ describe("grant-based caching", () => {
583
607
  expect(targetInms[1]).toBe('"race"'); // second: conditional GET
584
608
  expect(srv.requestCount()).toBe(103);
585
609
  } finally {
586
- _setClockForTest(() => Date.now());
587
- await srv.close();
610
+ await stopCacheServer(srv);
588
611
  }
589
612
  });
590
613
 
@@ -612,8 +635,7 @@ describe("grant-based caching", () => {
612
635
  expect(r3.cached).toBe(true);
613
636
  expect(srv.requestCount()).toBe(2);
614
637
  } finally {
615
- _setClockForTest(() => Date.now());
616
- await srv.close();
638
+ await stopCacheServer(srv);
617
639
  }
618
640
  });
619
641
 
@@ -636,8 +658,7 @@ describe("grant-based caching", () => {
636
658
  expect(r3.cached).toBe(false);
637
659
  expect(srv.requestCount()).toBe(3);
638
660
  } finally {
639
- _setClockForTest(() => Date.now());
640
- await srv.close();
661
+ await stopCacheServer(srv);
641
662
  }
642
663
  });
643
664
 
@@ -667,8 +688,134 @@ describe("grant-based caching", () => {
667
688
  expect(r2.body).not.toContain("public");
668
689
  expect(srv.requestCount()).toBe(2);
669
690
  } finally {
670
- _setClockForTest(() => Date.now());
671
- await srv.close();
691
+ await stopCacheServer(srv);
692
+ }
693
+ });
694
+ });
695
+
696
+ // ═══════════════════════════════════════════════════════════════
697
+ // fetchUrl — fallbackCharset + guardRedirects (moved from helpers.test.ts)
698
+ // ═══════════════════════════════════════════════════════════════
699
+
700
+ interface CharsetTestCtx {
701
+ serverUrl: string;
702
+ stop: () => Promise<void>;
703
+ requestCounts: Map<string, number>;
704
+ }
705
+
706
+ /** Serves the latin-1 / charset-declaration / redirect-metadata routes. */
707
+ async function startCharsetServer(): Promise<CharsetTestCtx> {
708
+ const requestCounts = new Map<string, number>();
709
+ const server = createServer((req, res) => {
710
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
711
+ requestCounts.set(pathname, (requestCounts.get(pathname) ?? 0) + 1);
712
+
713
+ if (pathname === "/api/latin1-no-charset") {
714
+ // ISO-8859-1 bytes for áéíóú, served with NO charset parameter —
715
+ // the transport must fall back to the caller's fallbackCharset.
716
+ res.writeHead(200, { "Content-Type": "application/json" });
717
+ res.end(Buffer.from([0xe1, 0xe9, 0xed, 0xf3, 0xfa]));
718
+ return;
719
+ }
720
+
721
+ if (pathname === "/api/utf8-with-charset") {
722
+ // Real UTF-8 bytes for áéíóú, served WITH charset=utf-8 — the
723
+ // header charset must win even if a fallbackCharset is supplied.
724
+ res.writeHead(200, {
725
+ "Content-Type": "application/json; charset=utf-8",
726
+ });
727
+ res.end(Buffer.from("áéíóú", "utf-8"));
728
+ return;
729
+ }
730
+
731
+ // 302 redirect to the cloud metadata endpoint (guardRedirects). The
732
+ // initial URL is on 127.0.0.1 — fetchUrl does NOT ssrf-check the
733
+ // initial URL (paginate owns that), only redirect targets.
734
+ if (pathname === "/redirect-to-metadata") {
735
+ res.writeHead(302, {
736
+ Location: "http://169.254.169.254/latest/meta-data/",
737
+ });
738
+ res.end();
739
+ return;
672
740
  }
741
+
742
+ res.writeHead(404);
743
+ res.end();
744
+ });
745
+ const port = await listenAsync(server);
746
+ return {
747
+ serverUrl: `http://127.0.0.1:${port}`,
748
+ requestCounts,
749
+ stop: () =>
750
+ new Promise<void>((resolve, reject) =>
751
+ server.close((err) => (err ? reject(err) : resolve())),
752
+ ),
753
+ };
754
+ }
755
+
756
+ describe("fetchUrl — fallbackCharset", () => {
757
+ let ctx: CharsetTestCtx;
758
+
759
+ beforeAll(async () => {
760
+ ctx = await startCharsetServer();
761
+ });
762
+ afterAll(async () => {
763
+ await ctx.stop();
764
+ });
765
+
766
+ it("falls back to fallbackCharset when the response omits a charset", async () => {
767
+ // Server serves ISO-8859-1 bytes with no charset parameter.
768
+ const { body } = await fetchUrl(`${ctx.serverUrl}/api/latin1-no-charset`, {
769
+ fallbackCharset: "iso-8859-1",
770
+ fresh: true,
771
+ });
772
+ expect(body).toBe("áéíóú");
773
+ });
774
+
775
+ it("uses utf-8 by default when no fallbackCharset is supplied", async () => {
776
+ const { body } = await fetchUrl(`${ctx.serverUrl}/api/latin1-no-charset`, {
777
+ fresh: true,
778
+ });
779
+ expect(body).not.toBe("áéíóú");
780
+ expect(body).toBe("�����");
781
+ });
782
+
783
+ it("header charset wins over fallbackCharset", async () => {
784
+ // Server declares charset=utf-8; supplying a latin-1 fallback must
785
+ // NOT override it — the header charset always wins.
786
+ const { body } = await fetchUrl(`${ctx.serverUrl}/api/utf8-with-charset`, {
787
+ fallbackCharset: "iso-8859-1",
788
+ fresh: true,
789
+ });
790
+ expect(body).toBe("áéíóú");
791
+ });
792
+ });
793
+
794
+ describe("fetchUrl — guardRedirects (M3)", () => {
795
+ let ctx: CharsetTestCtx;
796
+
797
+ beforeAll(async () => {
798
+ ctx = await startCharsetServer();
799
+ });
800
+
801
+ afterAll(async () => {
802
+ await ctx.stop();
803
+ });
804
+
805
+ it("blocks a 302 redirect to the cloud metadata endpoint", async () => {
806
+ // fetchUrl does NOT ssrf-check the initial URL (paginate owns that),
807
+ // so hitting the 127.0.0.1 test server is fine. The redirect target
808
+ // (169.254.169.254) must be blocked before it is fetched.
809
+ const before = ctx.requestCounts.get("/redirect-to-metadata") ?? 0;
810
+ await expect(
811
+ fetchUrl(`${ctx.serverUrl}/redirect-to-metadata`, {
812
+ guardRedirects: true,
813
+ }),
814
+ ).rejects.toThrow(/Redirect to blocked host/i);
815
+
816
+ // The redirect endpoint was hit exactly once (the SSRF block is not
817
+ // transient, so fetchUrl must not retry).
818
+ const after = ctx.requestCounts.get("/redirect-to-metadata") ?? 0;
819
+ expect(after - before).toBe(1);
673
820
  });
674
821
  });
@@ -43,6 +43,7 @@ import { setOAuthDir, writeToken } from "../core/oauth-store.js";
43
43
  import { handleVerifySubcommand } from "../core/verify-command.js";
44
44
  import { TODAY } from "../core/parse-api-guide.js";
45
45
  import { resetDisabledHelpers } from "../core/local-helpers.js";
46
+ import { mockCtx, notifyText } from "./test-utils.js";
46
47
 
47
48
  // ═══════════════════════════════════════════════════════════════════
48
49
  // Fixtures
@@ -234,14 +235,6 @@ function readGuideIn(dirName: string): string {
234
235
  return readFileSync(join(tmpGuidesDir, dirName, "guide.md"), "utf-8");
235
236
  }
236
237
 
237
- function mockCtx(overrides: Record<string, unknown> = {}): any {
238
- return { ui: { notify: vi.fn() }, hasUI: true, ...overrides };
239
- }
240
-
241
- function notifyText(ctx: any): string {
242
- return ctx.ui.notify.mock.calls.map((c: unknown[]) => c[0]).join("\n");
243
- }
244
-
245
238
  function requestedUrls(): string[] {
246
239
  return vi.mocked(fetchUrl).mock.calls.map((c) => String(c[0]));
247
240
  }
@@ -28,6 +28,7 @@ import { fetchUrl } from "../core/transport.js";
28
28
  import { setUserGuidesDir, invalidateCache } from "../core/guide-store.js";
29
29
  import { handleVerifySubcommand } from "../core/verify-command.js";
30
30
  import { TODAY } from "../core/parse-api-guide.js";
31
+ import { mockCtx } from "./test-utils.js";
31
32
 
32
33
  let tmpGuidesDir: string;
33
34
 
@@ -81,10 +82,6 @@ function readGuide(): string {
81
82
  return readFileSync(join(tmpGuidesDir, "verify", "guide.md"), "utf-8");
82
83
  }
83
84
 
84
- function mockCtx(): any {
85
- return { ui: { notify: vi.fn() }, hasUI: true };
86
- }
87
-
88
85
  describe("/api verify stamp routine", () => {
89
86
  it("--force stamps verified: today without any HTTP", async () => {
90
87
  setupGuide(recipe("verified: 2026-07-17"));
@@ -8,13 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
11
- import {
12
- mkdtempSync,
13
- mkdirSync,
14
- writeFileSync,
15
- rmSync,
16
- readFileSync,
17
- } from "node:fs";
11
+ import { mkdtempSync, rmSync } from "node:fs";
18
12
  import { tmpdir } from "node:os";
19
13
  import { join } from "node:path";
20
14
  import type { ApiGuide } from "../../core/api-guide-types.js";
@@ -29,25 +23,20 @@ vi.mock("../../core/transport.js", async () => ({
29
23
 
30
24
  import { restGet } from "../../core/helpers.js";
31
25
  import { callHelper } from "../../core/local-helpers.js";
32
- import { loadApiGuidesFromDir } from "../../core/guide-catalog.js";
33
- import { setUserGuidesDir, invalidateCache } from "../../core/guide-store.js";
26
+ import { stageGuides } from "../../__tests__/test-utils.js";
34
27
 
35
28
  const XML_BODY = `<diarios><diario id="1"><titulo>Test diary</titulo></diario></diarios>`;
36
29
 
37
30
  let tmpBase: string;
38
31
 
39
32
  async function setupRecipe(): Promise<{ guide: ApiGuide }> {
40
- const guidesDir = mkdtempSync(join(tmpBase, "guides-"));
41
- const domainDir = join(guidesDir, "boe");
42
- mkdirSync(domainDir, { recursive: true });
43
- for (const file of ["guide.md", "helper.ts"] as const) {
44
- const source = readFileSync(new URL(`./${file}`, import.meta.url), "utf-8");
45
- writeFileSync(join(domainDir, file), source, "utf-8");
46
- }
47
- setUserGuidesDir(guidesDir);
48
- invalidateCache();
49
- const loaded = loadApiGuidesFromDir(guidesDir);
50
- return { guide: loaded.guides["boe"]! };
33
+ const { guides } = stageGuides(
34
+ tmpBase,
35
+ new URL("../", import.meta.url),
36
+ ["boe"],
37
+ ["guide.md", "helper.ts"],
38
+ );
39
+ return { guide: guides["boe"]! };
51
40
  }
52
41
 
53
42
  beforeAll(() => {
@@ -13,13 +13,7 @@
13
13
  */
14
14
 
15
15
  import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
16
- import {
17
- mkdtempSync,
18
- mkdirSync,
19
- writeFileSync,
20
- rmSync,
21
- readFileSync,
22
- } from "node:fs";
16
+ import { mkdtempSync, rmSync } from "node:fs";
23
17
  import { tmpdir } from "node:os";
24
18
  import { join } from "node:path";
25
19
  import { HelperError } from "../../core/helpers.js";
@@ -34,8 +28,7 @@ vi.mock("../../core/transport.js", async () => ({
34
28
  }));
35
29
 
36
30
  import { paginate } from "../../core/helpers.js";
37
- import { loadApiGuidesFromDir } from "../../core/guide-catalog.js";
38
- import { setUserGuidesDir, invalidateCache } from "../../core/guide-store.js";
31
+ import { stageGuides } from "../../__tests__/test-utils.js";
39
32
 
40
33
  const XML_HEADERS = { "content-type": "text/xml;charset=UTF-8" };
41
34
  const JSON_HEADERS = { "content-type": "application/json" };
@@ -72,15 +65,10 @@ async function mock200(
72
65
  }
73
66
 
74
67
  async function loadDnbGuide(): Promise<ApiGuide> {
75
- const guidesDir = mkdtempSync(join(tmpBase, "guides-"));
76
- const domainDir = join(guidesDir, "dnb");
77
- mkdirSync(domainDir, { recursive: true });
78
- const source = readFileSync(new URL("./guide.md", import.meta.url), "utf-8");
79
- writeFileSync(join(domainDir, "guide.md"), source, "utf-8");
80
- setUserGuidesDir(guidesDir);
81
- invalidateCache();
82
- const loaded = loadApiGuidesFromDir(guidesDir);
83
- return loaded.guides["dnb"]!;
68
+ const { guides } = stageGuides(tmpBase, new URL("../", import.meta.url), [
69
+ "dnb",
70
+ ]);
71
+ return guides["dnb"]!;
84
72
  }
85
73
 
86
74
  // Inline-guide builder for the World Bank mirror.
@@ -8,13 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
11
- import {
12
- mkdtempSync,
13
- mkdirSync,
14
- writeFileSync,
15
- rmSync,
16
- readFileSync,
17
- } from "node:fs";
11
+ import { mkdtempSync, rmSync } from "node:fs";
18
12
  import { tmpdir } from "node:os";
19
13
  import { join } from "node:path";
20
14
  import type { ApiGuide } from "../../core/api-guide-types.js";
@@ -28,8 +22,7 @@ vi.mock("../../core/transport.js", async () => ({
28
22
  }));
29
23
 
30
24
  import { paginate } from "../../core/helpers.js";
31
- import { loadApiGuidesFromDir } from "../../core/guide-catalog.js";
32
- import { setUserGuidesDir, invalidateCache } from "../../core/guide-store.js";
25
+ import { stageGuides } from "../../__tests__/test-utils.js";
33
26
 
34
27
  const PAGE1_XML = `<OAI-PMH><ListRecords><record><metadata><title>One</title></metadata></record><record><metadata><title>Two</title></metadata></record><resumptionToken completeListSize="3">token-abc</resumptionToken></ListRecords></OAI-PMH>`;
35
28
 
@@ -39,15 +32,10 @@ const PAGE2_XML = `<OAI-PMH><ListRecords><record><metadata><title>Three</title><
39
32
  let tmpBase: string;
40
33
 
41
34
  async function setupRecipe(): Promise<{ guide: ApiGuide }> {
42
- const guidesDir = mkdtempSync(join(tmpBase, "guides-"));
43
- const domainDir = join(guidesDir, "dnb");
44
- mkdirSync(domainDir, { recursive: true });
45
- const source = readFileSync(new URL("./guide.md", import.meta.url), "utf-8");
46
- writeFileSync(join(domainDir, "guide.md"), source, "utf-8");
47
- setUserGuidesDir(guidesDir);
48
- invalidateCache();
49
- const loaded = loadApiGuidesFromDir(guidesDir);
50
- return { guide: loaded.guides["dnb"]! };
35
+ const { guides } = stageGuides(tmpBase, new URL("../", import.meta.url), [
36
+ "dnb",
37
+ ]);
38
+ return { guide: guides["dnb"]! };
51
39
  }
52
40
 
53
41
  beforeAll(() => {
@@ -20,13 +20,7 @@ import {
20
20
  afterAll,
21
21
  beforeEach,
22
22
  } from "vitest";
23
- import {
24
- mkdtempSync,
25
- mkdirSync,
26
- writeFileSync,
27
- rmSync,
28
- readFileSync,
29
- } from "node:fs";
23
+ import { mkdtempSync, rmSync } from "node:fs";
30
24
  import { tmpdir } from "node:os";
31
25
  import { join } from "node:path";
32
26
  import type { ApiGuide } from "../../core/api-guide-types.js";
@@ -40,8 +34,7 @@ vi.mock("../../core/transport.js", async () => ({
40
34
  }));
41
35
 
42
36
  import { paginate } from "../../core/helpers.js";
43
- import { loadApiGuidesFromDir } from "../../core/guide-catalog.js";
44
- import { setUserGuidesDir, invalidateCache } from "../../core/guide-store.js";
37
+ import { stageGuides } from "../../__tests__/test-utils.js";
45
38
  import { fetchUrl } from "../../core/transport.js";
46
39
 
47
40
  // Drain any under-consumed Once-queue so a leaky test can't shift its
@@ -124,15 +117,10 @@ const PAGE2 = JSON.stringify({
124
117
  let tmpBase: string;
125
118
 
126
119
  async function setupRecipe(): Promise<{ guide: ApiGuide }> {
127
- const guidesDir = mkdtempSync(join(tmpBase, "guides-"));
128
- const domainDir = join(guidesDir, "frost-sensorthings");
129
- mkdirSync(domainDir, { recursive: true });
130
- const source = readFileSync(new URL("./guide.md", import.meta.url), "utf-8");
131
- writeFileSync(join(domainDir, "guide.md"), source, "utf-8");
132
- setUserGuidesDir(guidesDir);
133
- invalidateCache();
134
- const loaded = loadApiGuidesFromDir(guidesDir);
135
- return { guide: loaded.guides["frost-sensorthings"]! };
120
+ const { guides } = stageGuides(tmpBase, new URL("../", import.meta.url), [
121
+ "frost-sensorthings",
122
+ ]);
123
+ return { guide: guides["frost-sensorthings"]! };
136
124
  }
137
125
 
138
126
  beforeAll(() => {
@@ -15,16 +15,10 @@
15
15
  */
16
16
 
17
17
  import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
18
- import {
19
- mkdtempSync,
20
- mkdirSync,
21
- writeFileSync,
22
- rmSync,
23
- readFileSync,
24
- } from "node:fs";
18
+ import { mkdtempSync, rmSync } from "node:fs";
25
19
  import { tmpdir } from "node:os";
26
20
  import { join } from "node:path";
27
- import type { ApiGuide, Operation } from "../../core/api-guide-types.js";
21
+ import type { ApiGuide } from "../../core/api-guide-types.js";
28
22
 
29
23
  // Mock the transport layer BEFORE any imports that use it.
30
24
  vi.mock("../../core/transport.js", async () => ({
@@ -35,27 +29,15 @@ vi.mock("../../core/transport.js", async () => ({
35
29
  }));
36
30
 
37
31
  import { restGet, paginate } from "../../core/helpers.js";
38
- import { loadApiGuidesFromDir } from "../../core/guide-catalog.js";
39
- import { setUserGuidesDir, invalidateCache } from "../../core/guide-store.js";
32
+ import { stageGuides, findOp } from "../../__tests__/test-utils.js";
40
33
 
41
34
  let tmpBase: string;
42
35
 
43
36
  async function setupRecipe(): Promise<{ guide: ApiGuide }> {
44
- const guidesDir = mkdtempSync(join(tmpBase, "guides-"));
45
- const domainDir = join(guidesDir, "github");
46
- mkdirSync(domainDir, { recursive: true });
47
- const source = readFileSync(new URL("./guide.md", import.meta.url), "utf-8");
48
- writeFileSync(join(domainDir, "guide.md"), source, "utf-8");
49
- setUserGuidesDir(guidesDir);
50
- invalidateCache();
51
- const loaded = loadApiGuidesFromDir(guidesDir);
52
- return { guide: loaded.guides["github"]! };
53
- }
54
-
55
- function findOp(guide: ApiGuide, name: string): Operation {
56
- const op = guide.operations.find((o) => o.name === name);
57
- if (!op) throw new Error(`op ${name} not found`);
58
- return op;
37
+ const { guides } = stageGuides(tmpBase, new URL("../", import.meta.url), [
38
+ "github",
39
+ ]);
40
+ return { guide: guides["github"]! };
59
41
  }
60
42
 
61
43
  beforeAll(() => {
@@ -20,13 +20,7 @@ import {
20
20
  afterAll,
21
21
  beforeEach,
22
22
  } from "vitest";
23
- import {
24
- mkdtempSync,
25
- mkdirSync,
26
- writeFileSync,
27
- rmSync,
28
- readFileSync,
29
- } from "node:fs";
23
+ import { mkdtempSync, rmSync } from "node:fs";
30
24
  import { tmpdir } from "node:os";
31
25
  import { join } from "node:path";
32
26
  import type { ApiGuide } from "../../core/api-guide-types.js";
@@ -41,8 +35,7 @@ vi.mock("../../core/transport.js", async () => ({
41
35
 
42
36
  import { fetchUrl } from "../../core/transport.js";
43
37
  import { paginate } from "../../core/helpers.js";
44
- import { loadApiGuidesFromDir } from "../../core/guide-catalog.js";
45
- import { setUserGuidesDir, invalidateCache } from "../../core/guide-store.js";
38
+ import { stageGuides } from "../../__tests__/test-utils.js";
46
39
 
47
40
  // Drain any under-consumed Once-queue so a leaky test can't shift its
48
41
  // leftover pages into the next test (mockReset also clears calls).
@@ -100,15 +93,10 @@ const PAGE3 = JSON.stringify({
100
93
  let tmpBase: string;
101
94
 
102
95
  async function setupRecipe(): Promise<{ guide: ApiGuide }> {
103
- const guidesDir = mkdtempSync(join(tmpBase, "guides-"));
104
- const domainDir = join(guidesDir, "inaturalist");
105
- mkdirSync(domainDir, { recursive: true });
106
- const source = readFileSync(new URL("./guide.md", import.meta.url), "utf-8");
107
- writeFileSync(join(domainDir, "guide.md"), source, "utf-8");
108
- setUserGuidesDir(guidesDir);
109
- invalidateCache();
110
- const loaded = loadApiGuidesFromDir(guidesDir);
111
- return { guide: loaded.guides["inaturalist"]! };
96
+ const { guides } = stageGuides(tmpBase, new URL("../", import.meta.url), [
97
+ "inaturalist",
98
+ ]);
99
+ return { guide: guides["inaturalist"]! };
112
100
  }
113
101
 
114
102
  beforeAll(() => {