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
@@ -4,24 +4,14 @@
4
4
  * Covers:
5
5
  * - BOE worked example parses to a valid ApiGuide with defaults filled.
6
6
  * - Each malformed fixture returns a ParseError with dotted field path.
7
-
8
- * - projectToGuide() strips recipe fields, retains kind: "api".
9
- * - Catalog rendering lists healthy + ⚠ malformed together.
7
+ *
8
+ * (projectToGuide/slug/loader/catalog/stampFrontmatterField tests live in
9
+ * guide-catalog.test.ts.)
10
10
  */
11
11
 
12
- import { describe, it, expect, vi } from "vitest";
13
- import {
14
- mkdtempSync,
15
- mkdirSync,
16
- writeFileSync,
17
- rmSync,
18
- renameSync,
19
- } from "node:fs";
20
- import { tmpdir } from "node:os";
21
- import { join } from "node:path";
12
+ import { describe, it, expect } from "vitest";
22
13
  import {
23
14
  parseApiGuide,
24
- stampFrontmatterField,
25
15
  PAGINATION_ALLOWLISTS,
26
16
  AUTH_ALLOWLISTS,
27
17
  PARAM_SPEC_KEYS,
@@ -29,12 +19,6 @@ import {
29
19
  OP_ALLOWLIST,
30
20
  RESPONSE_SHAPE_ALLOWLIST,
31
21
  } from "../core/parse-api-guide.js";
32
- import {
33
- projectToGuide,
34
- loadApiGuidesFromDir,
35
- formatApiGuideCatalog,
36
- } from "../core/guide-catalog.js";
37
- import { slug } from "../core/path-template.js";
38
22
  import { parse as yamlParse } from "yaml";
39
23
  import {
40
24
  GATHER_ALL_MAX_FALLBACK,
@@ -2066,10 +2050,6 @@ body
2066
2050
  });
2067
2051
  });
2068
2052
 
2069
- // ═══════════════════════════════════════════════════════════════════
2070
- // projectToGuide — strips recipe, retains presentation + kind
2071
- // ═══════════════════════════════════════════════════════════════════
2072
-
2073
2053
  // ═════════════════════════════════════════════════════════════════
2074
2054
  // errorPath — present-only-on-error envelope path
2075
2055
  // ═════════════════════════════════════════════════════════════════
@@ -2175,391 +2155,6 @@ body
2175
2155
  });
2176
2156
  });
2177
2157
 
