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.
- package/README.md +5 -3
- package/node_modules/pi-lean-host/AGENTS.md +13 -6
- package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
- package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
- package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
- package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
- package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
- package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
- package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
- package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
- package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
- package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
- package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
- package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
- package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
- package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
- package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
- package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
- package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
- package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
- package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
- package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
- package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
- package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
- package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
- package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
- package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
- package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
- package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
- package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
- package/node_modules/pi-lean-host/core/auth.ts +2 -1
- package/node_modules/pi-lean-host/core/helpers.ts +29 -35
- package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
- package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
- package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
- package/node_modules/pi-lean-host/core/transport.ts +1 -1
- package/node_modules/pi-lean-host/package.json +1 -1
- package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
- package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
- package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
- package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
- package/node_modules/pi-lean-host/tools/utils.ts +69 -0
- package/node_modules/pi-lean-portal/AGENTS.md +4 -3
- package/node_modules/pi-lean-portal/README.md +9 -9
- package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
- package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
- package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
- package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
- package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
- package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
- package/node_modules/pi-lean-portal/browser-install.ts +454 -0
- package/node_modules/pi-lean-portal/browser-status.ts +21 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
- package/node_modules/pi-lean-portal/core/router.ts +7 -32
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
- package/node_modules/pi-lean-portal/index.ts +1 -18
- package/node_modules/pi-lean-portal/package.json +2 -1
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
- package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
- package/node_modules/pi-lean-search/AGENTS.md +5 -3
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
- package/node_modules/pi-lean-search/index.ts +0 -3
- package/node_modules/pi-lean-search/package.json +1 -1
- package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guide-catalog structural tests: projectToGuide() projection, slug()
|
|
3
|
+
* sanitization, the directory loader + catalog formatter, and the
|
|
4
|
+
* stampFrontmatterField save-stamp editor.
|
|
5
|
+
*
|
|
6
|
+
* Split out of parse-api-guide.test.ts — these test
|
|
7
|
+
* core/guide-catalog.ts + core/path-template.ts concerns, not the parser
|
|
8
|
+
* schema. BOE_RECIPE/expectOk are duplicated (not imported) so both files
|
|
9
|
+
* stay standalone test modules.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } 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";
|
|
22
|
+
import {
|
|
23
|
+
parseApiGuide,
|
|
24
|
+
stampFrontmatterField,
|
|
25
|
+
} from "../core/parse-api-guide.js";
|
|
26
|
+
import {
|
|
27
|
+
projectToGuide,
|
|
28
|
+
loadApiGuidesFromDir,
|
|
29
|
+
formatApiGuideCatalog,
|
|
30
|
+
} from "../core/guide-catalog.js";
|
|
31
|
+
import { slug } from "../core/path-template.js";
|
|
32
|
+
import { type ApiGuide } from "../core/api-guide-types.js";
|
|
33
|
+
|
|
34
|
+
const BOE_RECIPE = `---
|
|
35
|
+
schemaVersion: 1
|
|
36
|
+
kind: api
|
|
37
|
+
domains: [boe.es, www.boe.es]
|
|
38
|
+
icon: ⚖️
|
|
39
|
+
shortName: BOE
|
|
40
|
+
updated: 2026-07-17
|
|
41
|
+
apiHost: https://apidatos.boe.es/v1
|
|
42
|
+
verified: 2026-07-17
|
|
43
|
+
gatherAllMax: 500
|
|
44
|
+
|
|
45
|
+
auth:
|
|
46
|
+
kind: none
|
|
47
|
+
|
|
48
|
+
pagination:
|
|
49
|
+
style: offset-limit
|
|
50
|
+
pageParam: page
|
|
51
|
+
pageSizeParam: limit
|
|
52
|
+
pageSize: 50
|
|
53
|
+
itemsPath: data
|
|
54
|
+
|
|
55
|
+
responseShape:
|
|
56
|
+
format: json
|
|
57
|
+
charset: utf-8
|
|
58
|
+
|
|
59
|
+
operations:
|
|
60
|
+
- name: searchDiary
|
|
61
|
+
via: restGet
|
|
62
|
+
path: /diario/{date}
|
|
63
|
+
accept: json
|
|
64
|
+
params:
|
|
65
|
+
limit:
|
|
66
|
+
default: 50
|
|
67
|
+
helper: true
|
|
68
|
+
parse:
|
|
69
|
+
format: xml
|
|
70
|
+
charset: iso-8859-1
|
|
71
|
+
|
|
72
|
+
- name: listConsolidada
|
|
73
|
+
via: paginate
|
|
74
|
+
path: /legislacion-consolidada
|
|
75
|
+
accept: json
|
|
76
|
+
pagination:
|
|
77
|
+
style: cursor
|
|
78
|
+
cursorParam: cursor
|
|
79
|
+
cursorPath: pagination.nextCursor
|
|
80
|
+
itemsPath: results
|
|
81
|
+
gatherAllMax: 1000
|
|
82
|
+
---
|
|
83
|
+
# BOE Legislación Consolidada — structured API access
|
|
84
|
+
|
|
85
|
+
Use \`api-fetch\` with \`operation\` \`searchDiary\` to pull a day's dispatch.
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
function expectOk(
|
|
89
|
+
raw: string,
|
|
90
|
+
opts?: Parameters<typeof parseApiGuide>[1],
|
|
91
|
+
): ApiGuide {
|
|
92
|
+
const res = parseApiGuide(raw, opts);
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`expected ok, got error: ${res.error.field} — ${res.error.expected} (found: ${res.error.found})`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return res.guide;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("projectToGuide", () => {
|
|
102
|
+
it("strips recipe fields and retains kind: api", () => {
|
|
103
|
+
const guide = expectOk(BOE_RECIPE, { filename: "boe.es" });
|
|
104
|
+
const proj = projectToGuide(guide);
|
|
105
|
+
|
|
106
|
+
const keys = Object.keys(proj);
|
|
107
|
+
const RECIPE_KEYS = [
|
|
108
|
+
"apiHost",
|
|
109
|
+
"operations",
|
|
110
|
+
"pagination",
|
|
111
|
+
"auth",
|
|
112
|
+
"helper",
|
|
113
|
+
"verified",
|
|
114
|
+
"gatherAllMax",
|
|
115
|
+
"responseShape",
|
|
116
|
+
];
|
|
117
|
+
for (const k of RECIPE_KEYS) {
|
|
118
|
+
expect(keys).not.toContain(k);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
expect(proj.kind).toBe("api");
|
|
122
|
+
expect(proj.domains).toEqual(["boe.es", "www.boe.es"]);
|
|
123
|
+
expect(proj.icon).toBe("⚖️");
|
|
124
|
+
expect(proj.shortName).toBe("BOE");
|
|
125
|
+
expect(proj.updated).toBe("2026-07-17");
|
|
126
|
+
expect(proj.content).toContain("BOE Legislación Consolidada");
|
|
127
|
+
expect(proj.category).toBe("site");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("projection carries no helper reference", () => {
|
|
131
|
+
const guide = expectOk(BOE_RECIPE, { filename: "boe.es" });
|
|
132
|
+
const proj = projectToGuide(guide);
|
|
133
|
+
// Helper is a recipe (op-level) field; the Guide projection has no such key.
|
|
134
|
+
expect("helper" in proj).toBe(false);
|
|
135
|
+
expect("operations" in proj).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
140
|
+
// slug() — the shortName → identity-folder sanitizer
|
|
141
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
142
|
+
|
|
143
|
+
describe("slug()", () => {
|
|
144
|
+
it("lowercases and replaces non-[a-z0-9-] runs with a single '-'", () => {
|
|
145
|
+
expect(slug("BOE")).toBe("boe");
|
|
146
|
+
expect(slug("My Provider Pro!")).toBe("my-provider-pro");
|
|
147
|
+
expect(slug("Example Dev Inc")).toBe("example-dev-inc");
|
|
148
|
+
expect(slug("a/b")).toBe("a-b");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("transliterates Latin diacritics instead of dropping them", () => {
|
|
152
|
+
expect(slug("Café")).toBe("cafe");
|
|
153
|
+
expect(slug("Überwald")).toBe("uberwald");
|
|
154
|
+
expect(slug("Bjørk")).toBe("bjork");
|
|
155
|
+
expect(slug("Münchhausen")).toBe("munchhausen");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("collapses repeated '-' and strips leading/trailing '-'", () => {
|
|
159
|
+
expect(slug("a--b")).toBe("a-b");
|
|
160
|
+
expect(slug("-foo-")).toBe("foo");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("slug-collision pair: api_dev_full and api-dev-full both slug to api-dev-full", () => {
|
|
164
|
+
expect(slug("api_dev_full")).toBe("api-dev-full");
|
|
165
|
+
expect(slug("api-dev-full")).toBe("api-dev-full");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("throws on empty or all-symbol shortName (slug flattens to empty)", () => {
|
|
169
|
+
expect(() => slug("")).toThrow(/shortName/);
|
|
170
|
+
expect(() => slug("!!!")).toThrow(/shortName/);
|
|
171
|
+
expect(() => slug("..")).toThrow(/shortName/);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
176
|
+
// Loader + catalog — one malformed guide doesn't block the store
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
178
|
+
|
|
179
|
+
describe("loadApiGuidesFromDir + formatApiGuideCatalog", () => {
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
182
|
+
});
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
vi.restoreAllMocks();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("lists a healthy and a malformed guide together", () => {
|
|
188
|
+
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
189
|
+
try {
|
|
190
|
+
// Healthy guide in subdirectory
|
|
191
|
+
const boeDir = join(dir, "boe");
|
|
192
|
+
mkdirSync(boeDir, { recursive: true });
|
|
193
|
+
writeFileSync(join(boeDir, "guide.md"), BOE_RECIPE);
|
|
194
|
+
|
|
195
|
+
// Malformed guide in subdirectory
|
|
196
|
+
const brokenDir = join(dir, "broken");
|
|
197
|
+
mkdirSync(brokenDir, { recursive: true });
|
|
198
|
+
writeFileSync(
|
|
199
|
+
join(brokenDir, "guide.md"),
|
|
200
|
+
`---
|
|
201
|
+
schemaVersion: 1
|
|
202
|
+
domains: [broken.com]
|
|
203
|
+
apiHost: https://api.broken.com
|
|
204
|
+
operations:
|
|
205
|
+
- name: get
|
|
206
|
+
via: restPost
|
|
207
|
+
path: /things
|
|
208
|
+
---
|
|
209
|
+
body
|
|
210
|
+
`,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const loaded = loadApiGuidesFromDir(dir);
|
|
214
|
+
expect(Object.keys(loaded.guides)).toEqual(["boe"]);
|
|
215
|
+
expect(loaded.malformed).toHaveLength(1);
|
|
216
|
+
expect(loaded.malformed[0]!.filename).toBe("broken");
|
|
217
|
+
expect(loaded.malformed[0]!.error.field).toBe("operations[0].via");
|
|
218
|
+
|
|
219
|
+
const catalog = formatApiGuideCatalog(loaded);
|
|
220
|
+
expect(catalog).toContain("BOE");
|
|
221
|
+
expect(catalog).toContain("⚠ malformed");
|
|
222
|
+
expect(catalog).toContain("broken");
|
|
223
|
+
expect(catalog).toContain("operations[0].via");
|
|
224
|
+
} finally {
|
|
225
|
+
rmSync(dir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("collapses the catalog by organization (org line + orgless fallback)", () => {
|
|
230
|
+
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
231
|
+
try {
|
|
232
|
+
const orgRecipe = (d: string, shortName: string, domains: string) => `---
|
|
233
|
+
schemaVersion: 1
|
|
234
|
+
kind: api
|
|
235
|
+
domains: [${domains}]
|
|
236
|
+
organization: archive.org
|
|
237
|
+
description: ${shortName} surface.
|
|
238
|
+
icon: 🏛️
|
|
239
|
+
shortName: ${shortName}
|
|
240
|
+
updated: 2026-07-17
|
|
241
|
+
apiHost: https://${d}
|
|
242
|
+
verified: 2026-07-17
|
|
243
|
+
gatherAllMax: 500
|
|
244
|
+
auth:
|
|
245
|
+
kind: none
|
|
246
|
+
responseShape:
|
|
247
|
+
format: json
|
|
248
|
+
charset: utf-8
|
|
249
|
+
operations:
|
|
250
|
+
- name: get
|
|
251
|
+
via: restGet
|
|
252
|
+
path: /x
|
|
253
|
+
accept: json
|
|
254
|
+
---
|
|
255
|
+
org guide.
|
|
256
|
+
`;
|
|
257
|
+
for (const [domain, folder, shortName] of [
|
|
258
|
+
["archive.org", "archive", "Archive"],
|
|
259
|
+
["web.archive.org", "wayback", "Wayback"],
|
|
260
|
+
] as const) {
|
|
261
|
+
mkdirSync(join(dir, folder), { recursive: true });
|
|
262
|
+
writeFileSync(
|
|
263
|
+
join(dir, folder, "guide.md"),
|
|
264
|
+
orgRecipe(domain, shortName, domain),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
// Orgless guide keeps the per-guide line (fallback).
|
|
268
|
+
mkdirSync(join(dir, "boe"), { recursive: true });
|
|
269
|
+
writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
|
|
270
|
+
|
|
271
|
+
const loaded = loadApiGuidesFromDir(dir);
|
|
272
|
+
const catalog = formatApiGuideCatalog(loaded);
|
|
273
|
+
// One org-collapsed line for archive.org with guide count + domain set.
|
|
274
|
+
expect(catalog).toContain(
|
|
275
|
+
"🏛️ archive.org — 2 guides (archive.org, web.archive.org)",
|
|
276
|
+
);
|
|
277
|
+
// Orgless BOE keeps the per-guide shape (icon + shortName + ops).
|
|
278
|
+
expect(catalog).toContain("⚖️ BOE — boe.es, www.boe.es");
|
|
279
|
+
expect(catalog).not.toContain("🏛️ BOE");
|
|
280
|
+
// Footer mentions the disambiguation menu.
|
|
281
|
+
expect(catalog).toContain("disambiguation menu");
|
|
282
|
+
} finally {
|
|
283
|
+
rmSync(dir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("returns empty result for a nonexistent directory", () => {
|
|
288
|
+
const loaded = loadApiGuidesFromDir(
|
|
289
|
+
join(tmpdir(), "host-guides-nonexistent-xyz"),
|
|
290
|
+
);
|
|
291
|
+
expect(loaded.guides).toEqual({});
|
|
292
|
+
expect(loaded.malformed).toEqual([]);
|
|
293
|
+
expect(formatApiGuideCatalog(loaded)).toContain("no guides");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("skips subdirectories without guide.md", () => {
|
|
297
|
+
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
298
|
+
try {
|
|
299
|
+
// A subdir without guide.md — skipped
|
|
300
|
+
mkdirSync(join(dir, "no-guide"), { recursive: true });
|
|
301
|
+
writeFileSync(join(dir, "no-guide", "helper.ts"), "export default p => p;");
|
|
302
|
+
|
|
303
|
+
// A valid subdir with guide.md — loaded
|
|
304
|
+
mkdirSync(join(dir, "boe"), { recursive: true });
|
|
305
|
+
writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
|
|
306
|
+
|
|
307
|
+
// Flat .md files at top level — ignored
|
|
308
|
+
writeFileSync(join(dir, "README.txt"), "not a guide");
|
|
309
|
+
|
|
310
|
+
const loaded = loadApiGuidesFromDir(dir);
|
|
311
|
+
expect(Object.keys(loaded.guides)).toEqual(["boe"]);
|
|
312
|
+
} finally {
|
|
313
|
+
rmSync(dir, { recursive: true, force: true });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("routes a divergent folder (entry !== slug(shortName)) to malformed", () => {
|
|
318
|
+
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
319
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
320
|
+
try {
|
|
321
|
+
// BOE_RECIPE has shortName: BOE → slug "boe"; folder is "boe.es" —
|
|
322
|
+
// the pre-migration state. Under enforcement the guide does NOT load.
|
|
323
|
+
mkdirSync(join(dir, "boe.es"), { recursive: true });
|
|
324
|
+
writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
|
|
325
|
+
|
|
326
|
+
const loaded = loadApiGuidesFromDir(dir);
|
|
327
|
+
expect(Object.keys(loaded.guides)).toEqual([]);
|
|
328
|
+
expect(loaded.malformed).toHaveLength(1);
|
|
329
|
+
expect(loaded.malformed[0]!.filename).toBe("boe.es");
|
|
330
|
+
expect(loaded.malformed[0]!.error.field).toBe("shortName");
|
|
331
|
+
expect(loaded.malformed[0]!.error.found).toBe("folder 'boe.es'");
|
|
332
|
+
expect(loaded.malformed[0]!.error.fix).toContain("mv");
|
|
333
|
+
expect(loaded.malformed[0]!.error.fix).toContain("boe");
|
|
334
|
+
// The per-guide fix names the mv only; no /reload instruction on the
|
|
335
|
+
// fix line itself.
|
|
336
|
+
expect(loaded.malformed[0]!.error.fix).not.toContain("/reload");
|
|
337
|
+
// The malformed guide is warned about at load, and the catalog
|
|
338
|
+
// renders its actionable fix.
|
|
339
|
+
const msg = warn.mock.calls.map((c) => String(c[0])).join("\n");
|
|
340
|
+
expect(msg).toContain("Malformed guide");
|
|
341
|
+
expect(msg).toContain("boe.es");
|
|
342
|
+
const catalog = formatApiGuideCatalog(loaded);
|
|
343
|
+
expect(catalog).toContain("fix:");
|
|
344
|
+
expect(catalog).not.toContain("/reload");
|
|
345
|
+
} finally {
|
|
346
|
+
warn.mockRestore();
|
|
347
|
+
rmSync(dir, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("routes warnings through the notify callback when provided", () => {
|
|
352
|
+
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
353
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
354
|
+
const notify = vi.fn();
|
|
355
|
+
try {
|
|
356
|
+
// Divergent folder (boe.es vs slug "boe") — must surface loudly.
|
|
357
|
+
mkdirSync(join(dir, "boe.es"), { recursive: true });
|
|
358
|
+
writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
|
|
359
|
+
|
|
360
|
+
const loaded = loadApiGuidesFromDir(dir, notify);
|
|
361
|
+
expect(Object.keys(loaded.guides)).toEqual([]);
|
|
362
|
+
expect(loaded.malformed).toHaveLength(1);
|
|
363
|
+
// The per-guide warning goes through notify, not console.warn.
|
|
364
|
+
expect(notify).toHaveBeenCalled();
|
|
365
|
+
expect(warn).not.toHaveBeenCalled();
|
|
366
|
+
const msgs = notify.mock.calls.map((c) => String(c[0])).join("\n");
|
|
367
|
+
expect(msgs).toContain("Malformed guide");
|
|
368
|
+
expect(msgs).toContain("boe.es");
|
|
369
|
+
// Every notify call uses the warning kind (ctx.ui.notify signature).
|
|
370
|
+
expect(notify.mock.calls.every((c) => c[1] === "warning")).toBe(true);
|
|
371
|
+
} finally {
|
|
372
|
+
warn.mockRestore();
|
|
373
|
+
rmSync(dir, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("a divergent + convergent pair sharing shortName loads only the convergent one", () => {
|
|
378
|
+
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
379
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
380
|
+
try {
|
|
381
|
+
// Old divergent folder (pre-migration) + new convergent folder
|
|
382
|
+
// (slug). Only the convergent one loads.
|
|
383
|
+
mkdirSync(join(dir, "boe.es"), { recursive: true });
|
|
384
|
+
writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
|
|
385
|
+
mkdirSync(join(dir, "boe"), { recursive: true });
|
|
386
|
+
writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
|
|
387
|
+
|
|
388
|
+
const loaded = loadApiGuidesFromDir(dir);
|
|
389
|
+
expect(Object.keys(loaded.guides)).toEqual(["boe"]);
|
|
390
|
+
expect(loaded.malformed).toHaveLength(1);
|
|
391
|
+
} finally {
|
|
392
|
+
warn.mockRestore();
|
|
393
|
+
rmSync(dir, { recursive: true, force: true });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("routes an empty/all-symbol shortName to malformed without throwing", () => {
|
|
398
|
+
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
399
|
+
try {
|
|
400
|
+
for (const bad of ["'!!!'", "''"]) {
|
|
401
|
+
mkdirSync(join(dir, "bad"), { recursive: true });
|
|
402
|
+
writeFileSync(
|
|
403
|
+
join(dir, "bad", "guide.md"),
|
|
404
|
+
BOE_RECIPE.replace("shortName: BOE", `shortName: ${bad}`),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const loaded = loadApiGuidesFromDir(dir);
|
|
408
|
+
expect(Object.keys(loaded.guides)).toEqual([]);
|
|
409
|
+
expect(loaded.malformed).toHaveLength(1);
|
|
410
|
+
expect(loaded.malformed[0]!.filename).toBe("bad");
|
|
411
|
+
expect(loaded.malformed[0]!.error.field).toBe("shortName");
|
|
412
|
+
expect(loaded.malformed[0]!.error.fix).toContain("shortName");
|
|
413
|
+
rmSync(join(dir, "bad"), { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
} finally {
|
|
416
|
+
rmSync(dir, { recursive: true, force: true });
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("a divergent guide loads after an agent-assisted rename to slug(shortName)", () => {
|
|
421
|
+
const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
|
|
422
|
+
try {
|
|
423
|
+
// BOE_RECIPE has shortName: BOE → slug "boe"; folder is "boe.es" —
|
|
424
|
+
// the pre-migration state routes to malformed.
|
|
425
|
+
mkdirSync(join(dir, "boe.es"), { recursive: true });
|
|
426
|
+
writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
|
|
427
|
+
let loaded = loadApiGuidesFromDir(dir);
|
|
428
|
+
expect(Object.keys(loaded.guides)).toEqual([]);
|
|
429
|
+
expect(loaded.malformed).toHaveLength(1);
|
|
430
|
+
|
|
431
|
+
// The migration instruction (mv boe.es boe); /reload lives in the banner.
|
|
432
|
+
renameSync(join(dir, "boe.es"), join(dir, "boe"));
|
|
433
|
+
loaded = loadApiGuidesFromDir(dir);
|
|
434
|
+
expect(Object.keys(loaded.guides)).toEqual(["boe"]);
|
|
435
|
+
expect(loaded.malformed).toEqual([]);
|
|
436
|
+
} finally {
|
|
437
|
+
rmSync(dir, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
443
|
+
// stampFrontmatterField — save-stamp blank-line separation (G3)
|
|
444
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
445
|
+
|
|
446
|
+
describe("stampFrontmatterField", () => {
|
|
447
|
+
it("inserts a blank line before a new key when the preceding line is non-empty", () => {
|
|
448
|
+
const out = stampFrontmatterField(
|
|
449
|
+
"---\nfoo: bar\n---\n",
|
|
450
|
+
"schemaVersion",
|
|
451
|
+
"0",
|
|
452
|
+
);
|
|
453
|
+
expect(out).toBe("---\nfoo: bar\n\nschemaVersion: 0\n---\n");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("does not double-blank when the preceding line is already empty", () => {
|
|
457
|
+
const out = stampFrontmatterField(
|
|
458
|
+
"---\nfoo: bar\n\n---\n",
|
|
459
|
+
"schemaVersion",
|
|
460
|
+
"0",
|
|
461
|
+
);
|
|
462
|
+
expect(out).toBe("---\nfoo: bar\n\nschemaVersion: 0\n---\n");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("replaces an existing key without introducing a blank line (idempotent re-stamp)", () => {
|
|
466
|
+
const out = stampFrontmatterField(
|
|
467
|
+
"---\nfoo: bar\nschemaVersion: 0\n---\n",
|
|
468
|
+
"schemaVersion",
|
|
469
|
+
"1",
|
|
470
|
+
);
|
|
471
|
+
expect(out).toBe("---\nfoo: bar\nschemaVersion: 1\n---\n");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("replaces a valueless key line instead of duplicating it", () => {
|
|
475
|
+
// A bare `schemaVersion:` would otherwise survive the replace and
|
|
476
|
+
// collide with the appended stamp (duplicate YAML key).
|
|
477
|
+
const out = stampFrontmatterField(
|
|
478
|
+
"---\nfoo: bar\nschemaVersion:\n---\n",
|
|
479
|
+
"schemaVersion",
|
|
480
|
+
"1",
|
|
481
|
+
);
|
|
482
|
+
expect(out).toBe("---\nfoo: bar\nschemaVersion: 1\n---\n");
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -24,8 +24,6 @@ import {
|
|
|
24
24
|
normalizeDateParam,
|
|
25
25
|
type RestGetResult,
|
|
26
26
|
} from "../core/helpers.js";
|
|
27
|
-
import { ssrfGuard } from "../core/ssrf-guard.js";
|
|
28
|
-
import { fetchUrl } from "../core/transport.js";
|
|
29
27
|
import type { ApiGuide, Operation } from "../core/api-guide-types.js";
|
|
30
28
|
|
|
31
29
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -196,19 +194,6 @@ async function createTestServer(): Promise<TestContext> {
|
|
|
196
194
|
return;
|
|
197
195
|
}
|
|
198
196
|
|
|
199
|
-
// 302 redirect to the cloud metadata endpoint — used by the
|
|
200
|
-
// fetchUrl guardRedirects test (M3). The initial URL is on the
|
|
201
|
-
// test server (127.0.0.1); fetchUrl does NOT ssrf-check the
|
|
202
|
-
// initial URL, only redirect targets, so this isolates the
|
|
203
|
-
// redirect-guard behaviour from paginate's pre-fetch guard.
|
|
204
|
-
if (pathname === "/redirect-to-metadata") {
|
|
205
|
-
res.writeHead(302, {
|
|
206
|
-
Location: "http://169.254.169.254/latest/meta-data/",
|
|
207
|
-
});
|
|
208
|
-
res.end();
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
197
|
if (pathname === "/api/paginate/cursor") {
|
|
213
198
|
const cursor = url.searchParams.get("cursor") ?? "";
|
|
214
199
|
if (cursor === "done") {
|
|
@@ -459,24 +444,6 @@ async function createTestServer(): Promise<TestContext> {
|
|
|
459
444
|
return;
|
|
460
445
|
}
|
|
461
446
|
|
|
462
|
-
if (pathname === "/api/latin1-no-charset") {
|
|
463
|
-
// ISO-8859-1 bytes for áéíóú, served with NO charset parameter —
|
|
464
|
-
// the transport must fall back to the caller's fallbackCharset.
|
|
465
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
466
|
-
res.end(Buffer.from([0xe1, 0xe9, 0xed, 0xf3, 0xfa]));
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (pathname === "/api/utf8-with-charset") {
|
|
471
|
-
// Real UTF-8 bytes for áéíóú, served WITH charset=utf-8 — the
|
|
472
|
-
// header charset must win even if a fallbackCharset is supplied.
|
|
473
|
-
res.writeHead(200, {
|
|
474
|
-
"Content-Type": "application/json; charset=utf-8",
|
|
475
|
-
});
|
|
476
|
-
res.end(Buffer.from("áéíóú", "utf-8"));
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
447
|
if (pathname === "/api/cache-test") {
|
|
481
448
|
const count = requestCounts.get(pathname) ?? 0;
|
|
482
449
|
res.writeHead(200, {
|
|
@@ -2145,105 +2112,6 @@ describe("errorPath — 200-with-error-envelope", () => {
|
|
|
2145
2112
|
// runs passing — pinned here once, not re-tested in verify-command.
|
|
2146
2113
|
});
|
|
2147
2114
|
|
|
2148
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2149
|
-
// SSRF guard (M1) — IPv4-mapped IPv6 bypass + baseline blocks
|
|
2150
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2151
|
-
|
|
2152
|
-
describe("ssrfGuard — IPv4-mapped IPv6 (M1)", () => {
|
|
2153
|
-
it("blocks IPv4-mapped loopback in hex form", () => {
|
|
2154
|
-
// Node renders http://[::ffff:127.0.0.1]/ as "[::ffff:7f00:1]" —
|
|
2155
|
-
// the old decimal "::ffff:127" check never matched this.
|
|
2156
|
-
expect(ssrfGuard("http://[::ffff:127.0.0.1]/").ok).toBe(false);
|
|
2157
|
-
});
|
|
2158
|
-
|
|
2159
|
-
it("blocks IPv4-mapped private + metadata ranges", () => {
|
|
2160
|
-
expect(ssrfGuard("http://[::ffff:10.0.0.1]/").ok).toBe(false);
|
|
2161
|
-
expect(ssrfGuard("http://[::ffff:192.168.1.1]/").ok).toBe(false);
|
|
2162
|
-
expect(ssrfGuard("http://[::ffff:169.254.169.254]/").ok).toBe(false);
|
|
2163
|
-
});
|
|
2164
|
-
|
|
2165
|
-
it("still blocks plain IPv4 private/metadata/loopback", () => {
|
|
2166
|
-
expect(ssrfGuard("http://127.0.0.1/").ok).toBe(false);
|
|
2167
|
-
expect(ssrfGuard("http://10.0.0.1/").ok).toBe(false);
|
|
2168
|
-
expect(ssrfGuard("http://169.254.169.254/").ok).toBe(false);
|
|
2169
|
-
});
|
|
2170
|
-
|
|
2171
|
-
it("allows public hostnames", () => {
|
|
2172
|
-
expect(ssrfGuard("https://api.example.com/v1/foo").ok).toBe(true);
|
|
2173
|
-
});
|
|
2174
|
-
});
|
|
2175
|
-
|
|
2176
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2177
|
-
// fetchUrl guardRedirects (M3) — redirect-target SSRF guarding
|
|
2178
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
2179
|
-
|
|
2180
|
-
describe("fetchUrl — fallbackCharset", () => {
|
|
2181
|
-
let ctx: TestContext;
|
|
2182
|
-
|
|
2183
|
-
beforeAll(async () => {
|
|
2184
|
-
ctx = await createTestServer();
|
|
2185
|
-
});
|
|
2186
|
-
afterAll(async () => {
|
|
2187
|
-
await ctx.stop();
|
|
2188
|
-
});
|
|
2189
|
-
|
|
2190
|
-
it("falls back to fallbackCharset when the response omits a charset", async () => {
|
|
2191
|
-
// Server serves ISO-8859-1 bytes with no charset parameter.
|
|
2192
|
-
const { body } = await fetchUrl(`${ctx.serverUrl}/api/latin1-no-charset`, {
|
|
2193
|
-
fallbackCharset: "iso-8859-1",
|
|
2194
|
-
fresh: true,
|
|
2195
|
-
});
|
|
2196
|
-
expect(body).toBe("áéíóú");
|
|
2197
|
-
});
|
|
2198
|
-
|
|
2199
|
-
it("uses utf-8 by default when no fallbackCharset is supplied", async () => {
|
|
2200
|
-
const { body } = await fetchUrl(`${ctx.serverUrl}/api/latin1-no-charset`, {
|
|
2201
|
-
fresh: true,
|
|
2202
|
-
});
|
|
2203
|
-
expect(body).not.toBe("áéíóú");
|
|
2204
|
-
expect(body).toBe("�����");
|
|
2205
|
-
});
|
|
2206
|
-
|
|
2207
|
-
it("header charset wins over fallbackCharset", async () => {
|
|
2208
|
-
// Server declares charset=utf-8; supplying a latin-1 fallback must
|
|
2209
|
-
// NOT override it — the header charset always wins.
|
|
2210
|
-
const { body } = await fetchUrl(`${ctx.serverUrl}/api/utf8-with-charset`, {
|
|
2211
|
-
fallbackCharset: "iso-8859-1",
|
|
2212
|
-
fresh: true,
|
|
2213
|
-
});
|
|
2214
|
-
expect(body).toBe("áéíóú");
|
|
2215
|
-
});
|
|
2216
|
-
});
|
|
2217
|
-
|
|
2218
|
-
describe("fetchUrl — guardRedirects (M3)", () => {
|
|
2219
|
-
let ctx: TestContext;
|
|
2220
|
-
|
|
2221
|
-
beforeAll(async () => {
|
|
2222
|
-
ctx = await createTestServer();
|
|
2223
|
-
});
|
|
2224
|
-
|
|
2225
|
-
afterAll(async () => {
|
|
2226
|
-
await ctx.stop();
|
|
2227
|
-
});
|
|
2228
|
-
|
|
2229
|
-
it("blocks a 302 redirect to the cloud metadata endpoint", async () => {
|
|
2230
|
-
// fetchUrl does NOT ssrf-check the initial URL (paginate owns that),
|
|
2231
|
-
// so hitting the 127.0.0.1 test server is fine. The redirect target
|
|
2232
|
-
// (169.254.169.254) must be blocked before it is fetched.
|
|
2233
|
-
const before = ctx.requestCounts.get("/redirect-to-metadata") ?? 0;
|
|
2234
|
-
await expect(
|
|
2235
|
-
fetchUrl(`${ctx.serverUrl}/redirect-to-metadata`, {
|
|
2236
|
-
guardRedirects: true,
|
|
2237
|
-
}),
|
|
2238
|
-
).rejects.toThrow(/Redirect to blocked host/i);
|
|
2239
|
-
|
|
2240
|
-
// The redirect endpoint was hit exactly once (the SSRF block is not
|
|
2241
|
-
// transient, so fetchUrl must not retry).
|
|
2242
|
-
const after = ctx.requestCounts.get("/redirect-to-metadata") ?? 0;
|
|
2243
|
-
expect(after - before).toBe(1);
|
|
2244
|
-
});
|
|
2245
|
-
});
|
|
2246
|
-
|
|
2247
2115
|
// ═══════════════════════════════════════════════════════════════════
|
|
2248
2116
|
// listStyle — multi-value query params (serializer widening)
|
|
2249
2117
|
// ═══════════════════════════════════════════════════════════════════
|