@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,423 @@
1
+ /**
2
+ * Extension path tests — ablation coverage for each execute() branch.
3
+ *
4
+ * Tests the search, crawl, and single-page paths by:
5
+ * 1. Loading the extension via jiti (same mode as pi loads it)
6
+ * 2. Capturing the registered execute() function
7
+ * 3. Mocking globalThis.fetch to serve fixture HTML without network access
8
+ *
9
+ * Each describe block loads a fresh extension factory to avoid session-state
10
+ * leakage between suites (cache, graph, corpus are per-factory).
11
+ */
12
+
13
+ import { createRequire } from "node:module"
14
+ import { dirname, join } from "node:path"
15
+ import { fileURLToPath } from "node:url"
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url))
19
+ const EXTENSION_PATH = join(__dirname, "../src/index.ts")
20
+
21
+ const require = createRequire(import.meta.url)
22
+ const jitiPath = require.resolve("jiti")
23
+ const JITI_BASE = `file://${join(__dirname, "../src/index.ts")}`
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Fixtures
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const MOCK_URL = "https://test.example.com/article"
30
+
31
+ const FIXTURE_HTML = `<!DOCTYPE html>
32
+ <html lang="en">
33
+ <head>
34
+ <title>Spider Test Page</title>
35
+ <meta name="description" content="A fixture page for path tests">
36
+ </head>
37
+ <body>
38
+ <article>
39
+ <h1>Spider Test Article</h1>
40
+ <h2>Section One</h2>
41
+ <p>This fixture page is used to test the web spider extension paths.
42
+ It contains enough prose for Readability to extract meaningful content,
43
+ including headings, links, and multiple paragraphs of body text.</p>
44
+ <h2>Section Two</h2>
45
+ <p>The cost optimization strategies described here are illustrative.
46
+ OpenAI API calls can be expensive; caching and chunking help.</p>
47
+ <a href="https://example.com/related">Related article</a>
48
+ <a href="https://example.com/other">Another link</a>
49
+ </article>
50
+ </body>
51
+ </html>`
52
+
53
+ const BRAVE_RESPONSE = JSON.stringify({
54
+ web: {
55
+ results: [
56
+ { url: "https://example.com/a", title: "Result A", description: "Snippet A" },
57
+ { url: "https://example.com/b", title: "Result B", description: "Snippet B" },
58
+ ],
59
+ },
60
+ })
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ type ExecuteFn = (id: string, params: Record<string, unknown>) => Promise<{ content: { text: string }[]; details: Record<string, unknown> }>
67
+
68
+ function mockResponse(body: string, status = 200): Response {
69
+ return {
70
+ ok: status < 400,
71
+ status,
72
+ statusText: status === 200 ? "OK" : "Not Found",
73
+ headers: { get: () => null },
74
+ text: async () => body,
75
+ json: async () => JSON.parse(body),
76
+ } as unknown as Response
77
+ }
78
+
79
+ /** Mock fetch that handles all the URLs spider/crawl may contact. */
80
+ function makeFetchMock(pageHtml = FIXTURE_HTML) {
81
+ return vi.fn(async (input: RequestInfo | URL) => {
82
+ const url = input.toString()
83
+ if (url.includes("robots.txt")) return mockResponse("User-agent: *\nAllow: /")
84
+ if (url.includes("sitemap")) return mockResponse("", 404)
85
+ if (url.includes("brave.com")) return mockResponse(BRAVE_RESPONSE)
86
+ if (url.startsWith(MOCK_URL)) return mockResponse(pageHtml)
87
+ return mockResponse("", 404)
88
+ })
89
+ }
90
+
91
+ /** Load a fresh extension and return the execute() for a specific tool name. */
92
+ async function loadExecute(toolName = "web_fetch"): Promise<ExecuteFn> {
93
+ const { createJiti: cj } = await import(jitiPath)
94
+ const jiti = cj(JITI_BASE, { moduleCache: false, tryNative: false })
95
+ const factory = await jiti.import(EXTENSION_PATH, { default: true }) as (api: unknown) => Promise<void>
96
+
97
+ const tools = new Map<string, ExecuteFn>()
98
+ const api = {
99
+ registerTool: vi.fn((tool: { name: string; execute: ExecuteFn }) => {
100
+ tools.set(tool.name, tool.execute)
101
+ }),
102
+ on: vi.fn(), registerCommand: vi.fn(), registerShortcut: vi.fn(),
103
+ registerFlag: vi.fn(), appendEntry: vi.fn(),
104
+ }
105
+
106
+ process.env["WEB_SPIDER_CACHE_PATH"] = "/tmp/web-spider-test-cache.json"
107
+ await factory(api)
108
+ delete process.env["WEB_SPIDER_CACHE_PATH"]
109
+
110
+ const fn = tools.get(toolName)
111
+ if (!fn) throw new Error(`Tool '${toolName}' not registered — got: ${[...tools.keys()].join(", ")}`)
112
+ return fn
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Single-page path — format=markdown (default)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ describe("single-page path — markdown", () => {
120
+ let execute: ExecuteFn
121
+ let fetchSpy: ReturnType<typeof vi.spyOn>
122
+
123
+ beforeEach(async () => {
124
+ execute = await loadExecute()
125
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
126
+ })
127
+ afterEach(() => fetchSpy.mockRestore())
128
+
129
+ it("returns url, title, wordCount, markdown fields", async () => {
130
+ const result = await execute("1", { url: MOCK_URL })
131
+ const body = JSON.parse(result.content[0].text)
132
+ expect(body.url).toBe(MOCK_URL)
133
+ expect(typeof body.title).toBe("string")
134
+ expect(body.title.length).toBeGreaterThan(0)
135
+ expect(typeof body.markdown).toBe("string")
136
+ expect(body.markdown.length).toBeGreaterThan(0)
137
+ expect(typeof body.wordCount).toBe("number")
138
+ })
139
+
140
+ it("details includes format and wordCount", async () => {
141
+ const result = await execute("1", { url: MOCK_URL })
142
+ expect(result.details.format).toBe("markdown")
143
+ expect(typeof result.details.wordCount).toBe("number")
144
+ })
145
+
146
+ it("does not include chunks or links in output", async () => {
147
+ const result = await execute("1", { url: MOCK_URL })
148
+ const body = JSON.parse(result.content[0].text)
149
+ expect(body.chunks).toBeUndefined()
150
+ expect(body.links).toBeUndefined()
151
+ })
152
+ })
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Single-page path — format=lean
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe("single-page path — lean", () => {
159
+ let execute: ExecuteFn
160
+ let fetchSpy: ReturnType<typeof vi.spyOn>
161
+
162
+ beforeEach(async () => {
163
+ execute = await loadExecute()
164
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
165
+ })
166
+ afterEach(() => fetchSpy.mockRestore())
167
+
168
+ it("returns url, title, headings, bodyLinks — no markdown", async () => {
169
+ const result = await execute("1", { url: MOCK_URL, format: "lean" })
170
+ const body = JSON.parse(result.content[0].text)
171
+ expect(body.url).toBe(MOCK_URL)
172
+ expect(Array.isArray(body.headings)).toBe(true)
173
+ expect(body.headings.length).toBeGreaterThan(0)
174
+ expect(body.markdown).toBeUndefined()
175
+ })
176
+
177
+ it("headings are flat markdown strings", async () => {
178
+ const result = await execute("1", { url: MOCK_URL, format: "lean" })
179
+ const body = JSON.parse(result.content[0].text)
180
+ for (const h of body.headings) {
181
+ expect(h).toMatch(/^#{1,6} /)
182
+ }
183
+ })
184
+
185
+ it("details includes format=lean", async () => {
186
+ const result = await execute("1", { url: MOCK_URL, format: "lean" })
187
+ expect(result.details.format).toBe("lean")
188
+ })
189
+ })
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Single-page path — format=links
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe("single-page path — links", () => {
196
+ let execute: ExecuteFn
197
+ let fetchSpy: ReturnType<typeof vi.spyOn>
198
+
199
+ beforeEach(async () => {
200
+ execute = await loadExecute()
201
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
202
+ })
203
+ afterEach(() => fetchSpy.mockRestore())
204
+
205
+ it("returns url, title, bodyLinks — no markdown", async () => {
206
+ const result = await execute("1", { url: MOCK_URL, format: "links" })
207
+ const body = JSON.parse(result.content[0].text)
208
+ expect(body.url).toBe(MOCK_URL)
209
+ expect(Array.isArray(body.bodyLinks)).toBe(true)
210
+ expect(body.markdown).toBeUndefined()
211
+ })
212
+
213
+ it("bodyLinks have href and text", async () => {
214
+ const result = await execute("1", { url: MOCK_URL, format: "links" })
215
+ const body = JSON.parse(result.content[0].text)
216
+ for (const l of body.bodyLinks) {
217
+ expect(typeof l.href).toBe("string")
218
+ expect(typeof l.text).toBe("string")
219
+ }
220
+ })
221
+ })
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Single-page path — format=highlights
225
+ // ---------------------------------------------------------------------------
226
+
227
+ describe("single-page path — highlights", () => {
228
+ let execute: ExecuteFn
229
+ let fetchSpy: ReturnType<typeof vi.spyOn>
230
+
231
+ beforeEach(async () => {
232
+ execute = await loadExecute()
233
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
234
+ })
235
+ afterEach(() => fetchSpy.mockRestore())
236
+
237
+ it("returns error when query is missing", async () => {
238
+ const result = await execute("1", { url: MOCK_URL, format: "highlights" })
239
+ const body = JSON.parse(result.content[0].text)
240
+ expect(body.error).toBeDefined()
241
+ })
242
+
243
+ it("returns hits array when query is provided", async () => {
244
+ const result = await execute("1", { url: MOCK_URL, format: "highlights", query: "cost optimization" })
245
+ const body = JSON.parse(result.content[0].text)
246
+ expect(body.url).toBe(MOCK_URL)
247
+ expect(Array.isArray(body.hits)).toBe(true)
248
+ })
249
+
250
+ it("details includes format=highlights and hit count", async () => {
251
+ const result = await execute("1", { url: MOCK_URL, format: "highlights", query: "spider" })
252
+ expect(result.details.format).toBe("highlights")
253
+ expect(typeof result.details.hits).toBe("number")
254
+ })
255
+ })
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Crawl path (depth > 0)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe("crawl path — depth=1", () => {
262
+ let execute: ExecuteFn
263
+ let fetchSpy: ReturnType<typeof vi.spyOn>
264
+
265
+ beforeEach(async () => {
266
+ execute = await loadExecute()
267
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
268
+ })
269
+ afterEach(() => fetchSpy.mockRestore())
270
+
271
+ it("returns pagesFound and pages array", async () => {
272
+ const result = await execute("1", { url: MOCK_URL, depth: 1, maxPages: 3 })
273
+ const body = JSON.parse(result.content[0].text)
274
+ expect(typeof body.pagesFound).toBe("number")
275
+ expect(body.pagesFound).toBeGreaterThanOrEqual(1)
276
+ expect(Array.isArray(body.pages)).toBe(true)
277
+ })
278
+
279
+ it("details includes depth and pagesFound", async () => {
280
+ const result = await execute("1", { url: MOCK_URL, depth: 1, maxPages: 2 })
281
+ expect(result.details.depth).toBe(1)
282
+ expect(typeof result.details.pagesFound).toBe("number")
283
+ })
284
+
285
+ it("format=lean returns leanOutput per page", async () => {
286
+ const result = await execute("1", { url: MOCK_URL, depth: 1, maxPages: 2, format: "lean" })
287
+ const body = JSON.parse(result.content[0].text)
288
+ expect(Array.isArray(body.pages)).toBe(true)
289
+ // Each page in lean crawl has headings and url
290
+ for (const page of body.pages) {
291
+ expect(typeof page.url).toBe("string")
292
+ expect(typeof page.wordCount).toBe("number")
293
+ }
294
+ })
295
+
296
+ it("highlights format without query returns error", async () => {
297
+ const result = await execute("1", { url: MOCK_URL, depth: 1, format: "highlights" })
298
+ const body = JSON.parse(result.content[0].text)
299
+ expect(body.error).toBeDefined()
300
+ })
301
+
302
+ it("highlights format with query returns hits", async () => {
303
+ const result = await execute("1", { url: MOCK_URL, depth: 1, format: "highlights", query: "spider fixture" })
304
+ const body = JSON.parse(result.content[0].text)
305
+ expect(Array.isArray(body.hits)).toBe(true)
306
+ expect(typeof body.pagesSearched).toBe("number")
307
+ })
308
+ })
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // format=tree paths — full tree, query, navigate — all via web_fetch
312
+ // ---------------------------------------------------------------------------
313
+
314
+ describe("single-page path — tree (full)", () => {
315
+ let execute: ExecuteFn
316
+ let fetchSpy: ReturnType<typeof vi.spyOn>
317
+
318
+ beforeEach(async () => {
319
+ execute = await loadExecute("web_fetch")
320
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
321
+ })
322
+ afterEach(() => fetchSpy.mockRestore())
323
+
324
+ it("returns a tree with tag=article at root", async () => {
325
+ const result = await execute("1", { url: MOCK_URL, format: "tree" })
326
+ const tree = JSON.parse(result.content[0].text)
327
+ expect(tree.tag).toBe("article")
328
+ expect(tree.path).toBe("article")
329
+ })
330
+
331
+ it("tree has children", async () => {
332
+ const result = await execute("1", { url: MOCK_URL, format: "tree" })
333
+ const tree = JSON.parse(result.content[0].text)
334
+ expect(Array.isArray(tree.children)).toBe(true)
335
+ expect(tree.children.length).toBeGreaterThan(0)
336
+ })
337
+
338
+ it("details includes format=tree mode=full", async () => {
339
+ const result = await execute("1", { url: MOCK_URL, format: "tree" })
340
+ expect(result.details.format).toBe("tree")
341
+ expect(result.details.mode).toBe("full")
342
+ })
343
+
344
+ it("returns error for network failure", async () => {
345
+ fetchSpy.mockImplementation(async () => { throw new Error("ECONNREFUSED") })
346
+ const result = await execute("1", { url: MOCK_URL, format: "tree" })
347
+ const body = JSON.parse(result.content[0].text)
348
+ expect(body.error).toBeDefined()
349
+ })
350
+ })
351
+
352
+ describe("single-page path — tree + query", () => {
353
+ let execute: ExecuteFn
354
+ let fetchSpy: ReturnType<typeof vi.spyOn>
355
+
356
+ beforeEach(async () => {
357
+ execute = await loadExecute("web_fetch")
358
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
359
+ })
360
+ afterEach(() => fetchSpy.mockRestore())
361
+
362
+ it("returns hits array with url and query", async () => {
363
+ const result = await execute("1", { url: MOCK_URL, format: "tree", query: "spider fixture" })
364
+ const body = JSON.parse(result.content[0].text)
365
+ expect(Array.isArray(body.hits)).toBe(true)
366
+ expect(body.url).toBe(MOCK_URL)
367
+ expect(body.query).toBe("spider fixture")
368
+ })
369
+
370
+ it("each hit has path, tag, score, snippet", async () => {
371
+ const result = await execute("1", { url: MOCK_URL, format: "tree", query: "section" })
372
+ const body = JSON.parse(result.content[0].text)
373
+ for (const hit of body.hits) {
374
+ expect(typeof hit.path).toBe("string")
375
+ expect(typeof hit.tag).toBe("string")
376
+ expect(typeof hit.score).toBe("number")
377
+ expect(typeof hit.snippet).toBe("string")
378
+ }
379
+ })
380
+
381
+ it("respects topN", async () => {
382
+ const result = await execute("1", { url: MOCK_URL, format: "tree", query: "the", topN: 2 })
383
+ const body = JSON.parse(result.content[0].text)
384
+ expect(body.hits.length).toBeLessThanOrEqual(2)
385
+ })
386
+
387
+ it("details includes mode=query and hit count", async () => {
388
+ const result = await execute("1", { url: MOCK_URL, format: "tree", query: "spider" })
389
+ expect(result.details.mode).toBe("query")
390
+ expect(typeof result.details.hits).toBe("number")
391
+ })
392
+ })
393
+
394
+ describe("single-page path — tree + path (navigate)", () => {
395
+ let execute: ExecuteFn
396
+ let fetchSpy: ReturnType<typeof vi.spyOn>
397
+
398
+ beforeEach(async () => {
399
+ execute = await loadExecute("web_fetch")
400
+ fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
401
+ })
402
+ afterEach(() => fetchSpy.mockRestore())
403
+
404
+ it("returns error for unknown path", async () => {
405
+ const result = await execute("1", { url: MOCK_URL, format: "tree", path: "article.nonexistent[99]" })
406
+ const body = JSON.parse(result.content[0].text)
407
+ expect(body.error).toBeDefined()
408
+ })
409
+
410
+ it("returns article root node for path=article", async () => {
411
+ const result = await execute("1", { url: MOCK_URL, format: "tree", path: "article" })
412
+ const body = JSON.parse(result.content[0].text)
413
+ expect(body.tag).toBe("article")
414
+ expect(body.path).toBe("article")
415
+ })
416
+
417
+ it("details includes mode=navigate, tag, path", async () => {
418
+ const result = await execute("1", { url: MOCK_URL, format: "tree", path: "article" })
419
+ expect(result.details.mode).toBe("navigate")
420
+ expect(result.details.tag).toBe("article")
421
+ expect(result.details.path).toBe("article")
422
+ })
423
+ })
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Playwright fallback path — error propagation and happy-path coverage.
3
+ *
4
+ * The extension has two Playwright entry points:
5
+ *
6
+ * 1. Auto-fallback — triggered when spider() detects jsRendered:true and
7
+ * params.enhanced is false. The extension retries with
8
+ * a PlaywrightHttpClient transparently.
9
+ *
10
+ * 2. enhanced=true — caller explicitly opts into the headless browser for
11
+ * the first (and only) fetch attempt.
12
+ *
13
+ * Both paths must:
14
+ * - Return a well-formed page on success.
15
+ * - Return { error: string } when the Playwright client throws — never crash,
16
+ * never leak the raw exception type.
17
+ *
18
+ * We use vi.mock + importActual to replace only PlaywrightHttpClient while
19
+ * keeping the real spider(), crawl(), etc. alive. The mock is a controllable
20
+ * stub whose fetch() behaviour is set per-test via a shared ref.
21
+ *
22
+ * Fixture: fixtures/gh-shell.html — a GitHub-like minimal app shell.
23
+ * Readability finds no article content → jsRendered:true.
24
+ * The real article HTML from fixtures/article-with-images.html is returned
25
+ * by the stub Playwright client to simulate a successful JS-rendered fetch.
26
+ */
27
+
28
+ import { readFileSync } from "node:fs"
29
+ import { dirname, join } from "node:path"
30
+ import { fileURLToPath } from "node:url"
31
+ import {
32
+ afterEach,
33
+ beforeEach,
34
+ describe,
35
+ expect,
36
+ it,
37
+ vi,
38
+ } from "vitest"
39
+ import {
40
+ createExtensionHarness,
41
+ type ExtensionHarness,
42
+ } from "@earendil-works/pi-coding-agent/testing"
43
+ import type { HttpRequest, HttpResponse } from "@danypops/web-spider"
44
+
45
+ const __dirname = dirname(fileURLToPath(import.meta.url))
46
+ const FIXTURES = join(__dirname, "../../web-spider/fixtures")
47
+
48
+ const GH_SHELL_HTML = readFileSync(join(FIXTURES, "gh-shell.html"), "utf8")
49
+ const ARTICLE_HTML = readFileSync(join(FIXTURES, "article-with-images.html"), "utf8")
50
+
51
+ const MOCK_URL = "https://github.com/hyprwm/aquamarine/issues"
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Controllable Playwright stub
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Shared ref so each test can swap the Playwright behaviour without
59
+ * reloading the module mock.
60
+ */
61
+ let playwrightFetchImpl: (req: HttpRequest) => Promise<HttpResponse> = async () => {
62
+ throw new Error("playwrightFetchImpl not set — call setPlaywrightBehaviour() in each test")
63
+ }
64
+
65
+ function makeOkResponse(html: string): HttpResponse {
66
+ return {
67
+ ok: true,
68
+ status: 200,
69
+ statusText: "OK",
70
+ headers: { get: () => null },
71
+ text: async () => html,
72
+ arrayBuffer: async () => new ArrayBuffer(0),
73
+ }
74
+ }
75
+
76
+ // Replace PlaywrightHttpClient with a stub whose fetch() delegates to the
77
+ // per-test ref. All other exports are real (importActual).
78
+ vi.mock("@danypops/web-spider", async (importActual) => {
79
+ const real = await importActual<typeof import("@danypops/web-spider")>()
80
+ class StubPlaywrightHttpClient {
81
+ async fetch(req: HttpRequest): Promise<HttpResponse> {
82
+ return playwrightFetchImpl(req)
83
+ }
84
+ async close() {}
85
+ }
86
+ return { ...real, PlaywrightHttpClient: StubPlaywrightHttpClient }
87
+ })
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Global fetch mock — returns the gh-shell for the target URL, robots + 404
91
+ // for everything else.
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function installFetchMock(pageHtml = GH_SHELL_HTML) {
95
+ vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
96
+ const url = input.toString()
97
+ if (url.includes("robots.txt")) return makeOkResponse("User-agent: *\nAllow: /")
98
+ if (url.includes("sitemap")) return makeOkResponse("") // triggers 200 → empty
99
+ if (url.startsWith(MOCK_URL)) return makeOkResponse(pageHtml)
100
+ return makeOkResponse("")
101
+ }))
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Harness
106
+ // ---------------------------------------------------------------------------
107
+
108
+ let h: ExtensionHarness
109
+
110
+ beforeEach(async () => {
111
+ const { default: factory } = await import("../src/index.js")
112
+ // Unique path per test — prevents cache hits from a previous test's successful
113
+ // fetch from bypassing Playwright in subsequent error-propagation tests.
114
+ // Without isolation the first test caches the page, later tests return it
115
+ // immediately from DiskCache.load(), and Playwright is never invoked.
116
+ const cachePath = `/tmp/ws-playwright-fallback-${Date.now()}-${Math.random().toString(36).slice(2)}.json`
117
+ h = createExtensionHarness(factory, {
118
+ cwd: "/tmp",
119
+ env: { WEB_SPIDER_CACHE_PATH: cachePath },
120
+ })
121
+ await h.boot()
122
+ })
123
+
124
+ afterEach(async () => {
125
+ await h.shutdown()
126
+ vi.restoreAllMocks()
127
+ })
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Auto-fallback — jsRendered:true → retry with Playwright
131
+ // ---------------------------------------------------------------------------
132
+
133
+ describe("auto-fallback: jsRendered:true → Playwright retry", () => {
134
+ it("returns article content when Playwright succeeds", async () => {
135
+ installFetchMock(GH_SHELL_HTML)
136
+ playwrightFetchImpl = async () => makeOkResponse(ARTICLE_HTML)
137
+
138
+ const result = await h.invokeTool("web_fetch", {
139
+ url: MOCK_URL,
140
+ format: "lean",
141
+ }) as { content: { text: string }[] }
142
+
143
+ const text = JSON.parse(result.content[0].text)
144
+ expect(text).not.toHaveProperty("error")
145
+ expect(text).toHaveProperty("title")
146
+ expect(text.title.length).toBeGreaterThan(0)
147
+ expect(text).toHaveProperty("wordCount")
148
+ expect(text.wordCount).toBeGreaterThan(0)
149
+ })
150
+
151
+ it("propagates error cleanly when Playwright throws a generic error", async () => {
152
+ installFetchMock(GH_SHELL_HTML)
153
+ playwrightFetchImpl = async () => { throw new Error("Browser closed unexpectedly") }
154
+
155
+ const result = await h.invokeTool("web_fetch", { url: MOCK_URL }) as { content: { text: string }[] }
156
+ const text = JSON.parse(result.content[0].text)
157
+
158
+ expect(text).toHaveProperty("error")
159
+ expect(typeof text.error).toBe("string")
160
+ expect(text.error).toContain("Browser closed unexpectedly")
161
+ })
162
+
163
+ it("propagates error cleanly when Playwright throws 'Map operation called on non-Map object'", async () => {
164
+ installFetchMock(GH_SHELL_HTML)
165
+ playwrightFetchImpl = async () => { throw new TypeError("Map operation called on non-Map object") }
166
+
167
+ const result = await h.invokeTool("web_fetch", { url: MOCK_URL }) as { content: { text: string }[] }
168
+ const text = JSON.parse(result.content[0].text)
169
+
170
+ expect(text).toHaveProperty("error")
171
+ expect(typeof text.error).toBe("string")
172
+ expect(text.error).toContain("Map operation called on non-Map object")
173
+ })
174
+
175
+ it("propagates error cleanly when Playwright throws a timeout", async () => {
176
+ installFetchMock(GH_SHELL_HTML)
177
+ playwrightFetchImpl = async () => { throw new Error("Timeout 30000ms exceeded.") }
178
+
179
+ const result = await h.invokeTool("web_fetch", { url: MOCK_URL }) as { content: { text: string }[] }
180
+ const text = JSON.parse(result.content[0].text)
181
+
182
+ expect(text).toHaveProperty("error")
183
+ expect(text.error).toContain("Timeout")
184
+ })
185
+
186
+ it("propagates error cleanly when Playwright throws a non-Error value", async () => {
187
+ installFetchMock(GH_SHELL_HTML)
188
+ playwrightFetchImpl = async () => { throw "chromium launch failed" }
189
+
190
+ const result = await h.invokeTool("web_fetch", { url: MOCK_URL }) as { content: { text: string }[] }
191
+ const text = JSON.parse(result.content[0].text)
192
+
193
+ expect(text).toHaveProperty("error")
194
+ expect(typeof text.error).toBe("string")
195
+ })
196
+
197
+ it("does not call Playwright when direct fetch returns readable content", async () => {
198
+ // ARTICLE_HTML has enough content for Readability → jsRendered stays false
199
+ installFetchMock(ARTICLE_HTML)
200
+ playwrightFetchImpl = async () => {
201
+ throw new Error("Playwright should NOT have been called for a readable page")
202
+ }
203
+
204
+ const result = await h.invokeTool("web_fetch", {
205
+ url: MOCK_URL,
206
+ format: "lean",
207
+ }) as { content: { text: string }[] }
208
+
209
+ const text = JSON.parse(result.content[0].text)
210
+ expect(text).not.toHaveProperty("error")
211
+ expect(text.wordCount).toBeGreaterThan(0)
212
+ })
213
+ })
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // enhanced=true — Playwright is used for the first fetch, no fallback needed
217
+ // ---------------------------------------------------------------------------
218
+
219
+ describe("enhanced=true: Playwright used for initial fetch", () => {
220
+ it("returns content when Playwright succeeds", async () => {
221
+ installFetchMock() // fetch mock won't be called for the main page
222
+ playwrightFetchImpl = async () => makeOkResponse(ARTICLE_HTML)
223
+
224
+ const result = await h.invokeTool("web_fetch", {
225
+ url: MOCK_URL,
226
+ format: "lean",
227
+ enhanced: true,
228
+ }) as { content: { text: string }[] }
229
+
230
+ const text = JSON.parse(result.content[0].text)
231
+ expect(text).not.toHaveProperty("error")
232
+ expect(text.wordCount).toBeGreaterThan(0)
233
+ })
234
+
235
+ it("returns { error } when Playwright throws — does not crash", async () => {
236
+ installFetchMock()
237
+ playwrightFetchImpl = async () => { throw new Error("executable doesn't exist at /nonexistent") }
238
+
239
+ const result = await h.invokeTool("web_fetch", {
240
+ url: MOCK_URL,
241
+ enhanced: true,
242
+ }) as { content: { text: string }[] }
243
+
244
+ const text = JSON.parse(result.content[0].text)
245
+ expect(text).toHaveProperty("error")
246
+ expect(typeof text.error).toBe("string")
247
+ })
248
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "noEmit": true
6
+ },
7
+ "include": ["src/**/*"]
8
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config"
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["test/**/*.test.ts"],
6
+ },
7
+ })