@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.
@@ -0,0 +1,430 @@
1
+ import { getMarkdownTheme, type AgentToolResult, type Theme } from "@earendil-works/pi-coding-agent"
2
+ import { Markdown, Text, truncateToWidth, type Component, type MarkdownTheme } from "@earendil-works/pi-tui"
3
+ import {
4
+ COLLAPSED_ITEM_PREVIEW,
5
+ DETAILS_MAX_FIELD_CHARACTERS,
6
+ DETAILS_MAX_ITEMS,
7
+ DETAILS_MAX_SERIALIZED_CHARACTERS,
8
+ DETAILS_VERSION,
9
+ EXPANDED_PRIMARY_MAX_LINES,
10
+ MODEL_CONTENT_MAX_CHARACTERS,
11
+ } from "./constants.js"
12
+
13
+ export type WebOperation = "search" | "fetch" | "crawl" | "cache-list" | "cache-search" | "tree-full" | "tree-query" | "tree-path"
14
+ export type WebFormat = "search" | "markdown" | "lean" | "links" | "highlights" | "tree"
15
+ export type WebStatus = "ok" | "empty" | "blocked"
16
+
17
+ export interface WebItemDetails {
18
+ title: string
19
+ url: string
20
+ }
21
+
22
+ export interface WebPresentationDetails {
23
+ version: typeof DETAILS_VERSION
24
+ kind: "web"
25
+ operation: WebOperation
26
+ format: WebFormat
27
+ status: WebStatus
28
+ url?: string
29
+ title?: string
30
+ query?: string
31
+ path?: string
32
+ depth?: number
33
+ pages?: number
34
+ hits?: number
35
+ links?: number
36
+ errors?: number
37
+ wordCount?: number
38
+ cache?: "hit" | "miss" | "listing" | "search"
39
+ enhanced?: boolean
40
+ blockedBy?: "robots.txt"
41
+ papyrusDocs?: number
42
+ items: WebItemDetails[]
43
+ truncated: boolean
44
+ complete: boolean
45
+ contentCharacters: number
46
+ deliveredCharacters: number
47
+ }
48
+
49
+ export interface CreateWebDetailsInput extends Partial<Omit<WebPresentationDetails, "version" | "kind" | "items" | "truncated" | "complete" | "contentCharacters" | "deliveredCharacters">> {
50
+ operation: WebOperation
51
+ format: WebFormat
52
+ items?: Array<{ title?: string; url: string }>
53
+ truncated?: boolean
54
+ complete?: boolean
55
+ }
56
+
57
+ function bounded(value: unknown): string | undefined {
58
+ if (typeof value !== "string") return undefined
59
+ const normalized = value.trim()
60
+ if (!normalized) return undefined
61
+ return normalized.slice(0, DETAILS_MAX_FIELD_CHARACTERS)
62
+ }
63
+
64
+ const SENSITIVE_QUERY_KEY = /(?:token|key|secret|auth|signature|credential|password)/iu
65
+
66
+ export function sanitizeWebUrl(value: unknown): string | undefined {
67
+ const raw = bounded(value)
68
+ if (!raw) return undefined
69
+ try {
70
+ const url = new URL(raw)
71
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
72
+ url.username = ""
73
+ url.password = ""
74
+ for (const key of [...url.searchParams.keys()]) {
75
+ if (SENSITIVE_QUERY_KEY.test(key)) url.searchParams.set(key, "[redacted]")
76
+ }
77
+ url.hash = ""
78
+ return url.toString().slice(0, DETAILS_MAX_FIELD_CHARACTERS)
79
+ } catch {
80
+ return undefined
81
+ }
82
+ }
83
+
84
+ export function createWebDetails(input: CreateWebDetailsInput): WebPresentationDetails {
85
+ const items = (input.items ?? []).slice(0, DETAILS_MAX_ITEMS).flatMap((item) => {
86
+ const url = sanitizeWebUrl(item.url)
87
+ if (!url) return []
88
+ return [{ title: bounded(item.title) ?? url, url }]
89
+ })
90
+ return {
91
+ version: DETAILS_VERSION,
92
+ kind: "web",
93
+ operation: input.operation,
94
+ format: input.format,
95
+ status: input.status ?? "ok",
96
+ ...(sanitizeWebUrl(input.url) ? { url: sanitizeWebUrl(input.url) } : {}),
97
+ ...(bounded(input.title) ? { title: bounded(input.title) } : {}),
98
+ ...(bounded(input.query) ? { query: bounded(input.query) } : {}),
99
+ ...(bounded(input.path) ? { path: bounded(input.path) } : {}),
100
+ ...(validCount(input.depth) ? { depth: input.depth } : {}),
101
+ ...(validCount(input.pages) ? { pages: input.pages } : {}),
102
+ ...(validCount(input.hits) ? { hits: input.hits } : {}),
103
+ ...(validCount(input.links) ? { links: input.links } : {}),
104
+ ...(validCount(input.errors) ? { errors: input.errors } : {}),
105
+ ...(validCount(input.wordCount) ? { wordCount: input.wordCount } : {}),
106
+ ...(input.cache ? { cache: input.cache } : {}),
107
+ ...(input.enhanced ? { enhanced: true } : {}),
108
+ ...(input.blockedBy ? { blockedBy: input.blockedBy } : {}),
109
+ ...(validCount(input.papyrusDocs) ? { papyrusDocs: input.papyrusDocs } : {}),
110
+ items,
111
+ truncated: input.truncated ?? false,
112
+ complete: input.complete ?? !(input.truncated ?? false),
113
+ contentCharacters: 0,
114
+ deliveredCharacters: 0,
115
+ }
116
+ }
117
+
118
+ function truncateMarkdownPayload(payload: Record<string, unknown>, originalCharacters: number): string | undefined {
119
+ if (typeof payload.markdown !== "string") return undefined
120
+ const base = {
121
+ ...payload,
122
+ markdown: "",
123
+ truncated: true,
124
+ originalCharacters,
125
+ hint: "Use highlights, a tree query/path, or selectors for complete evidence.",
126
+ }
127
+ let low = 0
128
+ let high = payload.markdown.length
129
+ let best = JSON.stringify(base)
130
+ while (low <= high) {
131
+ const middle = Math.floor((low + high) / 2)
132
+ const candidate = JSON.stringify({ ...base, markdown: payload.markdown.slice(0, middle) })
133
+ if (candidate.length <= MODEL_CONTENT_MAX_CHARACTERS) {
134
+ best = candidate
135
+ low = middle + 1
136
+ } else {
137
+ high = middle - 1
138
+ }
139
+ }
140
+ return best
141
+ }
142
+
143
+ function truncatePayload(serialized: string): string {
144
+ const base = {
145
+ truncated: true,
146
+ originalCharacters: serialized.length,
147
+ preview: "",
148
+ hint: "Use a narrower format, query, tree path, selector, or lower crawl scope for complete content.",
149
+ }
150
+ let low = 0
151
+ let high = Math.min(serialized.length, MODEL_CONTENT_MAX_CHARACTERS)
152
+ let best = JSON.stringify(base)
153
+ while (low <= high) {
154
+ const middle = Math.floor((low + high) / 2)
155
+ const candidate = JSON.stringify({ ...base, preview: serialized.slice(0, middle) })
156
+ if (candidate.length <= MODEL_CONTENT_MAX_CHARACTERS) {
157
+ best = candidate
158
+ low = middle + 1
159
+ } else {
160
+ high = middle - 1
161
+ }
162
+ }
163
+ return best
164
+ }
165
+
166
+ export function createWebResult(payload: unknown, inputDetails: WebPresentationDetails) {
167
+ const serialized = JSON.stringify(payload)
168
+ const content = serialized.length <= MODEL_CONTENT_MAX_CHARACTERS
169
+ ? serialized
170
+ : payload && typeof payload === "object" && !Array.isArray(payload)
171
+ ? truncateMarkdownPayload(payload as Record<string, unknown>, serialized.length) ?? truncatePayload(serialized)
172
+ : truncatePayload(serialized)
173
+ const truncated = inputDetails.truncated || serialized.length > MODEL_CONTENT_MAX_CHARACTERS
174
+ const details: WebPresentationDetails = {
175
+ ...inputDetails,
176
+ truncated,
177
+ complete: inputDetails.complete && !truncated,
178
+ contentCharacters: serialized.length,
179
+ deliveredCharacters: content.length,
180
+ }
181
+ return { content: [{ type: "text" as const, text: content }], details }
182
+ }
183
+
184
+ const OPERATIONS = new Set<WebOperation>(["search", "fetch", "crawl", "cache-list", "cache-search", "tree-full", "tree-query", "tree-path"])
185
+ const FORMATS = new Set<WebFormat>(["search", "markdown", "lean", "links", "highlights", "tree"])
186
+ const STATUSES = new Set<WebStatus>(["ok", "empty", "blocked"])
187
+
188
+ export function parseWebDetails(value: unknown): WebPresentationDetails | undefined {
189
+ try {
190
+ if (!value || typeof value !== "object" || JSON.stringify(value).length > DETAILS_MAX_SERIALIZED_CHARACTERS) return undefined
191
+ const details = value as Record<string, unknown>
192
+ if (details.version !== DETAILS_VERSION || details.kind !== "web") return undefined
193
+ if (typeof details.operation !== "string" || !OPERATIONS.has(details.operation as WebOperation)) return undefined
194
+ if (typeof details.format !== "string" || !FORMATS.has(details.format as WebFormat)) return undefined
195
+ if (typeof details.status !== "string" || !STATUSES.has(details.status as WebStatus)) return undefined
196
+ if (!Array.isArray(details.items) || details.items.length > DETAILS_MAX_ITEMS) return undefined
197
+ if (!details.items.every((item) => validItem(item))) return undefined
198
+ if (typeof details.truncated !== "boolean" || typeof details.complete !== "boolean") return undefined
199
+ if (details.truncated && details.complete) return undefined
200
+ if (!validCount(details.contentCharacters) || !validCount(details.deliveredCharacters) || details.deliveredCharacters > MODEL_CONTENT_MAX_CHARACTERS) return undefined
201
+ if (details.cache !== undefined && (typeof details.cache !== "string" || !["hit", "miss", "listing", "search"].includes(details.cache))) return undefined
202
+ if (details.enhanced !== undefined && typeof details.enhanced !== "boolean") return undefined
203
+ if (details.blockedBy !== undefined && details.blockedBy !== "robots.txt") return undefined
204
+ for (const field of ["url", "title", "query", "path"] as const) {
205
+ if (details[field] !== undefined && (typeof details[field] !== "string" || details[field].length > DETAILS_MAX_FIELD_CHARACTERS)) return undefined
206
+ }
207
+ for (const field of ["depth", "pages", "hits", "links", "errors", "wordCount", "papyrusDocs"] as const) {
208
+ if (details[field] !== undefined && !validCount(details[field])) return undefined
209
+ }
210
+ return value as WebPresentationDetails
211
+ } catch {
212
+ return undefined
213
+ }
214
+ }
215
+
216
+ function validCount(value: unknown): value is number {
217
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
218
+ }
219
+
220
+ function validItem(value: unknown): boolean {
221
+ if (!value || typeof value !== "object") return false
222
+ const item = value as Record<string, unknown>
223
+ return typeof item.title === "string" && item.title.length <= DETAILS_MAX_FIELD_CHARACTERS
224
+ && typeof item.url === "string" && item.url.length <= DETAILS_MAX_FIELD_CHARACTERS
225
+ }
226
+
227
+ function host(value: unknown): string {
228
+ const safe = sanitizeWebUrl(value)
229
+ if (!safe) return ""
230
+ try { return new URL(safe).hostname } catch { return safe }
231
+ }
232
+
233
+ function callText(args: Record<string, unknown>): string {
234
+ const format = typeof args.format === "string" ? args.format : "markdown"
235
+ if (typeof args.searchQuery === "string" && args.searchQuery.trim()) return `Search · ${args.searchQuery.trim()}`
236
+ if (!args.url) {
237
+ if (typeof args.query === "string" && args.query.trim()) return `Cache search · ${args.query.trim()}`
238
+ return `Cache list${typeof args.grep === "string" && args.grep.trim() ? ` · ${args.grep.trim()}` : ""}`
239
+ }
240
+ const domain = host(args.url)
241
+ if (format === "tree") {
242
+ if (typeof args.path === "string" && args.path.trim()) return `Tree path · ${domain} · ${args.path.trim()}`
243
+ if (typeof args.query === "string" && args.query.trim()) return `Tree query · ${domain} · ${args.query.trim()}`
244
+ return `Tree · ${domain}`
245
+ }
246
+ if (typeof args.depth === "number" && args.depth > 0) return `Crawl · ${domain} · depth ${Math.floor(args.depth)}`
247
+ return `Fetch ${format} · ${domain}`
248
+ }
249
+
250
+ export interface WebFetchCallContext {
251
+ /** The previously returned call-slot component, if any — reuse per Pi's documented best practice. */
252
+ lastComponent: Component | undefined
253
+ }
254
+
255
+ /**
256
+ * Reuses `context.lastComponent` (a `Text`) via `setText()` instead of allocating a new
257
+ * component on every render, per docs/extensions.md's own renderCall example — `Text`
258
+ * already caches its own rendered lines internally and only recomputes when the text
259
+ * actually changes.
260
+ */
261
+ export function renderWebFetchCall(args: Record<string, unknown>, theme: Theme, context: WebFetchCallContext = { lastComponent: undefined }): Text {
262
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0)
263
+ text.setText(theme.fg("toolTitle", theme.bold(truncateToWidth(callText(args), 160))))
264
+ return text
265
+ }
266
+
267
+ function summary(details: WebPresentationDetails): string {
268
+ if (details.status === "blocked") return `Blocked by ${details.blockedBy ?? "policy"} · ${host(details.url)}`
269
+ const suffix = [
270
+ details.wordCount !== undefined ? `${details.wordCount} words` : undefined,
271
+ details.pages !== undefined ? `${details.pages} pages` : undefined,
272
+ details.hits !== undefined ? `${details.hits} ${details.operation === "search" ? "results" : "hits"}` : undefined,
273
+ details.links !== undefined ? `${details.links} links` : undefined,
274
+ details.cache ? `cache ${details.cache}` : undefined,
275
+ details.enhanced ? "browser" : undefined,
276
+ details.truncated ? "truncated" : undefined,
277
+ details.papyrusDocs ? `${details.papyrusDocs} → mesh` : undefined,
278
+ ].filter(Boolean).join(" · ")
279
+ const identity = details.title || host(details.url) || details.query
280
+ const action = details.operation === "search" ? "Search complete"
281
+ : details.operation === "crawl" ? "Crawled"
282
+ : details.operation === "cache-list" ? "Cached pages"
283
+ : details.operation === "cache-search" ? "Cache search"
284
+ : details.operation.startsWith("tree") ? "Tree"
285
+ : `Fetched ${details.format}`
286
+ return [action, identity, suffix].filter(Boolean).join(" · ")
287
+ }
288
+
289
+ function markdownTheme(theme: Theme): MarkdownTheme {
290
+ let highlightCode: MarkdownTheme["highlightCode"] | undefined
291
+ try { highlightCode = getMarkdownTheme().highlightCode } catch { highlightCode = undefined }
292
+ return {
293
+ heading: (text) => theme.fg("mdHeading", text),
294
+ link: (text) => theme.fg("mdLink", text),
295
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
296
+ code: (text) => theme.fg("mdCode", text),
297
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
298
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
299
+ quote: (text) => theme.fg("mdQuote", text),
300
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
301
+ hr: (text) => theme.fg("mdHr", text),
302
+ listBullet: (text) => theme.fg("mdListBullet", text),
303
+ bold: (text) => theme.bold(text),
304
+ italic: (text) => theme.italic(text),
305
+ strikethrough: (text) => theme.strikethrough(text),
306
+ underline: (text) => theme.underline(text),
307
+ highlightCode: (code, language) => {
308
+ try { return highlightCode?.(code, language) ?? code.split("\n") } catch { return code.split("\n") }
309
+ },
310
+ }
311
+ }
312
+
313
+ function primaryLines(text: string, details: WebPresentationDetails, width: number, theme: Theme): string[] {
314
+ let payload: unknown
315
+ try { payload = JSON.parse(text) } catch { return new Text(text, 0, 0).render(width) }
316
+ if (details.format === "markdown" && payload && typeof payload === "object" && "markdown" in payload && typeof payload.markdown === "string") {
317
+ const component = new Markdown(payload.markdown, 0, 0, markdownTheme(theme), { color: (value) => theme.fg("text", value) })
318
+ const lines = component.render(width)
319
+ if (lines.length <= EXPANDED_PRIMARY_MAX_LINES) return lines
320
+ return [...lines.slice(0, EXPANDED_PRIMARY_MAX_LINES), theme.fg("warning", `… ${lines.length - EXPANDED_PRIMARY_MAX_LINES} rendered lines omitted`)]
321
+ }
322
+ const pretty = JSON.stringify(payload, null, 2)
323
+ const lines = new Text(pretty, 0, 0).render(width)
324
+ if (lines.length <= EXPANDED_PRIMARY_MAX_LINES) return lines
325
+ return [...lines.slice(0, EXPANDED_PRIMARY_MAX_LINES), theme.fg("warning", `… ${lines.length - EXPANDED_PRIMARY_MAX_LINES} rendered lines omitted`)]
326
+ }
327
+
328
+ function fallbackText(result: AgentToolResult<unknown>): string {
329
+ return result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n")
330
+ }
331
+
332
+ /**
333
+ * Reusable width-cached result card — mirrors the documented Pi Component performance
334
+ * pattern (docs/tui.md "Performance": cache render lines per width, clear on invalidate())
335
+ * and Papyrus's ArtifactCard shape. Constructed once per tool-call result slot and
336
+ * updated in place via `update()` on subsequent renders of the same call
337
+ * (see docs/extensions.md: "Reuse context.lastComponent when the same component
338
+ * instance can be updated in place").
339
+ *
340
+ * Known limitation shared with every component that pre-bakes theme colors into
341
+ * cached lines (see docs/tui.md "Invalidation and Theme Changes"): a bare theme
342
+ * switch with no new tool result calls only `invalidate()`, not `update()`, so the
343
+ * next render recomputes from the *previous* theme reference until this call's
344
+ * result changes again. This is the same accepted trade-off Papyrus's ArtifactCard/
345
+ * ArtifactListCard/TaskHierarchyPreview already ship with in production.
346
+ */
347
+ export class WebResultCard implements Component {
348
+ private result: AgentToolResult<unknown>
349
+ private details: WebPresentationDetails
350
+ private expanded: boolean
351
+ private theme: Theme
352
+ private cachedWidth: number | undefined
353
+ private cachedLines: string[] | undefined
354
+
355
+ constructor(result: AgentToolResult<unknown>, details: WebPresentationDetails, expanded: boolean, theme: Theme) {
356
+ this.result = result
357
+ this.details = details
358
+ this.expanded = expanded
359
+ this.theme = theme
360
+ }
361
+
362
+ update(result: AgentToolResult<unknown>, details: WebPresentationDetails, expanded: boolean, theme: Theme): void {
363
+ this.result = result
364
+ this.details = details
365
+ this.expanded = expanded
366
+ this.theme = theme
367
+ this.invalidate()
368
+ }
369
+
370
+ render(width: number): string[] {
371
+ const safeWidth = Math.max(1, width)
372
+ if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines
373
+
374
+ const details = this.details
375
+ const theme = this.theme
376
+ const expanded = this.expanded
377
+ const color = details.status === "blocked" ? "warning" : details.status === "empty" ? "muted" : "success"
378
+ const lines = [truncateToWidth(theme.fg(color, summary(details)), safeWidth)]
379
+ const shown = expanded ? details.items : details.items.slice(0, COLLAPSED_ITEM_PREVIEW)
380
+ for (const item of shown) {
381
+ lines.push(truncateToWidth(theme.fg("accent", ` ${item.title}`), safeWidth))
382
+ if (expanded) lines.push(truncateToWidth(theme.fg("dim", ` ${item.url}`), safeWidth))
383
+ }
384
+ if (!expanded && details.items.length > shown.length) lines.push(theme.fg("muted", ` … ${details.items.length - shown.length} more`))
385
+
386
+ let finalLines = lines
387
+ if (expanded) {
388
+ const text = fallbackText(this.result)
389
+ if (text) finalLines = [...lines, "", ...primaryLines(text, details, safeWidth, theme)]
390
+ }
391
+
392
+ this.cachedWidth = safeWidth
393
+ this.cachedLines = finalLines
394
+ return finalLines
395
+ }
396
+
397
+ invalidate(): void {
398
+ this.cachedWidth = undefined
399
+ this.cachedLines = undefined
400
+ }
401
+ }
402
+
403
+ export interface WebFetchResultContext {
404
+ isPartial: boolean
405
+ /** The previously returned result-slot component, if any — reuse per Pi's documented best practice. */
406
+ lastComponent: Component | undefined
407
+ }
408
+
409
+ export function renderWebFetchResult(
410
+ result: AgentToolResult<unknown>,
411
+ options: { expanded: boolean; isPartial: boolean },
412
+ theme: Theme,
413
+ context: WebFetchResultContext,
414
+ ): Component {
415
+ const details = parseWebDetails(result.details)
416
+ if (options.isPartial || context.isPartial) {
417
+ const activity = details?.operation === "search" ? "Searching the web…"
418
+ : details?.operation === "crawl" ? "Crawling pages…"
419
+ : "Fetching web content…"
420
+ return new Text(theme.fg("accent", activity), 0, 0)
421
+ }
422
+ if (!details) return new Text(fallbackText(result), 0, 0)
423
+
424
+ const previous = context.lastComponent instanceof WebResultCard ? context.lastComponent : undefined
425
+ if (previous) {
426
+ previous.update(result, details, options.expanded, theme)
427
+ return previous
428
+ }
429
+ return new WebResultCard(result, details, options.expanded, theme)
430
+ }
@@ -3,85 +3,53 @@
3
3
  *
