@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/LICENSE +21 -0
- package/package.json +14 -4
- package/src/constants.ts +7 -0
- package/src/daemon-client.ts +190 -0
- package/src/index.ts +346 -351
- package/src/presentation.ts +430 -0
- package/test/cache-paths.test.ts +38 -91
- package/test/daemon-client.test.ts +158 -0
- package/test/daemon-isolation.ts +56 -0
- package/test/e2e-jiti.test.ts +46 -56
- package/test/execute-contract.test.ts +62 -32
- package/test/fixtures/mock-pi-cli.mjs +5 -9
- package/test/harness/extension-harness.ts +554 -0
- package/test/harness/index.ts +10 -0
- package/test/harness/jiti-native-modules.ts +13 -0
- package/test/helpers/fixture-server.ts +48 -0
- package/test/live-fetch.test.ts +10 -3
- package/test/load.test.ts +13 -3
- package/test/paths.test.ts +61 -96
- package/test/presentation.test.ts +190 -0
- package/src/format.ts +0 -142
- package/test/format.test.ts +0 -373
- package/test/playwright-fallback.test.ts +0 -248
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 "
|
|
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 }[] }
|
package/test/paths.test.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extension path tests — ablation coverage for each execute() branch.
|
|
3
3
|
*
|
|
4
|
-
* Tests the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
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 {
|
|
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="
|
|
48
|
-
<a href="
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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("
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
|
282
|
-
expect(typeof result.details.
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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
|
|
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("
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
407
|
-
expect(
|
|
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
|
|
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
|
|
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
|
+
})
|