@danypops/pi-web-spider 0.10.4 → 0.11.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/test/load.test.ts CHANGED
@@ -9,15 +9,24 @@
9
9
 
10
10
  import { join, dirname } from "node:path"
11
11
  import { fileURLToPath } from "node:url"
12
- import { describe, expect, it } from "vitest"
12
+ import { afterEach, describe, expect, it } from "vitest"
13
13
  import {
14
14
  createExtensionHarness,
15
15
  loadExtensionViaJiti,
16
- } from "@earendil-works/pi-coding-agent/testing"
16
+ } from "./harness/index.ts"
17
+ import { isolatedDaemonEnv, type IsolatedDaemonEnv } from "./daemon-isolation.js"
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url))
19
20
  const EXTENSION_PATH = join(__dirname, "../src/index.ts")
20
21
 
22
+ // Any test that calls h.invokeTool() can reach getClient(), which
23
+ // auto-starts a real daemon using ambient XDG paths unless isolated.
24
+ let isolated: IsolatedDaemonEnv | undefined
25
+ afterEach(() => {
26
+ isolated?.cleanup()
27
+ isolated = undefined
28
+ })
29
+
21
30
  // ── tryNative:false — Bun binary simulation ───────────────────────────────────
22
31
 
23
32
  describe("extension load — tryNative:false (Bun binary simulation)", () => {
@@ -59,8 +68,9 @@ describe("extension load — tryNative:true (Node ESM baseline)", () => {
59
68
  // The Map realm bug only surfaces on the first execute() call, not at
60
69
  // construction time. The e2e-jiti tests cover the production jiti context;
61
70
  // this guards the simpler failure of execute() crashing at all.
71
+ isolated = isolatedDaemonEnv("pi-web-spider-load-test-")
62
72
  const { default: factory } = await import("../src/index.js")
63
- const h = createExtensionHarness(factory, { cwd: "/tmp" })
73
+ const h = createExtensionHarness(factory, { cwd: "/tmp", env: isolated.env })
64
74
  await h.boot()
65
75
 
66
76
  const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
@@ -1,19 +1,23 @@
1
1
  /**
2
2
  * Extension path tests — ablation coverage for each execute() branch.
3
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
4
+ * Tests the crawl and single-page paths against a real, isolated Web Spider
5
+ * daemon (auto-started by the extension itself, see daemon-isolation.ts)
6
+ * fetching a real local fixture HTTP server — not a mocked globalThis.fetch.
7
+ * That approach stopped working once fetching moved into the daemon's own
8
+ * (separate) process, which never sees this process's mocked fetch.
8
9
  *
9
- * Each describe block loads a fresh extension factory to avoid session-state
10
- * leakage between suites (cache, graph, corpus are per-factory).
10
+ * Each describe block loads a fresh extension factory (fresh in-memory tool
11
+ * registration) but all share one isolated daemon + fixture server for the
12
+ * file, matching the previous behavior of one shared on-disk cache file.
11
13
  */
12
14
 
13
15
  import { createRequire } from "node:module"
14
16
  import { dirname, join } from "node:path"
15
17
  import { fileURLToPath } from "node:url"
16
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
18
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
19
+ import { isolatedDaemonEnv, type IsolatedDaemonEnv } from "./daemon-isolation.js"
20
+ import { startFixtureServer, type FixtureServer } from "./helpers/fixture-server.js"
17
21
 
18
22
  const __dirname = dirname(fileURLToPath(import.meta.url))
19
23
  const EXTENSION_PATH = join(__dirname, "../src/index.ts")
@@ -26,8 +30,6 @@ const JITI_BASE = `file://${join(__dirname, "../src/index.ts")}`
26
30
  // Fixtures
27
31
  // ---------------------------------------------------------------------------
28
32
 
29
- const MOCK_URL = "https://test.example.com/article"
30
-
31
33
  const FIXTURE_HTML = `<!DOCTYPE html>
32
34
  <html lang="en">
33
35
  <head>
@@ -44,19 +46,28 @@ const FIXTURE_HTML = `<!DOCTYPE html>
44
46
  <h2>Section Two</h2>
45
47
  <p>The cost optimization strategies described here are illustrative.
46
48
  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
+ <a href="/related">Related article</a>
50
+ <a href="/other">Another link</a>
49
51
  </article>
50
52
  </body>
51
53
  </html>`
52
54
 
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
- },
55
+ let isolated: IsolatedDaemonEnv
56
+ let server: FixtureServer
57
+ let MOCK_URL: string
58
+
59
+ beforeAll(async () => {
60
+ isolated = isolatedDaemonEnv("pi-web-spider-paths-test-")
61
+ server = await startFixtureServer()
62
+ server.set("/article", FIXTURE_HTML)
63
+ server.set("/related", "<html><body><article><h1>Related</h1><p>Related page body text, long enough for Readability to extract as an article rather than treating it as empty content.</p></article></body></html>")
64
+ server.set("/other", "<html><body><article><h1>Other</h1><p>Other page body text, also long enough for Readability to extract as an article rather than treating it as empty content.</p></article></body></html>")
65
+ MOCK_URL = `${server.baseUrl}/article`
66
+ })
67
+
68
+ afterAll(async () => {
69
+ await server.close()
70
+ isolated.cleanup()
60
71
  })
61
72
 
62
73
  // ---------------------------------------------------------------------------
@@ -65,29 +76,6 @@ const BRAVE_RESPONSE = JSON.stringify({
65
76
 
66
77
  type ExecuteFn = (id: string, params: Record<string, unknown>) => Promise<{ content: { text: string }[]; details: Record<string, unknown> }>
67
78
 
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
79
  /** Load a fresh extension and return the execute() for a specific tool name. */
92
80
  async function loadExecute(toolName = "web_fetch"): Promise<ExecuteFn> {
93
81
  const { createJiti: cj } = await import(jitiPath)
@@ -103,9 +91,10 @@ async function loadExecute(toolName = "web_fetch"): Promise<ExecuteFn> {
103
91
  registerFlag: vi.fn(), appendEntry: vi.fn(),
104
92
  }
105
93
 
106
- process.env["WEB_SPIDER_CACHE_PATH"] = "/tmp/web-spider-test-cache.json"
94
+ // Isolated for the whole file's duration (set in beforeAll) — the daemon
95
+ // connection is lazy (first execute() call), so the env must still be set
96
+ // when execute() runs, not just during this factory() call.
107
97
  await factory(api)
108
- delete process.env["WEB_SPIDER_CACHE_PATH"]
109
98
 
110
99
  const fn = tools.get(toolName)
111
100
  if (!fn) throw new Error(`Tool '${toolName}' not registered — got: ${[...tools.keys()].join(", ")}`)
@@ -118,13 +107,11 @@ async function loadExecute(toolName = "web_fetch"): Promise<ExecuteFn> {
118
107
 
119
108
  describe("single-page path — markdown", () => {
120
109
  let execute: ExecuteFn
121
- let fetchSpy: ReturnType<typeof vi.spyOn>
122
110
 
123
111
  beforeEach(async () => {
112
+ Object.assign(process.env, isolated.env)
124
113
  execute = await loadExecute()
125
- fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
126
114
  })
127
- afterEach(() => fetchSpy.mockRestore())
128
115
 
129
116
  it("returns url, title, wordCount, markdown fields", async () => {
130
117
  const result = await execute("1", { url: MOCK_URL })
@@ -157,13 +144,11 @@ describe("single-page path — markdown", () => {
157
144
 
158
145
  describe("single-page path — lean", () => {
159
146
  let execute: ExecuteFn
160
- let fetchSpy: ReturnType<typeof vi.spyOn>
161
147
 
162
148
  beforeEach(async () => {
149
+ Object.assign(process.env, isolated.env)
163
150
  execute = await loadExecute()
164
- fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
165
151
  })
166
- afterEach(() => fetchSpy.mockRestore())
167
152
 
168
153
  it("returns url, title, headings, bodyLinks — no markdown", async () => {
169
154
  const result = await execute("1", { url: MOCK_URL, format: "lean" })
@@ -194,13 +179,11 @@ describe("single-page path — lean", () => {
194
179
 
195
180
  describe("single-page path — links", () => {
196
181
  let execute: ExecuteFn
197
- let fetchSpy: ReturnType<typeof vi.spyOn>
198
182
 
199
183
  beforeEach(async () => {
184
+ Object.assign(process.env, isolated.env)
200
185
  execute = await loadExecute()
201
- fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
202
186
  })
203
- afterEach(() => fetchSpy.mockRestore())
204
187
 
205
188
  it("returns url, title, bodyLinks — no markdown", async () => {
206
189
  const result = await execute("1", { url: MOCK_URL, format: "links" })
@@ -226,18 +209,15 @@ describe("single-page path — links", () => {
226
209
 
227
210
  describe("single-page path — highlights", () => {
228
211
  let execute: ExecuteFn
229
- let fetchSpy: ReturnType<typeof vi.spyOn>
230
212
 
231
213
  beforeEach(async () => {
214
+ Object.assign(process.env, isolated.env)
232
215
  execute = await loadExecute()
233
- fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
234
216
  })
235
- afterEach(() => fetchSpy.mockRestore())
236
217
 
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()
218
+ it("throws when query is missing", async () => {
219
+ await expect(execute("1", { url: MOCK_URL, format: "highlights" }))
220
+ .rejects.toThrow("highlights format requires a query")
241
221
  })
242
222
 
243
223
  it("returns hits array when query is provided", async () => {
@@ -260,13 +240,11 @@ describe("single-page path — highlights", () => {
260
240
 
261
241
  describe("crawl path — depth=1", () => {
262
242
  let execute: ExecuteFn
263
- let fetchSpy: ReturnType<typeof vi.spyOn>
264
243
 
265
244
  beforeEach(async () => {
245
+ Object.assign(process.env, isolated.env)
266
246
  execute = await loadExecute()
267
- fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
268
247
  })
269
- afterEach(() => fetchSpy.mockRestore())
270
248
 
271
249
  it("returns pagesFound and pages array", async () => {
272
250
  const result = await execute("1", { url: MOCK_URL, depth: 1, maxPages: 3 })
@@ -276,27 +254,25 @@ describe("crawl path — depth=1", () => {
276
254
  expect(Array.isArray(body.pages)).toBe(true)
277
255
  })
278
256
 
279
- it("details includes depth and pagesFound", async () => {
257
+ it("details includes the crawl depth and bounded page count", async () => {
280
258
  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")
259
+ expect(result.details).toMatchObject({ kind: "web", operation: "crawl", depth: 1 })
260
+ expect(typeof result.details.pages).toBe("number")
283
261
  })
284
262
 
285
263
  it("format=lean returns leanOutput per page", async () => {
286
264
  const result = await execute("1", { url: MOCK_URL, depth: 1, maxPages: 2, format: "lean" })
287
265
  const body = JSON.parse(result.content[0].text)
288
266
  expect(Array.isArray(body.pages)).toBe(true)
289
- // Each page in lean crawl has headings and url
290
267
  for (const page of body.pages) {
291
268
  expect(typeof page.url).toBe("string")
292
269
  expect(typeof page.wordCount).toBe("number")
293
270
  }
294
271
  })
295
272
 
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()
273
+ it("highlights format without query throws", async () => {
274
+ await expect(execute("1", { url: MOCK_URL, depth: 1, format: "highlights" }))
275
+ .rejects.toThrow("highlights format requires a query")
300
276
  })
301
277
 
302
278
  it("highlights format with query returns hits", async () => {
@@ -313,13 +289,11 @@ describe("crawl path — depth=1", () => {
313
289
 
314
290
  describe("single-page path — tree (full)", () => {
315
291
  let execute: ExecuteFn
316
- let fetchSpy: ReturnType<typeof vi.spyOn>
317
292
 
318
293
  beforeEach(async () => {
294
+ Object.assign(process.env, isolated.env)
319
295
  execute = await loadExecute("web_fetch")
320
- fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
321
296
  })
322
- afterEach(() => fetchSpy.mockRestore())
323
297
 
324
298
  it("returns a tree with tag=article at root", async () => {
325
299
  const result = await execute("1", { url: MOCK_URL, format: "tree" })
@@ -335,29 +309,24 @@ describe("single-page path — tree (full)", () => {
335
309
  expect(tree.children.length).toBeGreaterThan(0)
336
310
  })
337
311
 
338
- it("details includes format=tree mode=full", async () => {
312
+ it("details identifies a full tree result", async () => {
339
313
  const result = await execute("1", { url: MOCK_URL, format: "tree" })
340
- expect(result.details.format).toBe("tree")
341
- expect(result.details.mode).toBe("full")
314
+ expect(result.details).toMatchObject({ kind: "web", format: "tree", operation: "tree-full" })
342
315
  })
343
316
 
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()
317
+ it("throws for a genuinely unreachable host", async () => {
318
+ await expect(execute("1", { url: "http://127.0.0.1:1", format: "tree", timeoutMs: 2000 }))
319
+ .rejects.toThrow("tree fetch failed")
349
320
  })
350
321
  })
351
322
 
352
323
  describe("single-page path — tree + query", () => {
353
324
  let execute: ExecuteFn
354
- let fetchSpy: ReturnType<typeof vi.spyOn>
355
325
 
356
326
  beforeEach(async () => {
327
+ Object.assign(process.env, isolated.env)
357
328
  execute = await loadExecute("web_fetch")
358
- fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
359
329
  })
360
- afterEach(() => fetchSpy.mockRestore())
361
330
 
362
331
  it("returns hits array with url and query", async () => {
363
332
  const result = await execute("1", { url: MOCK_URL, format: "tree", query: "spider fixture" })
@@ -384,27 +353,25 @@ describe("single-page path — tree + query", () => {
384
353
  expect(body.hits.length).toBeLessThanOrEqual(2)
385
354
  })
386
355
 
387
- it("details includes mode=query and hit count", async () => {
356
+ it("details identifies a tree query and hit count", async () => {
388
357
  const result = await execute("1", { url: MOCK_URL, format: "tree", query: "spider" })
389
- expect(result.details.mode).toBe("query")
358
+ expect(result.details.operation).toBe("tree-query")
390
359
  expect(typeof result.details.hits).toBe("number")
391
360
  })
392
361
  })
393
362
 
394
363
  describe("single-page path — tree + path (navigate)", () => {
395
364
  let execute: ExecuteFn
396
- let fetchSpy: ReturnType<typeof vi.spyOn>
397
365
 
398
366
  beforeEach(async () => {
367
+ Object.assign(process.env, isolated.env)
399
368
  execute = await loadExecute("web_fetch")
400
- fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock())
401
369
  })
402
- afterEach(() => fetchSpy.mockRestore())
403
370
 
404
- it("returns error for unknown path", async () => {
371
+ it("returns an empty typed result for unknown path", async () => {
405
372
  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()
373
+ expect(JSON.parse(result.content[0].text)).toMatchObject({ found: false })
374
+ expect(result.details.status).toBe("empty")
408
375
  })
409
376
 
410
377
  it("returns article root node for path=article", async () => {
@@ -414,10 +381,8 @@ describe("single-page path — tree + path (navigate)", () => {
414
381
  expect(body.path).toBe("article")
415
382
  })
416
383
 
417
- it("details includes mode=navigate, tag, path", async () => {
384
+ it("details identifies tree path navigation", async () => {
418
385
  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")
386
+ expect(result.details).toMatchObject({ operation: "tree-path", path: "article" })
422
387
  })
423
388
  })
@@ -0,0 +1,190 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Theme } from "@earendil-works/pi-coding-agent"
3
+ import { Text, visibleWidth } from "@earendil-works/pi-tui"
4
+ import {
5
+ createWebDetails,
6
+ createWebResult,
7
+ parseWebDetails,
8
+ renderWebFetchCall,
9
+ renderWebFetchResult,
10
+ WebResultCard,
11
+ } from "../src/presentation.js"
12
+
13
+ const theme = {
14
+ fg: (_color: string, text: string) => text,
15
+ bg: (_color: string, text: string) => text,
16
+ bold: (text: string) => text,
17
+ italic: (text: string) => text,
18
+ strikethrough: (text: string) => text,
19
+ underline: (text: string) => text,
20
+ } as unknown as Theme
21
+
22
+ const render = (result: ReturnType<typeof createWebResult>, expanded = false, width = 80) =>
23
+ renderWebFetchResult(result, { expanded, isPartial: false }, theme, { isPartial: false, lastComponent: undefined }).render(width).join("\n")
24
+
25
+ describe("web_fetch dual-channel presentation", () => {
26
+ it("keeps requested primary content only in the bounded model channel", () => {
27
+ const body = "article body ".repeat(10_000)
28
+ const result = createWebResult(
29
+ { url: "https://example.com/article", title: "Article", markdown: body },
30
+ createWebDetails({ operation: "fetch", format: "markdown", url: "https://example.com/article", title: "Article" }),
31
+ )
32
+ expect(result.content[0].text.length).toBeLessThanOrEqual(50_000)
33
+ expect(JSON.parse(result.content[0].text)).toMatchObject({ truncated: true })
34
+ expect(JSON.stringify(result.details)).not.toContain("article body")
35
+ expect(result.details.truncated).toBe(true)
36
+ expect(result.details.complete).toBe(false)
37
+ expect(parseWebDetails(result.details)?.operation).toBe("fetch")
38
+ })
39
+
40
+ it("renders compact calls for search, fetch, crawl, cache, and tree actions", () => {
41
+ const cases = [
42
+ [{ searchQuery: "Pi extensions" }, "Search · Pi extensions"],
43
+ [{ url: "https://example.com", depth: 2 }, "Crawl · example.com · depth 2"],
44
+ [{ url: "https://example.com", format: "tree", query: "install" }, "Tree query · example.com · install"],
45
+ [{ url: "https://example.com", format: "tree", path: "article.pre[0]" }, "Tree path · example.com · article.pre[0]"],
46
+ [{ query: "cache terms" }, "Cache search · cache terms"],
47
+ [{ grep: "docs" }, "Cache list · docs"],
48
+ [{ url: "https://example.com", format: "links" }, "Fetch links · example.com"],
49
+ ] as const
50
+ for (const [args, expected] of cases) {
51
+ expect(renderWebFetchCall(args, theme).render(100).join("\n")).toContain(expected)
52
+ }
53
+ })
54
+
55
+ it("renders metadata when collapsed and canonical primary content when expanded", () => {
56
+ const result = createWebResult(
57
+ { url: "https://example.com", title: "Example", markdown: "# Heading\n\nPrimary body" },
58
+ createWebDetails({
59
+ operation: "fetch",
60
+ format: "markdown",
61
+ url: "https://example.com",
62
+ title: "Example",
63
+ wordCount: 2,
64
+ cache: "hit",
65
+ }),
66
+ )
67
+ expect(render(result)).toContain("Fetched markdown · Example · 2 words · cache hit")
68
+ expect(render(result)).not.toContain("Primary body")
69
+ expect(render(result, true)).toContain("Primary body")
70
+ expect(JSON.stringify(result.details)).not.toContain("Primary body")
71
+ for (const width of [40, 80, 120]) {
72
+ const lines = renderWebFetchResult(result, { expanded: true, isPartial: false }, theme, { isPartial: false }).render(width)
73
+ expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true)
74
+ }
75
+ })
76
+
77
+ it("renders bounded identities for search, crawl, cache, links, highlights, and tree", () => {
78
+ const variants = [
79
+ createWebResult({ query: "q", results: [{ title: "Result", url: "https://r.test", snippet: "Evidence" }] }, createWebDetails({ operation: "search", format: "search", query: "q", hits: 1, items: [{ title: "Result", url: "https://r.test" }] })),
80
+ createWebResult({ pagesFound: 2, pages: [{ title: "One", url: "https://one.test" }] }, createWebDetails({ operation: "crawl", format: "lean", pages: 2, items: [{ title: "One", url: "https://one.test" }] })),
81
+ createWebResult({ total: 2, pages: [{ title: "Cached", url: "https://cache.test" }] }, createWebDetails({ operation: "cache-list", format: "lean", pages: 2, items: [{ title: "Cached", url: "https://cache.test" }] })),
82
+ createWebResult({ bodyLinks: [{ text: "Docs", href: "https://docs.test" }] }, createWebDetails({ operation: "fetch", format: "links", links: 1, items: [{ title: "Docs", url: "https://docs.test" }] })),
83
+ createWebResult({ hits: [{ heading: "Install", score: 1, text: "Evidence" }] }, createWebDetails({ operation: "fetch", format: "highlights", hits: 1, query: "install" })),
84
+ createWebResult({ tag: "code", path: "article.code", text: "npm install" }, createWebDetails({ operation: "tree-path", format: "tree", path: "article.code" })),
85
+ ]
86
+ for (const result of variants) {
87
+ expect(render(result).length).toBeGreaterThan(0)
88
+ expect(render(result, true)).not.toContain("[object Object]")
89
+ }
90
+ })
91
+
92
+ it("surfaces papyrusDocs in the summary line and round-trips through parseWebDetails", () => {
93
+ const result = createWebResult(
94
+ { url: "https://example.com", title: "Example", markdown: "body", papyrus: { ingested: [{ url: "https://example.com", docId: "example-abcd" }], skipped: [] } },
95
+ createWebDetails({ operation: "fetch", format: "markdown", url: "https://example.com", title: "Example", wordCount: 1, papyrusDocs: 1 }),
96
+ )
97
+ expect(render(result)).toContain("1 \u2192 mesh")
98
+ expect(parseWebDetails(result.details)?.papyrusDocs).toBe(1)
99
+ expect(JSON.parse(result.content[0].text)).toMatchObject({ papyrus: { ingested: [{ docId: "example-abcd" }] } })
100
+ })
101
+
102
+ it("omits the mesh suffix and papyrusDocs field entirely when ingestion was not requested", () => {
103
+ const result = createWebResult(
104
+ { url: "https://example.com", title: "Example", markdown: "body" },
105
+ createWebDetails({ operation: "fetch", format: "markdown", url: "https://example.com", title: "Example", wordCount: 1 }),
106
+ )
107
+ expect(render(result)).not.toContain("mesh")
108
+ expect(result.details.papyrusDocs).toBeUndefined()
109
+ expect(JSON.parse(result.content[0].text)).not.toHaveProperty("papyrus")
110
+ })
111
+
112
+ it("represents robots denial without treating it as successful fetched content", () => {
113
+ const result = createWebResult(
114
+ { blocked: true, url: "https://example.com/private", reason: "robots.txt", hint: "Try another source." },
115
+ createWebDetails({ operation: "fetch", format: "markdown", url: "https://example.com/private", status: "blocked", blockedBy: "robots.txt" }),
116
+ )
117
+ expect(render(result)).toContain("Blocked by robots.txt")
118
+ expect(result.details.status).toBe("blocked")
119
+ })
120
+
121
+ it("falls back safely for legacy details and shows partial activity", () => {
122
+ const fallback = renderWebFetchResult(
123
+ { content: [{ type: "text" as const, text: "legacy bounded content" }], details: {} },
124
+ { expanded: false, isPartial: false }, theme, { isPartial: false },
125
+ ).render(40).join("\n")
126
+ expect(fallback).toContain("legacy bounded content")
127
+ const malformed: unknown = { ...createWebDetails({ operation: "fetch", format: "lean" }), cache: "credential-bearing-unbounded-state" }
128
+ expect(parseWebDetails(malformed)).toBeUndefined()
129
+ const partial = renderWebFetchResult(
130
+ { content: [{ type: "text" as const, text: "" }], details: createWebDetails({ operation: "search", format: "search", query: "q" }) },
131
+ { expanded: false, isPartial: true }, theme, { isPartial: true, lastComponent: undefined },
132
+ ).render(40).join("\n")
133
+ expect(partial).toContain("Searching")
134
+ })
135
+
136
+ it("reuses context.lastComponent across renders (Pi's documented component-reuse best practice)", () => {
137
+ const first = createWebResult(
138
+ { url: "https://example.com/a", title: "Alpha", markdown: "alpha body" },
139
+ createWebDetails({ operation: "fetch", format: "markdown", url: "https://example.com/a", title: "Alpha", wordCount: 2 }),
140
+ )
141
+ const component = renderWebFetchResult(first, { expanded: false, isPartial: false }, theme, { isPartial: false, lastComponent: undefined })
142
+ expect(component).toBeInstanceOf(WebResultCard)
143
+ expect(component.render(80).join("\n")).toContain("Alpha")
144
+
145
+ const second = createWebResult(
146
+ { url: "https://example.com/b", title: "Beta", markdown: "beta body" },
147
+ createWebDetails({ operation: "fetch", format: "markdown", url: "https://example.com/b", title: "Beta", wordCount: 2 }),
148
+ )
149
+ const reused = renderWebFetchResult(second, { expanded: false, isPartial: false }, theme, { isPartial: false, lastComponent: component })
150
+ expect(reused).toBe(component) // same object identity — updated in place, not reallocated
151
+ expect(reused.render(80).join("\n")).toContain("Beta")
152
+ expect(reused.render(80).join("\n")).not.toContain("Alpha")
153
+ })
154
+
155
+ it("does not reuse a lastComponent of a different shape (falls back to constructing fresh)", () => {
156
+ const result = createWebResult(
157
+ { url: "https://example.com", title: "Example", markdown: "body" },
158
+ createWebDetails({ operation: "fetch", format: "markdown", url: "https://example.com", title: "Example" }),
159
+ )
160
+ const unrelated = new Text("unrelated", 0, 0)
161
+ const component = renderWebFetchResult(result, { expanded: false, isPartial: false }, theme, { isPartial: false, lastComponent: unrelated })
162
+ expect(component).not.toBe(unrelated)
163
+ expect(component).toBeInstanceOf(WebResultCard)
164
+ })
165
+
166
+ it("clears cached render lines on invalidate() so a later render reflects updated state", () => {
167
+ const result = createWebResult(
168
+ { url: "https://example.com", title: "Cached", markdown: "body" },
169
+ createWebDetails({ operation: "fetch", format: "markdown", url: "https://example.com", title: "Cached" }),
170
+ )
171
+ const component = renderWebFetchResult(result, { expanded: false, isPartial: false }, theme, { isPartial: false, lastComponent: undefined }) as WebResultCard
172
+ const first = component.render(80)
173
+ const second = component.render(80) // same width — must return the cached array, not merely equal content
174
+ expect(second).toBe(first)
175
+ component.invalidate()
176
+ const third = component.render(80)
177
+ expect(third).not.toBe(first) // cache was cleared; a fresh array was computed
178
+ expect(third.join("\n")).toBe(first.join("\n")) // content is unchanged — only identity differs
179
+ })
180
+
181
+ it("renderWebFetchCall reuses a lastComponent Text instance instead of allocating a new one", () => {
182
+ const previous = new Text("", 0, 0)
183
+ const reused = renderWebFetchCall({ url: "https://example.com" }, theme, { lastComponent: previous })
184
+ expect(reused).toBe(previous)
185
+ expect(reused.render(100).join("\n")).toContain("example.com")
186
+
187
+ const fresh = renderWebFetchCall({ url: "https://example.com" }, theme, { lastComponent: undefined })
188
+ expect(fresh).not.toBe(previous)
189
+ })
190
+ })