2178
- describe("projectToGuide", () => {
2179
- it("strips recipe fields and retains kind: api", () => {
2180
- const guide = expectOk(BOE_RECIPE, { filename: "boe.es" });
2181
- const proj = projectToGuide(guide);
2182
-
2183
- const keys = Object.keys(proj);
2184
- const RECIPE_KEYS = [
2185
- "apiHost",
2186
- "operations",
2187
- "pagination",
2188
- "auth",
2189
- "helper",
2190
- "verified",
2191
- "gatherAllMax",
2192
- "responseShape",
2193
- ];
2194
- for (const k of RECIPE_KEYS) {
2195
- expect(keys).not.toContain(k);
2196
- }
2197
-
2198
- expect(proj.kind).toBe("api");
2199
- expect(proj.domains).toEqual(["boe.es", "www.boe.es"]);
2200
- expect(proj.icon).toBe("⚖️");
2201
- expect(proj.shortName).toBe("BOE");
2202
- expect(proj.updated).toBe("2026-07-17");
2203
- expect(proj.content).toContain("BOE Legislación Consolidada");
2204
- expect(proj.category).toBe("site");
2205
- });
2206
-
2207
- it("projection carries no helper reference", () => {
2208
- const guide = expectOk(BOE_RECIPE, { filename: "boe.es" });
2209
- const proj = projectToGuide(guide);
2210
- // Helper is a recipe (op-level) field; the Guide projection has no such key.
2211
- expect("helper" in proj).toBe(false);
2212
- expect("operations" in proj).toBe(false);
2213
- });
2214
- });
2215
-
2216
- // ═══════════════════════════════════════════════════════════════════
2217
- // slug() — the shortName → identity-folder sanitizer
2218
- // ═══════════════════════════════════════════════════════════════════
2219
-
2220
- describe("slug()", () => {
2221
- it("lowercases and replaces non-[a-z0-9-] runs with a single '-'", () => {
2222
- expect(slug("BOE")).toBe("boe");
2223
- expect(slug("My Provider Pro!")).toBe("my-provider-pro");
2224
- expect(slug("Example Dev Inc")).toBe("example-dev-inc");
2225
- expect(slug("a/b")).toBe("a-b");
2226
- });
2227
-
2228
- it("transliterates Latin diacritics instead of dropping them", () => {
2229
- expect(slug("Café")).toBe("cafe");
2230
- expect(slug("Überwald")).toBe("uberwald");
2231
- expect(slug("Bjørk")).toBe("bjork");
2232
- expect(slug("Münchhausen")).toBe("munchhausen");
2233
- });
2234
-
2235
- it("collapses repeated '-' and strips leading/trailing '-'", () => {
2236
- expect(slug("a--b")).toBe("a-b");
2237
- expect(slug("-foo-")).toBe("foo");
2238
- });
2239
-
2240
- it("slug-collision pair: api_dev_full and api-dev-full both slug to api-dev-full", () => {
2241
- expect(slug("api_dev_full")).toBe("api-dev-full");
2242
- expect(slug("api-dev-full")).toBe("api-dev-full");
2243
- });
2244
-
2245
- it("throws on empty or all-symbol shortName (slug flattens to empty)", () => {
2246
- expect(() => slug("")).toThrow(/shortName/);
2247
- expect(() => slug("!!!")).toThrow(/shortName/);
2248
- expect(() => slug("..")).toThrow(/shortName/);
2249
- });
2250
- });
2251
-
2252
- // ═══════════════════════════════════════════════════════════════════
2253
- // Loader + catalog — one malformed guide doesn't block the store
2254
- // ═══════════════════════════════════════════════════════════════════
2255
-
2256
- describe("loadApiGuidesFromDir + formatApiGuideCatalog", () => {
2257
- beforeEach(() => {
2258
- vi.spyOn(console, "warn").mockImplementation(() => {});
2259
- });
2260
- afterEach(() => {
2261
- vi.restoreAllMocks();
2262
- });
2263
-
2264
- it("lists a healthy and a malformed guide together", () => {
2265
- const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
2266
- try {
2267
- // Healthy guide in subdirectory
2268
- const boeDir = join(dir, "boe");
2269
- mkdirSync(boeDir, { recursive: true });
2270
- writeFileSync(join(boeDir, "guide.md"), BOE_RECIPE);
2271
-
2272
- // Malformed guide in subdirectory
2273
- const brokenDir = join(dir, "broken");
2274
- mkdirSync(brokenDir, { recursive: true });
2275
- writeFileSync(
2276
- join(brokenDir, "guide.md"),
2277
- `---
2278
- schemaVersion: 1
2279
- domains: [broken.com]
2280
- apiHost: https://api.broken.com
2281
- operations:
2282
- - name: get
2283
- via: restPost
2284
- path: /things
2285
- ---
2286
- body
2287
- `,
2288
- );
2289
-
2290
- const loaded = loadApiGuidesFromDir(dir);
2291
- expect(Object.keys(loaded.guides)).toEqual(["boe"]);
2292
- expect(loaded.malformed).toHaveLength(1);
2293
- expect(loaded.malformed[0]!.filename).toBe("broken");
2294
- expect(loaded.malformed[0]!.error.field).toBe("operations[0].via");
2295
-
2296
- const catalog = formatApiGuideCatalog(loaded);
2297
- expect(catalog).toContain("BOE");
2298
- expect(catalog).toContain("⚠ malformed");
2299
- expect(catalog).toContain("broken");
2300
- expect(catalog).toContain("operations[0].via");
2301
- } finally {
2302
- rmSync(dir, { recursive: true, force: true });
2303
- }
2304
- });
2305
-
2306
- it("collapses the catalog by organization (org line + orgless fallback)", () => {
2307
- const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
2308
- try {
2309
- const orgRecipe = (d: string, shortName: string, domains: string) => `---
2310
- schemaVersion: 1
2311
- kind: api
2312
- domains: [${domains}]
2313
- organization: archive.org
2314
- description: ${shortName} surface.
2315
- icon: 🏛️
2316
- shortName: ${shortName}
2317
- updated: 2026-07-17
2318
- apiHost: https://${d}
2319
- verified: 2026-07-17
2320
- gatherAllMax: 500
2321
- auth:
2322
- kind: none
2323
- responseShape:
2324
- format: json
2325
- charset: utf-8
2326
- operations:
2327
- - name: get
2328
- via: restGet
2329
- path: /x
2330
- accept: json
2331
- ---
2332
- org guide.
2333
- `;
2334
- for (const [domain, folder, shortName] of [
2335
- ["archive.org", "archive", "Archive"],
2336
- ["web.archive.org", "wayback", "Wayback"],
2337
- ] as const) {
2338
- mkdirSync(join(dir, folder), { recursive: true });
2339
- writeFileSync(
2340
- join(dir, folder, "guide.md"),
2341
- orgRecipe(domain, shortName, domain),
2342
- );
2343
- }
2344
- // Orgless guide keeps the per-guide line (fallback).
2345
- mkdirSync(join(dir, "boe"), { recursive: true });
2346
- writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
2347
-
2348
- const loaded = loadApiGuidesFromDir(dir);
2349
- const catalog = formatApiGuideCatalog(loaded);
2350
- // One org-collapsed line for archive.org with guide count + domain set.
2351
- expect(catalog).toContain(
2352
- "🏛️ archive.org — 2 guides (archive.org, web.archive.org)",
2353
- );
2354
- // Orgless BOE keeps the per-guide shape (icon + shortName + ops).
2355
- expect(catalog).toContain("⚖️ BOE — boe.es, www.boe.es");
2356
- expect(catalog).not.toContain("🏛️ BOE");
2357
- // Footer mentions the disambiguation menu.
2358
- expect(catalog).toContain("disambiguation menu");
2359
- } finally {
2360
- rmSync(dir, { recursive: true, force: true });
2361
- }
2362
- });
2363
-
2364
- it("returns empty result for a nonexistent directory", () => {
2365
- const loaded = loadApiGuidesFromDir(
2366
- join(tmpdir(), "host-guides-nonexistent-xyz"),
2367
- );
2368
- expect(loaded.guides).toEqual({});
2369
- expect(loaded.malformed).toEqual([]);
2370
- expect(formatApiGuideCatalog(loaded)).toContain("no guides");
2371
- });
2372
-
2373
- it("skips subdirectories without guide.md", () => {
2374
- const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
2375
- try {
2376
- // A subdir without guide.md — skipped
2377
- mkdirSync(join(dir, "no-guide"), { recursive: true });
2378
- writeFileSync(join(dir, "no-guide", "helper.ts"), "export default p => p;");
2379
-
2380
- // A valid subdir with guide.md — loaded
2381
- mkdirSync(join(dir, "boe"), { recursive: true });
2382
- writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
2383
-
2384
- // Flat .md files at top level — ignored
2385
- writeFileSync(join(dir, "README.txt"), "not a guide");
2386
-
2387
- const loaded = loadApiGuidesFromDir(dir);
2388
- expect(Object.keys(loaded.guides)).toEqual(["boe"]);
2389
- } finally {
2390
- rmSync(dir, { recursive: true, force: true });
2391
- }
2392
- });
2393
-
2394
- it("routes a divergent folder (entry !== slug(shortName)) to malformed", () => {
2395
- const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
2396
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
2397
- try {
2398
- // BOE_RECIPE has shortName: BOE → slug "boe"; folder is "boe.es" —
2399
- // the pre-migration state. Under enforcement the guide does NOT load.
2400
- mkdirSync(join(dir, "boe.es"), { recursive: true });
2401
- writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
2402
-
2403
- const loaded = loadApiGuidesFromDir(dir);
2404
- expect(Object.keys(loaded.guides)).toEqual([]);
2405
- expect(loaded.malformed).toHaveLength(1);
2406
- expect(loaded.malformed[0]!.filename).toBe("boe.es");
2407
- expect(loaded.malformed[0]!.error.field).toBe("shortName");
2408
- expect(loaded.malformed[0]!.error.found).toBe("folder 'boe.es'");
2409
- expect(loaded.malformed[0]!.error.fix).toContain("mv");
2410
- expect(loaded.malformed[0]!.error.fix).toContain("boe");
2411
- // The per-guide fix names the mv only; no /reload instruction on the
2412
- // fix line itself.
2413
- expect(loaded.malformed[0]!.error.fix).not.toContain("/reload");
2414
- // The malformed guide is warned about at load, and the catalog
2415
- // renders its actionable fix.
2416
- const msg = warn.mock.calls.map((c) => String(c[0])).join("\n");
2417
- expect(msg).toContain("Malformed guide");
2418
- expect(msg).toContain("boe.es");
2419
- const catalog = formatApiGuideCatalog(loaded);
2420
- expect(catalog).toContain("fix:");
2421
- expect(catalog).not.toContain("/reload");
2422
- } finally {
2423
- warn.mockRestore();
2424
- rmSync(dir, { recursive: true, force: true });
2425
- }
2426
- });
2427
-
2428
- it("routes warnings through the notify callback when provided", () => {
2429
- const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
2430
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
2431
- const notify = vi.fn();
2432
- try {
2433
- // Divergent folder (boe.es vs slug "boe") — must surface loudly.
2434
- mkdirSync(join(dir, "boe.es"), { recursive: true });
2435
- writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
2436
-
2437
- const loaded = loadApiGuidesFromDir(dir, notify);
2438
- expect(Object.keys(loaded.guides)).toEqual([]);
2439
- expect(loaded.malformed).toHaveLength(1);
2440
- // The per-guide warning goes through notify, not console.warn.
2441
- expect(notify).toHaveBeenCalled();
2442
- expect(warn).not.toHaveBeenCalled();
2443
- const msgs = notify.mock.calls.map((c) => String(c[0])).join("\n");
2444
- expect(msgs).toContain("Malformed guide");
2445
- expect(msgs).toContain("boe.es");
2446
- // Every notify call uses the warning kind (ctx.ui.notify signature).
2447
- expect(notify.mock.calls.every((c) => c[1] === "warning")).toBe(true);
2448
- } finally {
2449
- warn.mockRestore();
2450
- rmSync(dir, { recursive: true, force: true });
2451
- }
2452
- });
2453
-
2454
- it("a divergent + convergent pair sharing shortName loads only the convergent one", () => {
2455
- const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
2456
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
2457
- try {
2458
- // Old divergent folder (pre-migration) + new convergent folder
2459
- // (slug). Only the convergent one loads.
2460
- mkdirSync(join(dir, "boe.es"), { recursive: true });
2461
- writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
2462
- mkdirSync(join(dir, "boe"), { recursive: true });
2463
- writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
2464
-
2465
- const loaded = loadApiGuidesFromDir(dir);
2466
- expect(Object.keys(loaded.guides)).toEqual(["boe"]);
2467
- expect(loaded.malformed).toHaveLength(1);
2468
- } finally {
2469
- warn.mockRestore();
2470
- rmSync(dir, { recursive: true, force: true });
2471
- }
2472
- });
2473
-
2474
- it("routes an empty/all-symbol shortName to malformed without throwing", () => {
2475
- const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
2476
- try {
2477
- for (const bad of ["'!!!'", "''"]) {
2478
- mkdirSync(join(dir, "bad"), { recursive: true });
2479
- writeFileSync(
2480
- join(dir, "bad", "guide.md"),
2481
- BOE_RECIPE.replace("shortName: BOE", `shortName: ${bad}`),
2482
- );
2483
-
2484
- const loaded = loadApiGuidesFromDir(dir);
2485
- expect(Object.keys(loaded.guides)).toEqual([]);
2486
- expect(loaded.malformed).toHaveLength(1);
2487
- expect(loaded.malformed[0]!.filename).toBe("bad");
2488
- expect(loaded.malformed[0]!.error.field).toBe("shortName");
2489
- expect(loaded.malformed[0]!.error.fix).toContain("shortName");
2490
- rmSync(join(dir, "bad"), { recursive: true, force: true });
2491
- }
2492
- } finally {
2493
- rmSync(dir, { recursive: true, force: true });
2494
- }
2495
- });
2496
-
2497
- it("a divergent guide loads after an agent-assisted rename to slug(shortName)", () => {
2498
- const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
2499
- try {
2500
- // BOE_RECIPE has shortName: BOE → slug "boe"; folder is "boe.es" —
2501
- // the pre-migration state routes to malformed.
2502
- mkdirSync(join(dir, "boe.es"), { recursive: true });
2503
- writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
2504
- let loaded = loadApiGuidesFromDir(dir);
2505
- expect(Object.keys(loaded.guides)).toEqual([]);
2506
- expect(loaded.malformed).toHaveLength(1);
2507
-
2508
- // The migration instruction (mv boe.es boe); /reload lives in the banner.
2509
- renameSync(join(dir, "boe.es"), join(dir, "boe"));
2510
- loaded = loadApiGuidesFromDir(dir);
2511
- expect(Object.keys(loaded.guides)).toEqual(["boe"]);
2512
- expect(loaded.malformed).toEqual([]);
2513
- } finally {
2514
- rmSync(dir, { recursive: true, force: true });
2515
- }
2516
- });
2517
- });
2518
-
2519
- // ═══════════════════════════════════════════════════════════════════
2520
- // stampFrontmatterField — save-stamp blank-line separation (G3)
2521
- // ═══════════════════════════════════════════════════════════════════
2522
-
2523
- describe("stampFrontmatterField", () => {
2524
- it("inserts a blank line before a new key when the preceding line is non-empty", () => {
2525
- const out = stampFrontmatterField(
2526
- "---\nfoo: bar\n---\n",
2527
- "schemaVersion",
2528
- "0",
2529
- );
2530
- expect(out).toBe("---\nfoo: bar\n\nschemaVersion: 0\n---\n");
2531
- });
2532
-
2533
- it("does not double-blank when the preceding line is already empty", () => {
2534
- const out = stampFrontmatterField(
2535
- "---\nfoo: bar\n\n---\n",
2536
- "schemaVersion",
2537
- "0",
2538
- );
2539
- expect(out).toBe("---\nfoo: bar\n\nschemaVersion: 0\n---\n");
2540
- });
2541
-
2542
- it("replaces an existing key without introducing a blank line (idempotent re-stamp)", () => {
2543
- const out = stampFrontmatterField(
2544
- "---\nfoo: bar\nschemaVersion: 0\n---\n",
2545
- "schemaVersion",
2546
- "1",
2547
- );
2548
- expect(out).toBe("---\nfoo: bar\nschemaVersion: 1\n---\n");
2549
- });
2550
-
2551
- it("replaces a valueless key line instead of duplicating it", () => {
2552
- // A bare `schemaVersion:` would otherwise survive the replace and
2553
- // collide with the appended stamp (duplicate YAML key).
2554
- const out = stampFrontmatterField(
2555
- "---\nfoo: bar\nschemaVersion:\n---\n",
2556
- "schemaVersion",
2557
- "1",
2558
- );
2559
- expect(out).toBe("---\nfoo: bar\nschemaVersion: 1\n---\n");
2560
- });
2561
- });
2562
-
2563
2158
  // ═══════════════════════════════════════════════════════════════════