4
4
  * Two paths in execute() that were previously uncovered:
5
5
  *
6
- * 1. Cache hit — when a URL has already been fetched in this session,
7
- * web_fetch(url) returns the cached page without hitting
8
- * the network again.
9
- *
6
+ * 1. Cache hit — when a URL has already been fetched, web_fetch(url)
7
+ * returns the cached page without a real second fetch.
10
8
  * 2. Cache search — web_fetch({ query }) with no url searches all cached
11
9
  * pages using BM25F and returns ranked hits.
12
10
  *
13
- * Both paths use a single harness session so the cache accumulates state
14
- * across calls as it would in a real Pi session.
11
+ * Both paths use a single harness session so the daemon's cache accumulates
12
+ * state across calls as it would in a real Pi session. Pages are served by
13
+ * a real local HTTP fixture server and fetched by the real (isolated) Web
14
+ * Spider daemon — mocking globalThis.fetch in this process no longer works
15
+ * once fetching moved into the daemon's own process.
15
16
  */
16
17
 
17
18
  import { readFileSync } from "node:fs"
18
19
  import { dirname, join } from "node:path"
19
20
  import { fileURLToPath } from "node:url"
20
- import {
21
- afterAll,
22
- beforeAll,
23
- beforeEach,
24
- describe,
25
- expect,
26
- it,
27
- vi,
28
- } from "vitest"
29
- import {
30
- createExtensionHarness,
31
- type ExtensionHarness,
32
- } from "@earendil-works/pi-coding-agent/testing"
21
+ import { afterAll, beforeAll, describe, expect, it } from "vitest"
22
+ import { createExtensionHarness, type ExtensionHarness } from "./harness/index.ts"
23
+ import { isolatedDaemonEnv, type IsolatedDaemonEnv } from "./daemon-isolation.js"
24
+ import { startFixtureServer, type FixtureServer } from "./helpers/fixture-server.js"
33
25
 
