@danypops/pi-web-spider 0.10.4

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.
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pi-loader-harness.mjs — 1:1 reproduction of Pi's production extension loader.
4
+ *
5
+ * Uses Pi's ACTUAL loadExtensionModule() jiti setup (same import.meta.url anchor,
6
+ * same alias map, same tryNative default) by re-exporting the internal function
7
+ * via a thin shim. This is the only way to reproduce bugs that only surface in
8
+ * Pi's production load path (e.g. "Map operation called on non-Map object").
9
+ *
10
+ * Emits NDJSON on stdout — same format as mock-pi-cli.mjs and Pi's --mode json.
11
+ *
12
+ * Args:
13
+ * --extension <path> extension entry point
14
+ * --tool <name> tool to invoke (default: web_fetch)
15
+ * --params <json> tool params as JSON
16
+ * --env KEY=VALUE env overrides (repeatable)
17
+ */
18
+
19
+ import { resolve } from "node:path"
20
+ import { fileURLToPath, pathToFileURL } from "node:url"
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Args
24
+ // ---------------------------------------------------------------------------
25
+ const args = process.argv.slice(2)
26
+ const get = f => { const i = args.indexOf(f); return i !== -1 ? args[i+1] : null }
27
+ const getAll = f => { const v = []; for (let i=0;i<args.length-1;i++) if(args[i]===f) v.push(args[i+1]); return v }
28
+
29
+ const extensionPath = get("--extension")
30
+ const toolName = get("--tool") ?? "web_fetch"
31
+ const paramsJson = get("--params") ?? "{}"
32
+ const envOverrides = Object.fromEntries(getAll("--env").map(s => s.split("=", 2)))
33
+
34
+ if (!extensionPath) { process.stderr.write("--extension required\n"); process.exit(1) }
35
+
36
+ for (const [k,v] of Object.entries(envOverrides)) process.env[k] = v
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Load Pi's actual extension infrastructure
40
+ // ---------------------------------------------------------------------------
41
+ const PI_LOADER = "/home/dpopsuev/Workspace/pi-mono/packages/coding-agent/dist/core/extensions/loader.js"
42
+
43
+ const {
44
+ createExtensionRuntime,
45
+ loadExtensions,
46
+ } = await import(pathToFileURL(PI_LOADER).href)
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Minimal EventBus stub — only what loadExtensions needs
50
+ // ---------------------------------------------------------------------------
51
+ const eventBus = {
52
+ emit() {},
53
+ on() {},
54
+ off() {},
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Load extension through Pi's EXACT production path
59
+ // ---------------------------------------------------------------------------
60
+ const runtime = createExtensionRuntime()
61
+ const absExtPath = resolve(extensionPath)
62
+
63
+ // loadExtensions returns { extensions, errors, runtime }
64
+ const { extensions, errors } = await loadExtensions([absExtPath], process.cwd(), eventBus)
65
+
66
+ if (errors.length) {
67
+ process.stderr.write(`[pi-loader-harness] load errors: ${JSON.stringify(errors)}\n`)
68
+ process.exit(1)
69
+ }
70
+ if (!extensions.length) {
71
+ process.stderr.write(`[pi-loader-harness] no extensions loaded\n`)
72
+ process.exit(1)
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Find the registered tool
77
+ // ---------------------------------------------------------------------------
78
+ const toolEntry = extensions[0].tools.get(toolName)
79
+ if (!toolEntry) {
80
+ process.stderr.write(`[pi-loader-harness] tool not found: ${toolName}\n`)
81
+ process.exit(1)
82
+ }
83
+
84
+ const params = JSON.parse(paramsJson)
85
+ emit({ type: "tool_execution_start", toolName, args: params })
86
+
87
+ try {
88
+ const result = await toolEntry.definition.execute(`harness-${Date.now()}`, params)
89
+ emit({ type: "tool_execution_end", toolName, result })
90
+ emit({ type: "exit", code: 0 })
91
+ } catch (err) {
92
+ emit({ type: "tool_execution_error", toolName, error: err?.message ?? String(err) })
93
+ emit({ type: "exit", code: 1 })
94
+ }
95
+
96
+ function emit(obj) { process.stdout.write(JSON.stringify(obj) + "\n") }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Token-efficiency tests for the web_fetch output formatters.
3
+ *
4
+ * The fixture is deliberately noisy — it mirrors real-world pages that have:
5
+ * - 30+ navigation links (site menus, footer, breadcrumbs)
6
+ * - 3 meaningful body links (actual content references)
7
+ * - Empty metadata (no author, no description, no tags, no publishedAt)
8
+ * - Rich heading structure already present in the markdown body
9
+ * - A non-trivial word count
10
+ *
11
+ * Each test asserts that the corresponding formatter:
12
+ * 1. Includes only the fields an agent can act on
13
+ * 2. Excludes fields that are redundant, derivable, or always empty
14
+ * 3. Separates body links from nav-chrome links
15
+ * 4. Produces output that is measurably smaller than a naive full dump
16
+ */
17
+
18
+ import { describe, expect, it } from "vitest";
19
+ import type { SpideredPage } from "@danypops/web-spider";
20
+ import {
21
+ bodyLinks,
22
+ highlightHit,
23
+ leanOutput,
24
+ linksOutput,
25
+ markdownOutput,
26
+ navLinksCount,
27
+ omitEmpty,
28
+ } from "../src/format.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Fixture
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Generate N nav links simulating a typical site header/footer. */
35
+ function makeNavLinks(n: number): SpideredPage["links"] {
36
+ return Array.from({ length: n }, (_, i) => ({
37
+ href: `https://example.com/nav-${i}`,
38
+ text: `Nav Item ${i}`,
39
+ isExternal: false,
40
+ rel: "nav" as const,
41
+ }));
42
+ }
43
+
44
+ const NOISY_PAGE: SpideredPage = {
45
+ url: "https://example.com/how-ai-agents-work",
46
+ domain: "example.com",
47
+ fetchedAt: "2024-06-01T12:00:00Z",
48
+ title: "How AI Agents Work",
49
+
50
+ // Empty metadata — common on many pages
51
+ description: "",
52
+ author: "",
53
+ publishedAt: "",
54
+ lang: "en",
55
+ tags: [],
56
+
57
+ wordCount: 820,
58
+ readingTimeMinutes: 5, // wordCount / 200 — derivable, not actionable
59
+
60
+ headings: [
61
+ { level: 1, text: "How AI Agents Work" },
62
+ { level: 2, text: "What is an Agent?" },
63
+ { level: 2, text: "Memory and State" },
64
+ { level: 3, text: "Short-term Memory" },
65
+ { level: 3, text: "Long-term Memory" },
66
+ { level: 2, text: "Tool Use" },
67
+ { level: 2, text: "Conclusion" },
68
+ ],
69
+
70
+ chunks: [
71
+ { id: "c0", index: 0, heading: "What is an Agent?", text: "An agent is a system that perceives its environment and takes actions.", wordCount: 13, contentType: "text" },
72
+ { id: "c1", index: 1, heading: "Memory and State", text: "Memory allows agents to retain information across interactions.", wordCount: 10, contentType: "text" },
73
+ ],
74
+
75
+ links: [
76
+ // 30 nav links — site header, footer, sidebar
77
+ ...makeNavLinks(30),
78
+ // 3 body links — actual content references
79
+ { href: "https://arxiv.org/abs/2309.00864", text: "ReAct paper", isExternal: true, rel: "body" },
80
+ { href: "https://example.com/memory-in-llms", text: "Memory in LLMs", isExternal: false, rel: "body" },
81
+ { href: "https://example.com/tool-use", text: "Tool Use survey", isExternal: false, rel: "body" },
82
+ ],
83
+
84
+ // Headings are already present as ## lines inside the markdown body
85
+ markdown: [
86
+ "# How AI Agents Work",
87
+ "",
88
+ "## What is an Agent?",
89
+ "",
90
+ "An agent is a system that perceives its environment and takes actions.",
91
+ "See the [ReAct paper](https://arxiv.org/abs/2309.00864) for details.",
92
+ "",
93
+ "## Memory and State",
94
+ "",
95
+ "Memory allows agents to retain information across interactions.",
96
+ "Read more in [Memory in LLMs](https://example.com/memory-in-llms).",
97
+ "",
98
+ "### Short-term Memory",
99
+ "",
100
+ "Short-term memory is the working context of an agent.",
101
+ "",
102
+ "### Long-term Memory",
103
+ "",
104
+ "Long-term memory persists across sessions.",
105
+ "",
106
+ "## Tool Use",
107
+ "",
108
+ "Agents use tools to interact with the world. See the [Tool Use survey](https://example.com/tool-use).",
109
+ "",
110
+ "## Conclusion",
111
+ "",
112
+ "AI agents combine perception, memory, and action to solve complex tasks.",
113
+ ].join("\n"),
114
+ };
115
+
116
+ // Baseline: what a naive full dump looks like
117
+ const NAIVE_DUMP = JSON.stringify(NOISY_PAGE);
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // omitEmpty
121
+ // ---------------------------------------------------------------------------
122
+
123
+ describe("omitEmpty", () => {
124
+ it("removes empty strings", () => {
125
+ const result = omitEmpty({ a: "hello", b: "", c: "world" });
126
+ expect(result).toEqual({ a: "hello", c: "world" });
127
+ });
128
+
129
+ it("removes empty arrays", () => {
130
+ const result = omitEmpty({ tags: [], links: ["a"] });
131
+ expect(result).toEqual({ links: ["a"] });
132
+ });
133
+
134
+ it("removes undefined", () => {
135
+ const result = omitEmpty({ a: 1, b: undefined });
136
+ expect(result).toEqual({ a: 1 });
137
+ });
138
+
139
+ it("removes false", () => {
140
+ const result = omitEmpty({ jsRendered: false, wordCount: 100 });
141
+ expect(result).toEqual({ wordCount: 100 });
142
+ });
143
+
144
+ it("keeps 0 — zero is a real value", () => {
145
+ const result = omitEmpty({ count: 0, name: "x" });
146
+ expect(result).toEqual({ count: 0, name: "x" });
147
+ });
148
+
149
+ it("keeps null — null is a real value", () => {
150
+ const result = omitEmpty({ val: null, name: "x" });
151
+ expect(result).toEqual({ val: null, name: "x" });
152
+ });
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Link splitting
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe("bodyLinks / navLinksCount", () => {
160
+ it("extracts only body links", () => {
161
+ const bl = bodyLinks(NOISY_PAGE);
162
+ expect(bl).toHaveLength(3);
163
+ expect(bl.every((l) => !l.hasOwnProperty("rel"))).toBe(true);
164
+ expect(bl.map((l) => l.href)).toContain("https://arxiv.org/abs/2309.00864");
165
+ });
166
+
167
+ it("counts nav links correctly", () => {
168
+ expect(navLinksCount(NOISY_PAGE)).toBe(30);
169
+ });
170
+
171
+ it("body + nav accounts for all links", () => {
172
+ expect(bodyLinks(NOISY_PAGE).length + navLinksCount(NOISY_PAGE)).toBe(NOISY_PAGE.links.length);
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // leanOutput
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe("leanOutput", () => {
181
+ const out = leanOutput(NOISY_PAGE);
182
+
183
+ it("includes essential identity fields", () => {
184
+ expect(out.url).toBe(NOISY_PAGE.url);
185
+ expect(out.title).toBe(NOISY_PAGE.title);
186
+ expect(out.wordCount).toBe(NOISY_PAGE.wordCount);
187
+ });
188
+
189
+ it("includes headings as flat markdown strings", () => {
190
+ expect(Array.isArray(out.headings)).toBe(true);
191
+ const headings = out.headings as string[];
192
+ expect(headings[0]).toBe("# How AI Agents Work");
193
+ expect(headings[1]).toBe("## What is an Agent?");
194
+ expect(headings[3]).toBe("### Short-term Memory");
195
+ });
196
+
197
+ it("includes only body links, not the nav flood", () => {
198
+ const bl = out.bodyLinks as Array<{ href: string; text: string }>;
199
+ expect(Array.isArray(bl)).toBe(true);
200
+ expect(bl).toHaveLength(3);
201
+ });
202
+
203
+ it("surfaces nav count instead of nav links", () => {
204
+ expect(out.navLinksCount).toBe(30);
205
+ expect(out).not.toHaveProperty("links");
206
+ });
207
+
208
+ it("omits empty metadata fields", () => {
209
+ expect(out).not.toHaveProperty("description");
210
+ expect(out).not.toHaveProperty("author");
211
+ expect(out).not.toHaveProperty("publishedAt");
212
+ expect(out).not.toHaveProperty("tags");
213
+ });
214
+
215
+ it("omits derivable and non-actionable fields", () => {
216
+ expect(out).not.toHaveProperty("domain");
217
+ expect(out).not.toHaveProperty("readingTimeMinutes");
218
+ expect(out).not.toHaveProperty("fetchedAt");
219
+ expect(out).not.toHaveProperty("lang");
220
+ });
221
+
222
+ it("omits prose body fields", () => {
223
+ expect(out).not.toHaveProperty("markdown");
224
+ expect(out).not.toHaveProperty("chunks");
225
+ });
226
+
227
+ it("omits jsRendered when false", () => {
228
+ expect(out).not.toHaveProperty("jsRendered");
229
+ });
230
+
231
+ it("includes jsRendered when true", () => {
232
+ const jsPage = { ...NOISY_PAGE, jsRendered: true };
233
+ const jsOut = leanOutput(jsPage);
234
+ expect(jsOut.jsRendered).toBe(true);
235
+ });
236
+
237
+ it("is materially smaller than a naive full dump", () => {
238
+ const leanSize = JSON.stringify(out).length;
239
+ // The naive dump includes 30 full link objects, all markdown, all chunks.
240
+ // Lean should be well under half.
241
+ expect(leanSize).toBeLessThan(NAIVE_DUMP.length * 0.5);
242
+ });
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // markdownOutput
247
+ // ---------------------------------------------------------------------------
248
+
249
+ describe("markdownOutput", () => {
250
+ const out = markdownOutput(NOISY_PAGE);
251
+
252
+ it("includes essential fields for reading", () => {
253
+ expect(out.url).toBe(NOISY_PAGE.url);
254
+ expect(out.title).toBe(NOISY_PAGE.title);
255
+ expect(out.wordCount).toBe(NOISY_PAGE.wordCount);
256
+ expect(out.markdown).toBe(NOISY_PAGE.markdown);
257
+ });
258
+
259
+ it("omits headings — they are already in the markdown body", () => {
260
+ expect(out).not.toHaveProperty("headings");
261
+ });
262
+
263
+ it("omits domain — derivable from url", () => {
264
+ expect(out).not.toHaveProperty("domain");
265
+ });
266
+
267
+ it("omits readingTimeMinutes — not actionable", () => {
268
+ expect(out).not.toHaveProperty("readingTimeMinutes");
269
+ });
270
+
271
+ it("omits links — not needed when reading prose", () => {
272
+ expect(out).not.toHaveProperty("links");
273
+ });
274
+
275
+ it("omits internal fields", () => {
276
+ expect(out).not.toHaveProperty("chunks");
277
+ expect(out).not.toHaveProperty("fetchedAt");
278
+ expect(out).not.toHaveProperty("lang");
279
+ });
280
+
281
+ it("omits empty metadata fields", () => {
282
+ expect(out).not.toHaveProperty("description");
283
+ expect(out).not.toHaveProperty("author");
284
+ expect(out).not.toHaveProperty("publishedAt");
285
+ expect(out).not.toHaveProperty("tags");
286
+ });
287
+
288
+ it("includes non-empty metadata fields", () => {
289
+ const richPage = { ...NOISY_PAGE, author: "Jane Smith", description: "A guide to AI agents." };
290
+ const richOut = markdownOutput(richPage);
291
+ expect(richOut.author).toBe("Jane Smith");
292
+ expect(richOut.description).toBe("A guide to AI agents.");
293
+ });
294
+
295
+ it("is materially smaller than a naive full dump", () => {
296
+ const size = JSON.stringify(out).length;
297
+ // Markdown output has the body text but not chunks, links, or redundant metadata.
298
+ expect(size).toBeLessThan(NAIVE_DUMP.length * 0.8);
299
+ });
300
+ });
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // linksOutput
304
+ // ---------------------------------------------------------------------------
305
+
306
+ describe("linksOutput", () => {
307
+ const out = linksOutput(NOISY_PAGE);
308
+
309
+ it("includes url and title", () => {
310
+ expect(out.url).toBe(NOISY_PAGE.url);
311
+ expect(out.title).toBe(NOISY_PAGE.title);
312
+ });
313
+
314
+ it("returns only body links", () => {
315
+ const bl = out.bodyLinks as Array<{ href: string; text: string }>;
316
+ expect(bl).toHaveLength(3);
317
+ });
318
+
319
+ it("surfaces nav count, not nav links", () => {
320
+ expect(out.navLinksCount).toBe(30);
321
+ expect(out).not.toHaveProperty("links");
322
+ });
323
+
324
+ it("omits navLinksCount when there are no nav links", () => {
325
+ const noNavPage = {
326
+ ...NOISY_PAGE,
327
+ links: NOISY_PAGE.links.filter((l) => l.rel === "body"),
328
+ };
329
+ const out2 = linksOutput(noNavPage);
330
+ expect(out2).not.toHaveProperty("navLinksCount");
331
+ });
332
+ });
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // highlightHit
336
+ // ---------------------------------------------------------------------------
337
+
338
+ describe("highlightHit", () => {
339
+ const hit = {
340
+ heading: "Memory and State",
341
+ score: 0.87,
342
+ snippet: "Memory allows agents to retain",
343
+ chunkId: "c1",
344
+ };
345
+
346
+ it("returns the full chunk text, not the snippet", () => {
347
+ const result = highlightHit(hit, NOISY_PAGE.chunks);
348
+ expect(result.text).toBe(NOISY_PAGE.chunks[1]!.text);
349
+ // snippet is a substring of text — including both would be redundant
350
+ expect(result).not.toHaveProperty("snippet");
351
+ });
352
+
353
+ it("falls back to snippet when chunkId is not found", () => {
354
+ const result = highlightHit({ ...hit, chunkId: "missing" }, NOISY_PAGE.chunks);
355
+ expect(result.text).toBe(hit.snippet);
356
+ });
357
+
358
+ it("falls back to snippet when chunkId is absent", () => {
359
+ const { chunkId: _, ...noId } = hit;
360
+ const result = highlightHit(noId as typeof hit, NOISY_PAGE.chunks);
361
+ expect(result.text).toBe(hit.snippet);
362
+ });
363
+
364
+ it("omits heading when empty", () => {
365
+ const result = highlightHit({ ...hit, heading: "" }, NOISY_PAGE.chunks);
366
+ expect(result).not.toHaveProperty("heading");
367
+ });
368
+
369
+ it("includes heading when present", () => {
370
+ const result = highlightHit(hit, NOISY_PAGE.chunks);
371
+ expect(result.heading).toBe("Memory and State");
372
+ });
373
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Live fetch tests — exercise the full execute() path against the real network.
3
+ *
4
+ * Purpose: catch the "Map operation called on non-Map object" class of failure
5
+ * that only surfaces when the extension runs with native ESM (tryNative:true),
6
+ * as pi's Node.js runtime does. The existing paths.test.ts covers jiti
7
+ * tryNative:false with mocked fetch; this file covers the native ESM path with
8
+ * a real HTTP round-trip.
9
+ *
10
+ * Two loader modes are tested to mirror both production code paths:
11
+ *
12
+ * native ESM (direct import) — matches pi's Node.js dev runtime
13
+ * jiti tryNative:false — matches pi's Bun binary + loadExtensionViaJiti
14
+ *
15
+ * Tests are skipped when offline (no LIVE_FETCH env var required — they use
16
+ * a timeout guard instead of an env flag so CI without network just times out
17
+ * gracefully rather than failing loudly).
18
+ */
19
+
20
+ import { describe, it, expect, beforeAll, afterAll } from "vitest"
21
+ import {
22
+ createExtensionHarness,
23
+ loadExtensionViaJiti,
24
+ type ExtensionHarness,
25
+ } from "@earendil-works/pi-coding-agent/testing"
26
+ import { dirname, join } from "node:path"
27
+ import { fileURLToPath } from "node:url"
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url))
30
+ const EXTENSION_PATH = join(__dirname, "../src/index.ts")
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Shared assertion — both loader modes must produce the same shape
34
+ // ---------------------------------------------------------------------------
35
+
36
+ async function assertWikipediaFetch(h: ExtensionHarness) {
37
+ const result = await h.invokeTool("web_fetch", {
38
+ url: "https://en.wikipedia.org/wiki/Web_crawler",
39
+ format: "lean",
40
+ }) as { content: { text: string }[] }
41
+
42
+ expect(result).toHaveProperty("content")
43
+ const text = JSON.parse(result.content[0].text)
44
+
45
+ // Must not be an error
46
+ expect(text).not.toHaveProperty("error")
47
+
48
+ // Must have expected shape
49
+ expect(text).toHaveProperty("url", "https://en.wikipedia.org/wiki/Web_crawler")
50
+ expect(text).toHaveProperty("title")
51
+ expect(typeof text.title).toBe("string")
52
+ expect(text.title.length).toBeGreaterThan(0)
53
+ expect(text).toHaveProperty("wordCount")
54
+ expect(text.wordCount).toBeGreaterThan(100)
55
+ expect(text).toHaveProperty("headings")
56
+ expect(Array.isArray(text.headings)).toBe(true)
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Mode A: native ESM — matches pi's Node.js dev runtime (tryNative:true)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe("live fetch — native ESM (pi Node.js runtime path)", () => {
64
+ let h: ExtensionHarness
65
+
66
+ beforeAll(async () => {
67
+ // Direct import — no jiti. This is how pi loads extensions in Node.js dev
68
+ // mode: the factory is imported natively, then wrapped in createExtensionHarness.
69
+ const { default: factory } = await import("../src/index.js")
70
+ h = createExtensionHarness(factory, { cwd: "/tmp" })
71
+ await h.boot()
72
+ })
73
+
74
+ afterAll(async () => {
75
+ await h.shutdown()
76
+ })
77
+
78
+ it("fetches Wikipedia lean — no Map errors, correct shape", async () => {
79
+ await assertWikipediaFetch(h)
80
+ }, 20_000)
81
+
82
+ it("fetches markdown format without error", async () => {
83
+ const result = await h.invokeTool("web_fetch", {
84
+ url: "https://en.wikipedia.org/wiki/Web_crawler",
85
+ format: "markdown",
86
+ }) as { content: { text: string }[] }
87
+
88
+ const text = JSON.parse(result.content[0].text)
89
+ expect(text).not.toHaveProperty("error")
90
+ expect(text).toHaveProperty("markdown")
91
+ expect(typeof text.markdown).toBe("string")
92
+ expect(text.markdown.length).toBeGreaterThan(100)
93
+ }, 20_000)
94
+
95
+ it("no url — returns cache listing, not error", async () => {
96
+ const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
97
+ const text = JSON.parse(result.content[0].text)
98
+ // After fetching Wikipedia above, the cache has at least 1 entry.
99
+ // Regardless: must return listing shape, not an error.
100
+ expect(text).not.toHaveProperty("error")
101
+ expect(text).toHaveProperty("total")
102
+ expect(typeof text.total).toBe("number")
103
+ }, 5_000)
104
+ })
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Mode B: jiti tryNative:false — matches Bun binary + loadExtensionViaJiti
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe("live fetch — jiti tryNative:false (Bun binary path)", () => {
111
+ let h: ExtensionHarness
112
+
113
+ beforeAll(async () => {
114
+ const factory = await loadExtensionViaJiti(EXTENSION_PATH)
115
+ h = createExtensionHarness(factory, { cwd: "/tmp" })
116
+ await h.boot()
117
+ })
118
+
119
+ afterAll(async () => {
120
+ await h.shutdown()
121
+ })
122
+
123
+ it("fetches Wikipedia lean — no Map errors, correct shape", async () => {
124
+ await assertWikipediaFetch(h)
125
+ }, 20_000)
126
+ })
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Extension load tests — two jiti modes.
3
+ *
4
+ * tryNative:false — Bun binary path. Class constructors from re-exported ESM
5
+ * packages silently become undefined here; the factory uses dynamic import()
6
+ * to avoid this.
7
+ * tryNative:true — Node ESM baseline.
8
+ */
9
+
10
+ import { join, dirname } from "node:path"
11
+ import { fileURLToPath } from "node:url"
12
+ import { describe, expect, it } from "vitest"
13
+ import {
14
+ createExtensionHarness,
15
+ loadExtensionViaJiti,
16
+ } from "@earendil-works/pi-coding-agent/testing"
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const EXTENSION_PATH = join(__dirname, "../src/index.ts")
20
+
21
+ // ── tryNative:false — Bun binary simulation ───────────────────────────────────
22
+
23
+ describe("extension load — tryNative:false (Bun binary simulation)", () => {
24
+ it("loads without throwing", async () => {
25
+ const factory = await loadExtensionViaJiti(EXTENSION_PATH)
26
+ expect(typeof factory).toBe("function")
27
+ })
28
+
29
+ it("registers web_fetch when factory is called", async () => {
30
+ const factory = await loadExtensionViaJiti(EXTENSION_PATH)
31
+ const h = createExtensionHarness(factory, { cwd: "/tmp" })
32
+ await h.boot()
33
+ expect(h.tools.has("web_fetch")).toBe(true)
34
+ await h.shutdown()
35
+ })
36
+
37
+ it("registers exactly one tool", async () => {
38
+ const factory = await loadExtensionViaJiti(EXTENSION_PATH)
39
+ const h = createExtensionHarness(factory, { cwd: "/tmp" })
40
+ await h.boot()
41
+ expect(h.tools.size).toBe(1)
42
+ expect(h.tools.has("web_fetch")).toBe(true)
43
+ await h.shutdown()
44
+ })
45
+ })
46
+
47
+ // ── tryNative:true — Node ESM baseline ───────────────────────────────────────
48
+
49
+ describe("extension load — tryNative:true (Node ESM baseline)", () => {
50
+ it("registers web_fetch", async () => {
51
+ const { default: factory } = await import("../src/index.js")
52
+ const h = createExtensionHarness(factory, { cwd: "/tmp" })
53
+ await h.boot()
54
+ expect(h.tools.has("web_fetch")).toBe(true)
55
+ await h.shutdown()
56
+ })
57
+
58
+ it("execute() returns valid result on first call — no crash at the Map boundary", async () => {
59
+ // The Map realm bug only surfaces on the first execute() call, not at
60
+ // construction time. The e2e-jiti tests cover the production jiti context;
61
+ // this guards the simpler failure of execute() crashing at all.
62
+ const { default: factory } = await import("../src/index.js")
63
+ const h = createExtensionHarness(factory, { cwd: "/tmp" })
64
+ await h.boot()
65
+
66
+ const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
67
+ expect(result).toHaveProperty("content")
68
+ const text = JSON.parse(result.content[0].text)
69
+ expect(text).not.toHaveProperty("error")
70
+ expect(text).toHaveProperty("total")
71
+ expect(typeof text.total).toBe("number")
72
+
73
+ await h.shutdown()
74
+ })
75
+ })