2564
2159
  // secretPathRefs — token-in-path secret injection
2565
2160
  // ═══════════════════════════════════════════════════════════════════
@@ -114,7 +114,6 @@ describe("spillResponse() — basic spill", () => {
114
114
  expected = JSON.parse(json);
115
115
  } catch {
116
116
  expect.fail("JSON parse failed");
117
- return;
118
117
  }
119
118
  expect(parsed).toEqual(expected);
120
119
  });
@@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
7
7
  import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
+ import { mockCtx, notifyText } from "./test-utils.js";
10
11
  import { handleSecretsSubcommand } from "../core/secrets-command.js";
11
12
  import {
12
13
  listDomains,
@@ -28,28 +29,11 @@ afterEach(() => {
28
29
  rmSync(dir, { recursive: true, force: true });
29
30
  });
30
31
 
31
- function mockCtx(overrides: Record<string, unknown> = {}): any {
32
- return {
33
- hasUI: true,
34
- ui: {
35
- input: vi.fn(async () => undefined),
36
- confirm: vi.fn(async () => true),
37
- notify: vi.fn(),
38
- },
39
- ...overrides,
40
- };
41
- }
42
-
43
- /** Collect notify() calls as a single string. */
44
- function notified(ctx: any): string {
45
- return ctx.ui.notify.mock.calls.map((c: any[]) => c[0]).join("\n");
46
- }
47
-
48
32
  describe("/api secrets — list", () => {
49
33
  it("reports (no secrets stored) with a --help hint when empty (instructions moved to --help)", async () => {
50
34
  const ctx = mockCtx();
51
35
  await handleSecretsSubcommand("", ctx);
52
- const text = notified(ctx);
36
+ const text = notifyText(ctx);
53
37
  expect(text).toContain("(no secrets stored)");
54
38
  expect(text).toContain("--help");
55
39
  // The full instructions block no longer clutters the bare list.
@@ -62,7 +46,7 @@ describe("/api secrets — list", () => {
62
46
 
63
47
  const ctx = mockCtx();
64
48
  await handleSecretsSubcommand("", ctx);
65
- const text = notified(ctx);
49
+ const text = notifyText(ctx);
66
50
  expect(text).toContain("d.example");
67
51
  expect(text).toContain("api_key");
68
52
  expect(text).not.toContain("super-secret-value");
@@ -75,7 +59,7 @@ describe("/api secrets --help", () => {
75
59
  it("prints usage + full file-write instructions", async () => {
76
60
  const ctx = mockCtx();
77
61
  await handleSecretsSubcommand("--help", ctx);
78
- const text = notified(ctx);
62
+ const text = notifyText(ctx);
79
63
  expect(text).toContain("Usage: /api secrets");
80
64
  expect(text).toContain("/<domain>.json");
81
65
  });
@@ -83,7 +67,7 @@ describe("/api secrets --help", () => {
83
67
  it("accepts the bare 'help' alias", async () => {
84
68
  const ctx = mockCtx();
85
69
  await handleSecretsSubcommand("help", ctx);
86
- expect(notified(ctx)).toContain("Usage: /api secrets");
70
+ expect(notifyText(ctx)).toContain("Usage: /api secrets");
87
71
  });
88
72
  });
89
73
 
@@ -98,8 +82,8 @@ describe("/api secrets <domain> <name> — manual entry", () => {
98
82
  expect(promptTitle).toContain("d.example");
99
83
  expect(promptTitle).toContain("api_key");
100
84
  expect(readSecret("d.example", "api_key")).toBe("demo-key-abc");
101
- expect(notified(ctx)).toContain("Stored secret 'api_key'");
102
- expect(notified(ctx)).not.toContain("demo-key-abc");
85
+ expect(notifyText(ctx)).toContain("Stored secret 'api_key'");
86
+ expect(notifyText(ctx)).not.toContain("demo-key-abc");
103
87
  });
104
88
 
105
89
  it("aborts on cancel (undefined) without writing", async () => {
@@ -113,7 +97,7 @@ describe("/api secrets <domain> <name> — manual entry", () => {
113
97
  const ctx = mockCtx();
114
98
  ctx.ui.input.mockResolvedValueOnce(" ");
115
99
  await handleSecretsSubcommand("d.example k", ctx);
116
- expect(notified(ctx)).toContain("Aborted");
100
+ expect(notifyText(ctx)).toContain("Aborted");
117
101
  expect(listNames("d.example")).toEqual([]);
118
102
  });
119
103
 
@@ -121,7 +105,7 @@ describe("/api secrets <domain> <name> — manual entry", () => {
121
105
  const ctx = mockCtx({ hasUI: false });
122
106
  await handleSecretsSubcommand("d.example k", ctx);
123
107
  expect(ctx.ui.input).not.toHaveBeenCalled();
124
- expect(notified(ctx)).toContain("d.example.json");
108
+ expect(notifyText(ctx)).toContain("d.example.json");
125
109
  });
126
110
  });
127
111
 
@@ -135,7 +119,7 @@ describe("/api secrets <domain> — assisted entry", () => {
135
119
  await handleSecretsSubcommand("d.example", ctx);
136
120
 
137
121
  expect(readSecret("d.example", "api_key")).toBe("demo-key-xyz");
138
- const text = notified(ctx);
122
+ const text = notifyText(ctx);
139
123
  expect(text).toContain("Secrets for 'd.example'");
140
124
  expect(text).not.toContain("demo-key-xyz");
141
125
  });
@@ -146,7 +130,7 @@ describe("/api secrets <domain> — assisted entry", () => {
146
130
  const ctx = mockCtx({ hasUI: false });
147
131
  await handleSecretsSubcommand("d.example", ctx);
148
132
  expect(ctx.ui.input).not.toHaveBeenCalled();
149
- const text = notified(ctx);
133
+ const text = notifyText(ctx);
150
134
  expect(text).toContain("k");
151
135
  expect(text).not.toContain("secret-42");
152
136
  });
@@ -188,7 +172,7 @@ body
188
172
  expect(prompt).toContain("d.example");
189
173
  expect(prompt).toContain("api_key");
190
174
  expect(readSecret("d.example", "api_key")).toBe("demo-key-abc");
191
- const text = notified(ctx);
175
+ const text = notifyText(ctx);
192
176
  expect(text).toContain("Declared (guide): api_key");
193
177
  expect(text).not.toContain("demo-key-abc");
194
178
  } finally {
@@ -248,7 +232,7 @@ describe("/api secrets <domain> <name> --delete", () => {
248
232
 
249
233
  expect(ctx.ui.confirm).not.toHaveBeenCalled();
250
234
  expect(readSecret("d.example", "k")).toBeNull();
251
- const text = notified(ctx);
235
+ const text = notifyText(ctx);
252
236
  expect(text).toContain("Deleted secret 'k'");
253
237
  expect(text).not.toContain("secret-42");
254
238
  });
@@ -259,7 +243,7 @@ describe("/api secrets <domain> <name> --delete", () => {
259
243
  const ctx = mockCtx();
260
244
  await handleSecretsSubcommand("d.example nope --delete", ctx);
261
245
  expect(readSecret("d.example", "keep")).toBe("1");
262
- expect(notified(ctx)).toContain("No secret 'nope'");
246
+ expect(notifyText(ctx)).toContain("No secret 'nope'");
263
247
  });
264
248
 
265
249
  it("prunes the domain file after deleting the last secret", async () => {
@@ -289,7 +273,7 @@ describe("/api secrets <domain> --delete", () => {
289
273
  expect(ctx.ui.confirm).toHaveBeenCalledTimes(1);
290
274
  expect(readSecret("d.example", "a")).toBeNull();
291
275
  expect(readSecret("d.example", "b")).toBeNull();
292
- expect(notified(ctx)).toContain("Deleted all secrets for 'd.example'.");
276
+ expect(notifyText(ctx)).toContain("Deleted all secrets for 'd.example'.");
293
277
  });
294
278
 
295
279
  it("aborts (nothing deleted) when the user declines the confirm", async () => {
@@ -300,7 +284,7 @@ describe("/api secrets <domain> --delete", () => {
300
284
 
301
285
  await handleSecretsSubcommand("d.example --delete", ctx);
302
286
  expect(readSecret("d.example", "a")).toBe("1");
303
- expect(notified(ctx)).toContain("Cancelled");
287
+ expect(notifyText(ctx)).toContain("Cancelled");
304
288
  });
305
289
 
306
290
  it("headless deletes all without prompting or hanging", async () => {
@@ -314,7 +298,7 @@ describe("/api secrets <domain> --delete", () => {
314
298
  it("reports when a domain has no secrets", async () => {
315
299
  const ctx = mockCtx();
316
300
  await handleSecretsSubcommand("empty.example --delete", ctx);
317
- expect(notified(ctx)).toContain("No secrets stored");
301
+ expect(notifyText(ctx)).toContain("No secrets stored");
318
302
  });
319
303
  });
320
304
 
@@ -322,12 +306,12 @@ describe("/api secrets --delete misuse", () => {
322
306
  it("bare --delete without a domain is a usage warning", async () => {
323
307
  const ctx = mockCtx();
324
308
  await handleSecretsSubcommand("--delete", ctx);
325
- expect(notified(ctx)).toContain("Usage");
309
+ expect(notifyText(ctx)).toContain("Usage");
326
310
  });
327
311
 
328
312
  it("--help wins over --delete", async () => {
329
313
  const ctx = mockCtx();
330
314
  await handleSecretsSubcommand("--delete --help", ctx);
331
- expect(notified(ctx)).toContain("Usage: /api secrets");
315
+ expect(notifyText(ctx)).toContain("Usage: /api secrets");
332
316
  });
333
317
  });