34
26
  const __dirname = dirname(fileURLToPath(import.meta.url))
35
27
  const FIXTURES = join(__dirname, "../../web-spider/fixtures")
36
28
  const ARTICLE_HTML = readFileSync(join(FIXTURES, "article-with-images.html"), "utf8")
37
29
 
38
- const URL_A = "https://example.com/article-a"
39
- const URL_B = "https://example.com/article-b"
40
-
41
- // ---------------------------------------------------------------------------
42
- // Helpers
43
- // ---------------------------------------------------------------------------
44
-
45
- function makeOkResponse(html: string) {
46
- return {
47
- ok: true,
48
- status: 200,
49
- statusText: "OK",
50
- headers: { get: () => null },
51
- text: async () => html,
52
- arrayBuffer: async () => new ArrayBuffer(0),
53
- }
54
- }
55
-
56
- // ---------------------------------------------------------------------------
57
- // Single shared session — cache builds up across tests
58
- // ---------------------------------------------------------------------------
59
-
60
30
  let h: ExtensionHarness
61
- let fetchMock: ReturnType<typeof vi.fn>
31
+ let isolated: IsolatedDaemonEnv
32
+ let server: FixtureServer
33
+ let URL_A: string
34
+ let URL_B: string
62
35
 
63
36
  beforeAll(async () => {
37
+ isolated = isolatedDaemonEnv("pi-web-spider-cache-paths-test-")
38
+ server = await startFixtureServer()
39
+ server.set("/article-a", ARTICLE_HTML)
40
+ server.set("/article-b", ARTICLE_HTML)
41
+ URL_A = `${server.baseUrl}/article-a`
42
+ URL_B = `${server.baseUrl}/article-b`
43
+
64
44
  const { default: factory } = await import("../src/index.js")
65
- h = createExtensionHarness(factory, {
66
- cwd: "/tmp",
67
- env: { WEB_SPIDER_CACHE_PATH: "/tmp/ws-cache-paths-test.json" },
68
- })
45
+ h = createExtensionHarness(factory, { cwd: "/tmp", env: isolated.env })
69
46
  await h.boot()
70
47
  })
71
48
 
72
49
  afterAll(async () => {
73
50
  await h.shutdown()
74
- })
75
-
76
- beforeEach(() => {
77
- fetchMock = vi.fn(async (input: RequestInfo | URL) => {
78
- const url = input.toString()
79
- if (url.includes("robots.txt")) return makeOkResponse("User-agent: *\nAllow: /")
80
- if (url.includes("sitemap")) return { ...makeOkResponse(""), status: 404, ok: false }
81
- if (url.startsWith("https://example.com")) return makeOkResponse(ARTICLE_HTML)
82
- return { ...makeOkResponse(""), status: 404, ok: false }
83
- })
84
- vi.stubGlobal("fetch", fetchMock)
51
+ await server.close()
52
+ isolated.cleanup()
85
53
  })
86
54
 
87
55
  // ---------------------------------------------------------------------------
@@ -90,7 +58,6 @@ beforeEach(() => {
90
58
 
91
59
  describe("cache listing path", () => {
92
60
  it("returns total=0 and empty pages on a cold cache", async () => {
93
- // Fresh harness in beforeAll, no fetches yet.
94
61
  const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
95
62
  const text = JSON.parse(result.content[0].text)
96
63
 
@@ -107,34 +74,23 @@ describe("cache listing path", () => {
107
74
 
108
75
  describe("cache hit path", () => {
109
76
  it("fetches URL_A on first call", async () => {
110
- const result = await h.invokeTool("web_fetch", {
111
- url: URL_A,
112
- format: "lean",
113
- }) as { content: { text: string }[] }
77
+ const result = await h.invokeTool("web_fetch", { url: URL_A, format: "lean" }) as { content: { text: string }[]; details: Record<string, unknown> }
114
78
  const text = JSON.parse(result.content[0].text)
115
79
 
116
80
  expect(text).not.toHaveProperty("error")
117
81
  expect(text.wordCount).toBeGreaterThan(0)
82
+ expect(result.details.cache).toBe("miss")
118
83
  })
119
84
 
120
85
  it("returns the cached page on second call without hitting the network", async () => {
121
- // Reset the mock so we can assert it was NOT called again.
122
- fetchMock.mockClear()
123
-
124
- const result = await h.invokeTool("web_fetch", {
125
- url: URL_A,
126
- format: "lean",
127
- }) as { content: { text: string }[] }
86
+ const result = await h.invokeTool("web_fetch", { url: URL_A, format: "lean" }) as { content: { text: string }[]; details: Record<string, unknown> }
128
87
  const text = JSON.parse(result.content[0].text)
129
88
 
130
89
  expect(text).not.toHaveProperty("error")
131
90
  expect(text.wordCount).toBeGreaterThan(0)
132
-
133
- // Network was not touched for the main URL only robots.txt may fire.
134
- const mainUrlCalls = fetchMock.mock.calls.filter(
135
- ([input]: [RequestInfo | URL]) => input.toString().startsWith(URL_A)
136
- )
137
- expect(mainUrlCalls).toHaveLength(0)
91
+ // The daemon's own cache reports this as a hit — the authoritative signal
92
+ // that no second real fetch happened (replaces the old fetch-mock-call-count check).
93
+ expect(result.details.cache).toBe("hit")
138
94
  })
139
95
 
140
96
  it("cache listing shows URL_A after it has been fetched", async () => {
@@ -153,18 +109,13 @@ describe("cache hit path", () => {
153
109
 
154
110
  describe("cache search path", () => {
155
111
  it("fetches URL_B to populate the cache", async () => {
156
- const result = await h.invokeTool("web_fetch", {
157
- url: URL_B,
158
- format: "lean",
159
- }) as { content: { text: string }[] }
112
+ const result = await h.invokeTool("web_fetch", { url: URL_B, format: "lean" }) as { content: { text: string }[] }
160
113
  const text = JSON.parse(result.content[0].text)
161
114
  expect(text).not.toHaveProperty("error")
162
115
  })
163
116
 
164
117
  it("returns BM25F hits when query matches cached content", async () => {
165
- const result = await h.invokeTool("web_fetch", {
166
- query: "image scraping web spider",
167
- }) as { content: { text: string }[] }
118
+ const result = await h.invokeTool("web_fetch", { query: "image scraping web spider" }) as { content: { text: string }[] }
168
119
  const text = JSON.parse(result.content[0].text)
169
120
 
170
121
  expect(text).not.toHaveProperty("error")
@@ -176,9 +127,7 @@ describe("cache search path", () => {
176
127
  })
177
128
 
178
129
  it("each hit has url, score, and text", async () => {
179
- const result = await h.invokeTool("web_fetch", {
180
- query: "image scraping",
181
- }) as { content: { text: string }[] }
130
+ const result = await h.invokeTool("web_fetch", { query: "image scraping" }) as { content: { text: string }[] }
182
131
  const text = JSON.parse(result.content[0].text)
183
132
  const hit = text.hits[0]
184
133
 
@@ -190,9 +139,9 @@ describe("cache search path", () => {
190
139
  it("returns zero hits gracefully for a query with no matches", async () => {
191
140
  const result = await h.invokeTool("web_fetch", {
192
141
  // Use a single token with no vowels — avoids the hyphen-splitting bug where
193
- // "xyzzy-no-such-content-ever-12345" tokenises to [xyzzy, no, such, content,
194
- // ever, 12345] and "content" literally matches the article fixture.
195
- query: "zxqfkwjpvm",
142
+ // "xyzzy-no-such-content-ever-12345" tokenises to [xyzzy, no, such, content,
143
+ // ever, 12345] and "content" literally matches the article fixture.
144
+ query: "zxqfkwjpvm",
196
145
  }) as { content: { text: string }[] }
197
146
  const text = JSON.parse(result.content[0].text)
198
147
 
@@ -202,9 +151,7 @@ describe("cache search path", () => {
202
151
  })
203
152
 
204
153
  it("grep= filter narrows cache listing results", async () => {
205
- const result = await h.invokeTool("web_fetch", {
206
- grep: "article-a",
207
- }) as { content: { text: string }[] }
154
+ const result = await h.invokeTool("web_fetch", { grep: "article-a" }) as { content: { text: string }[] }
208
155
  const text = JSON.parse(result.content[0].text)
209
156
 
210
157
  expect(text).not.toHaveProperty("error")