@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.
- package/biome.json +15 -0
- package/package.json +33 -0
- package/src/format.ts +142 -0
- package/src/index.ts +635 -0
- package/test/cache-paths.test.ts +213 -0
- package/test/e2e-jiti.test.ts +210 -0
- package/test/execute-contract.test.ts +69 -0
- package/test/fixtures/mock-pi-cli.mjs +113 -0
- package/test/fixtures/pi-loader-harness.mjs +96 -0
- package/test/format.test.ts +373 -0
- package/test/live-fetch.test.ts +126 -0
- package/test/load.test.ts +75 -0
- package/test/paths.test.ts +423 -0
- package/test/playwright-fallback.test.ts +248 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache path tests — local materialized view behaviour.
|
|
3
|
+
*
|
|
4
|
+
* Two paths in execute() that were previously uncovered:
|
|
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
|
+
*
|
|
10
|
+
* 2. Cache search — web_fetch({ query }) with no url searches all cached
|
|
11
|
+
* pages using BM25F and returns ranked hits.
|
|
12
|
+
*
|
|
13
|
+
* Both paths use a single harness session so the cache accumulates state
|
|
14
|
+
* across calls as it would in a real Pi session.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from "node:fs"
|
|
18
|
+
import { dirname, join } from "node:path"
|
|
19
|
+
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"
|
|
33
|
+
|
|
34
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
35
|
+
const FIXTURES = join(__dirname, "../../web-spider/fixtures")
|
|
36
|
+
const ARTICLE_HTML = readFileSync(join(FIXTURES, "article-with-images.html"), "utf8")
|
|
37
|
+
|
|
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
|
+
let h: ExtensionHarness
|
|
61
|
+
let fetchMock: ReturnType<typeof vi.fn>
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
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
|
+
})
|
|
69
|
+
await h.boot()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
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)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Cache listing — no url, no query
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe("cache listing path", () => {
|
|
92
|
+
it("returns total=0 and empty pages on a cold cache", async () => {
|
|
93
|
+
// Fresh harness in beforeAll, no fetches yet.
|
|
94
|
+
const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
|
|
95
|
+
const text = JSON.parse(result.content[0].text)
|
|
96
|
+
|
|
97
|
+
expect(text).not.toHaveProperty("error")
|
|
98
|
+
expect(text).toHaveProperty("total")
|
|
99
|
+
expect(text).toHaveProperty("pages")
|
|
100
|
+
expect(Array.isArray(text.pages)).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Cache hit — second fetch for the same URL skips the network
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
describe("cache hit path", () => {
|
|
109
|
+
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 }[] }
|
|
114
|
+
const text = JSON.parse(result.content[0].text)
|
|
115
|
+
|
|
116
|
+
expect(text).not.toHaveProperty("error")
|
|
117
|
+
expect(text.wordCount).toBeGreaterThan(0)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
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 }[] }
|
|
128
|
+
const text = JSON.parse(result.content[0].text)
|
|
129
|
+
|
|
130
|
+
expect(text).not.toHaveProperty("error")
|
|
131
|
+
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)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("cache listing shows URL_A after it has been fetched", async () => {
|
|
141
|
+
const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
|
|
142
|
+
const text = JSON.parse(result.content[0].text)
|
|
143
|
+
|
|
144
|
+
expect(text.total).toBeGreaterThanOrEqual(1)
|
|
145
|
+
const urls = text.pages.map((p: { url: string }) => p.url)
|
|
146
|
+
expect(urls).toContain(URL_A)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Cache search — no url, with query
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe("cache search path", () => {
|
|
155
|
+
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 }[] }
|
|
160
|
+
const text = JSON.parse(result.content[0].text)
|
|
161
|
+
expect(text).not.toHaveProperty("error")
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
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 }[] }
|
|
168
|
+
const text = JSON.parse(result.content[0].text)
|
|
169
|
+
|
|
170
|
+
expect(text).not.toHaveProperty("error")
|
|
171
|
+
expect(text).toHaveProperty("hits")
|
|
172
|
+
expect(Array.isArray(text.hits)).toBe(true)
|
|
173
|
+
expect(text.hits.length).toBeGreaterThan(0)
|
|
174
|
+
expect(text).toHaveProperty("pagesSearched")
|
|
175
|
+
expect(text.pagesSearched).toBeGreaterThanOrEqual(1)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
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 }[] }
|
|
182
|
+
const text = JSON.parse(result.content[0].text)
|
|
183
|
+
const hit = text.hits[0]
|
|
184
|
+
|
|
185
|
+
expect(hit).toHaveProperty("url")
|
|
186
|
+
expect(hit).toHaveProperty("score")
|
|
187
|
+
expect(typeof hit.score).toBe("number")
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("returns zero hits gracefully for a query with no matches", async () => {
|
|
191
|
+
const result = await h.invokeTool("web_fetch", {
|
|
192
|
+
// 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",
|
|
196
|
+
}) as { content: { text: string }[] }
|
|
197
|
+
const text = JSON.parse(result.content[0].text)
|
|
198
|
+
|
|
199
|
+
expect(text).not.toHaveProperty("error")
|
|
200
|
+
expect(text).toHaveProperty("hits")
|
|
201
|
+
expect(text.hits).toHaveLength(0)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
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 }[] }
|
|
208
|
+
const text = JSON.parse(result.content[0].text)
|
|
209
|
+
|
|
210
|
+
expect(text).not.toHaveProperty("error")
|
|
211
|
+
expect(text.pages.every((p: { url: string }) => p.url.includes("article-a"))).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E tests using the production jiti load path (tryNative:true + alias map).
|
|
3
|
+
*
|
|
4
|
+
* vi.mock() operates in vitest's module registry, which the extension factory
|
|
5
|
+
* bypasses via dynamic import(). These tests run the extension in a subprocess
|
|
6
|
+
* via mock-pi-cli.mjs so the real jiti context is exercised end-to-end.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, mkdirSync } from "node:fs"
|
|
10
|
+
import { spawn } from "node:child_process"
|
|
11
|
+
import { resolve, dirname } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
import { describe, expect, it } from "vitest"
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const CLI = resolve(__dirname, "fixtures/mock-pi-cli.mjs")
|
|
17
|
+
const EXTENSION = resolve(__dirname, "../src/index.ts")
|
|
18
|
+
const CACHE_DIR = "/tmp/ws-e2e-jiti"
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helper — run mock-pi-cli and collect emitted NDJSON events
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
interface Event { type: string; [k: string]: unknown }
|
|
24
|
+
|
|
25
|
+
interface RunResult {
|
|
26
|
+
events: Event[]
|
|
27
|
+
diagPath: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runCli(opts: {
|
|
31
|
+
tool?: string
|
|
32
|
+
/** Single invocation. Mutually exclusive with paramsList. */
|
|
33
|
+
params?: Record<string, unknown>
|
|
34
|
+
/** Multiple sequential invocations in the same process — same extension
|
|
35
|
+
* instance, same cache object. Use for cache round-trip tests. */
|
|
36
|
+
paramsList?: Record<string, unknown>[]
|
|
37
|
+
env?: Record<string, string>
|
|
38
|
+
timeoutMs?: number
|
|
39
|
+
}): Promise<RunResult> {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
mkdirSync(CACHE_DIR, { recursive: true })
|
|
42
|
+
const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
43
|
+
// Unique cache per run — prevents cross-test hits from masking errors.
|
|
44
|
+
const cachePath = `${CACHE_DIR}/cache-${tag}.json`
|
|
45
|
+
// Unique diag path — lets tests read and assert the boot probe.
|
|
46
|
+
const diagPath = `${CACHE_DIR}/diag-${tag}.log`
|
|
47
|
+
|
|
48
|
+
const invocations = opts.paramsList ?? [opts.params ?? {}]
|
|
49
|
+
|
|
50
|
+
const args = [
|
|
51
|
+
"--extension", EXTENSION,
|
|
52
|
+
"--tool", opts.tool ?? "web_fetch",
|
|
53
|
+
// Each element becomes a separate --params flag; mock-pi-cli.mjs
|
|
54
|
+
// invokes the tool once per flag in the same process.
|
|
55
|
+
...invocations.flatMap(p => ["--params", JSON.stringify(p)]),
|
|
56
|
+
"--env", `WEB_SPIDER_CACHE_PATH=${cachePath}`,
|
|
57
|
+
"--env", `WEB_SPIDER_DIAG_PATH=${diagPath}`,
|
|
58
|
+
...Object.entries(opts.env ?? {}).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const proc = spawn("node", [CLI, ...args], {
|
|
62
|
+
env: { ...process.env, ...opts.env },
|
|
63
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const lines: string[] = []
|
|
67
|
+
const stderr: string[] = []
|
|
68
|
+
|
|
69
|
+
proc.stdout.on("data", (chunk: Buffer) => lines.push(...chunk.toString().split("\n").filter(Boolean)))
|
|
70
|
+
proc.stderr.on("data", (chunk: Buffer) => stderr.push(chunk.toString()))
|
|
71
|
+
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
proc.kill("SIGKILL")
|
|
74
|
+
reject(new Error(`mock-pi-cli timed out after ${opts.timeoutMs ?? 15000}ms`))
|
|
75
|
+
}, opts.timeoutMs ?? 15000)
|
|
76
|
+
|
|
77
|
+
proc.on("close", () => {
|
|
78
|
+
clearTimeout(timer)
|
|
79
|
+
try {
|
|
80
|
+
resolve({ events: lines.map(l => JSON.parse(l) as Event), diagPath })
|
|
81
|
+
} catch (e) {
|
|
82
|
+
reject(new Error(`failed to parse NDJSON: ${e}\nlines: ${lines.join("\n")}\nstderr: ${stderr.join("")}`))
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read and parse the boot-probe line from a diag log written by the extension. */
|
|
89
|
+
function readBootProbe(diagPath: string): Record<string, unknown> | null {
|
|
90
|
+
let raw: string
|
|
91
|
+
try { raw = readFileSync(diagPath, "utf8") } catch { return null }
|
|
92
|
+
for (const line of raw.split("\n")) {
|
|
93
|
+
try {
|
|
94
|
+
const obj = JSON.parse(line) as Record<string, unknown>
|
|
95
|
+
if (obj["tag"] === "boot-probe") return obj
|
|
96
|
+
} catch { /* skip malformed lines */ }
|
|
97
|
+
}
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Tests
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
describe("E2E: production jiti load (tryNative:true + alias)", () => {
|
|
106
|
+
|
|
107
|
+
it("extension loads and tool is registered", async () => {
|
|
108
|
+
const { events } = await runCli({
|
|
109
|
+
params: { url: "https://example.com", format: "lean" },
|
|
110
|
+
timeoutMs: 20000,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const start = events.find(e => e.type === "tool_execution_start")
|
|
114
|
+
const end = events.find(e => e.type === "tool_execution_end")
|
|
115
|
+
|
|
116
|
+
expect(start).toBeDefined()
|
|
117
|
+
expect(end).toBeDefined()
|
|
118
|
+
|
|
119
|
+
const result = (end as { result: { content: { text: string }[] } }).result
|
|
120
|
+
const text = JSON.parse(result.content[0].text)
|
|
121
|
+
|
|
122
|
+
expect(text).not.toHaveProperty("error")
|
|
123
|
+
expect(text).toHaveProperty("title")
|
|
124
|
+
}, 25000)
|
|
125
|
+
|
|
126
|
+
it("Playwright error propagates as { error } — auto-fallback path", async () => {
|
|
127
|
+
// enhanced:true forces Playwright regardless of jsRendered, exercising the
|
|
128
|
+
// same error-propagation code path as the jsRendered auto-fallback.
|
|
129
|
+
// GitHub's server-side HTML now has enough content for Readability (wordCount>0)
|
|
130
|
+
// so jsRendered stays false on plain fetch — enhanced:true is the reliable trigger.
|
|
131
|
+
const { events } = await runCli({
|
|
132
|
+
params: { url: "https://example.com", enhanced: true },
|
|
133
|
+
env: { WEB_SPIDER_PLAYWRIGHT_EXECUTABLE: "/nonexistent" },
|
|
134
|
+
timeoutMs: 20000,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const end = events.find(e => e.type === "tool_execution_end")
|
|
138
|
+
expect(end).toBeDefined()
|
|
139
|
+
|
|
140
|
+
const result = (end as { result: { content: { text: string }[] } }).result
|
|
141
|
+
const text = JSON.parse(result.content[0].text)
|
|
142
|
+
|
|
143
|
+
expect(text).toHaveProperty("error")
|
|
144
|
+
expect(typeof text.error).toBe("string")
|
|
145
|
+
expect(text.error).toMatch(/executable|launch|nonexistent/i)
|
|
146
|
+
}, 25000)
|
|
147
|
+
|
|
148
|
+
it("enhanced=true with missing binary returns { error } immediately", async () => {
|
|
149
|
+
const { events } = await runCli({
|
|
150
|
+
params: { url: "https://example.com", enhanced: true },
|
|
151
|
+
env: { WEB_SPIDER_PLAYWRIGHT_EXECUTABLE: "/nonexistent" },
|
|
152
|
+
timeoutMs: 20000,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const end = events.find(e => e.type === "tool_execution_end")
|
|
156
|
+
expect(end).toBeDefined()
|
|
157
|
+
|
|
158
|
+
const result = (end as { result: { content: { text: string }[] } }).result
|
|
159
|
+
const text = JSON.parse(result.content[0].text)
|
|
160
|
+
|
|
161
|
+
expect(text).toHaveProperty("error")
|
|
162
|
+
}, 25000)
|
|
163
|
+
|
|
164
|
+
it("process exits cleanly — does not hang", async () => {
|
|
165
|
+
const { events } = await runCli({
|
|
166
|
+
params: { url: "https://github.com/hyprwm/aquamarine/issues" },
|
|
167
|
+
env: { WEB_SPIDER_PLAYWRIGHT_EXECUTABLE: "/nonexistent" },
|
|
168
|
+
timeoutMs: 20000, // would hit this if the process hangs
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const exit = events.find(e => e.type === "exit")
|
|
172
|
+
expect(exit).toBeDefined()
|
|
173
|
+
}, 25000)
|
|
174
|
+
|
|
175
|
+
it("cache round-trip: second fetch hits in-memory cache — no Map errors", async () => {
|
|
176
|
+
const { events } = await runCli({
|
|
177
|
+
paramsList: [
|
|
178
|
+
{ url: "https://example.com", format: "lean" },
|
|
179
|
+
{ url: "https://example.com", format: "lean" },
|
|
180
|
+
],
|
|
181
|
+
timeoutMs: 25000,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const ends = events.filter(e => e.type === "tool_execution_end")
|
|
185
|
+
expect(ends).toHaveLength(2)
|
|
186
|
+
|
|
187
|
+
for (const end of ends) {
|
|
188
|
+
const text = JSON.parse((end as { result: { content: { text: string }[] } }).result.content[0].text)
|
|
189
|
+
expect(text).not.toHaveProperty("error")
|
|
190
|
+
expect(text).toHaveProperty("title")
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const exit = events.find(e => e.type === "exit")
|
|
194
|
+
expect(exit).toBeDefined()
|
|
195
|
+
expect((exit as { code: number }).code).toBe(0)
|
|
196
|
+
}, 30000)
|
|
197
|
+
|
|
198
|
+
it("boot probe: storeIsMap===false and cacheClass===DiskCache (realm-safe storage)", async () => {
|
|
199
|
+
const { diagPath } = await runCli({
|
|
200
|
+
params: { url: "https://example.com", format: "lean" },
|
|
201
|
+
timeoutMs: 25000,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const probe = readBootProbe(diagPath)
|
|
205
|
+
expect(probe).not.toBeNull()
|
|
206
|
+
expect(probe!["cacheClass"]).toBe("DiskCache")
|
|
207
|
+
expect(probe!["storeIsMap"]).toBe(false)
|
|
208
|
+
expect(probe!["storeTag"]).toBe("[object Object]")
|
|
209
|
+
}, 30000)
|
|
210
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* execute() contract: never throw, always return content.
|
|
3
|
+
* Unhandled exceptions propagate to the Pi TUI and crash the session.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
7
|
+
import { createExtensionHarness, type ExtensionHarness } from "@earendil-works/pi-coding-agent/testing"
|
|
8
|
+
import piFactory from "../src/index.js"
|
|
9
|
+
|
|
10
|
+
let h: ExtensionHarness
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
h = createExtensionHarness(piFactory, { cwd: "/tmp" })
|
|
14
|
+
await h.boot()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await h.shutdown()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe("stream hygiene: extension must not write to stdout or stderr", () => {
|
|
22
|
+
it("boot produces no stdout/stderr leaks", () => {
|
|
23
|
+
expect(h.leaks).toHaveLength(0)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe("execute() error contract: never throws, always returns content", () => {
|
|
28
|
+
it("invalid URL scheme returns error, does not throw", async () => {
|
|
29
|
+
const result = await h.invokeTool("web_fetch", { url: "ftp://not-supported.example.com" }) as any
|
|
30
|
+
expect(result).toHaveProperty("content")
|
|
31
|
+
const text = JSON.parse(result.content[0].text)
|
|
32
|
+
expect(text).toHaveProperty("error")
|
|
33
|
+
expect(typeof text.error).toBe("string")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("unreachable host returns error, does not throw", async () => {
|
|
37
|
+
const result = await h.invokeTool("web_fetch", {
|
|
38
|
+
url: "http://this-host-does-not-exist-pivi-test.invalid",
|
|
39
|
+
timeoutMs: 3000,
|
|
40
|
+
}) as any
|
|
41
|
+
expect(result).toHaveProperty("content")
|
|
42
|
+
const text = JSON.parse(result.content[0].text)
|
|
43
|
+
expect(text).toHaveProperty("error")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("missing url returns cache listing (not error), does not throw", async () => {
|
|
47
|
+
// No url → local materialized view path: returns cache listing, not an error.
|
|
48
|
+
// This is intentional: omitting url is the way to query the session cache.
|
|
49
|
+
const result = await h.invokeTool("web_fetch", {}) as any
|
|
50
|
+
expect(result).toHaveProperty("content")
|
|
51
|
+
const text = JSON.parse(result.content[0].text)
|
|
52
|
+
expect(text).not.toHaveProperty("error")
|
|
53
|
+
expect(text).toHaveProperty("total")
|
|
54
|
+
expect(typeof text.total).toBe("number")
|
|
55
|
+
expect(text).toHaveProperty("pages")
|
|
56
|
+
expect(Array.isArray(text.pages)).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("highlights without query returns error, does not throw", async () => {
|
|
60
|
+
const result = await h.invokeTool("web_fetch", {
|
|
61
|
+
url: "http://127.0.0.1:1",
|
|
62
|
+
format: "highlights",
|
|
63
|
+
}) as any
|
|
64
|
+
expect(result).toHaveProperty("content")
|
|
65
|
+
const text = JSON.parse(result.content[0].text)
|
|
66
|
+
expect(text).toHaveProperty("error")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mock-pi-cli.mjs — minimal Pi CLI stub for E2E integration tests.
|
|
4
|
+
*
|
|
5
|
+
* Replicates the production jiti load path (alias + tryNative:true, NOT
|
|
6
|
+
* tryNative:false as the test harness uses). This is the critical difference
|
|
7
|
+
* that lets us verify error propagation in the real runtime context.
|
|
8
|
+
*
|
|
9
|
+
* Accepts Pi CLI args subset:
|
|
10
|
+
* --extension <path> extension entry point to load
|
|
11
|
+
* --env KEY=VALUE environment overrides (repeatable)
|
|
12
|
+
* --tool <name> tool to invoke (default: web_fetch)
|
|
13
|
+
* --params <json> tool params as JSON string
|
|
14
|
+
*
|
|
15
|
+
* Emits NDJSON events on stdout matching Pi's --mode json format:
|
|
16
|
+
* { type: "tool_execution_start", toolName, args }
|
|
17
|
+
* { type: "tool_execution_end", toolName, result }
|
|
18
|
+
* { type: "exit", code }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createJiti } from "jiti";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { resolve, dirname } from "node:path";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Parse args
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const get = (flag) => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : null; };
|
|
31
|
+
const getAll = (flag) => {
|
|
32
|
+
const vals = [];
|
|
33
|
+
for (let i = 0; i < args.length - 1; i++) if (args[i] === flag) vals.push(args[i + 1]);
|
|
34
|
+
return vals;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const extensionPath = get("--extension");
|
|
38
|
+
const toolName = get("--tool") ?? "web_fetch";
|
|
39
|
+
// Support multiple --params flags: each is one sequential tool invocation in the
|
|
40
|
+
// same process (same extension instance, same cache object). This is essential for
|
|
41
|
+
// cache round-trip tests where the second call must cross the realm boundary into
|
|
42
|
+
// an already-populated in-memory cache.
|
|
43
|
+
const allParamsJson = getAll("--params");
|
|
44
|
+
if (allParamsJson.length === 0) allParamsJson.push("{}");
|
|
45
|
+
const envOverrides = Object.fromEntries(getAll("--env").map(s => s.split("=", 2)));
|
|
46
|
+
|
|
47
|
+
if (!extensionPath) { process.stderr.write("--extension required\n"); process.exit(1); }
|
|
48
|
+
|
|
49
|
+
for (const [k, v] of Object.entries(envOverrides)) process.env[k] = v;
|
|
50
|
+
|
|
51
|
+
// tryNative:true (default) — matches the real Pi Node.js binary.
|
|
52
|
+
// The test harness uses tryNative:false, which breaks vi.mock interception.
|
|
53
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
54
|
+
const piDistRoot = resolve(__dirname, "../../../../../pi-mono/packages/coding-agent/dist");
|
|
55
|
+
const require = createRequire(import.meta.url);
|
|
56
|
+
|
|
57
|
+
const alias = {
|
|
58
|
+
"@earendil-works/pi-coding-agent": resolve(piDistRoot, "index.js"),
|
|
59
|
+
"@earendil-works/pi-agent-core": resolve(piDistRoot, "../../agent/dist/index.js"),
|
|
60
|
+
"@earendil-works/pi-tui": resolve(piDistRoot, "../../tui/dist/index.js"),
|
|
61
|
+
"@earendil-works/pi-ai": resolve(piDistRoot, "../../ai/dist/index.js"),
|
|
62
|
+
"typebox": require.resolve("typebox"),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false, alias });
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
const tools = new Map();
|
|
69
|
+
|
|
70
|
+
const pi = {
|
|
71
|
+
registerTool({ name, execute }) { tools.set(name, execute); },
|
|
72
|
+
on() {},
|
|
73
|
+
registerCommand() {},
|
|
74
|
+
ui: { notify() {} },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let factory;
|
|
78
|
+
try {
|
|
79
|
+
factory = await jiti.import(resolve(extensionPath), { default: true });
|
|
80
|
+
} catch (err) {
|
|
81
|
+
process.stderr.write(`[mock-pi-cli] load error: ${err.message}\n`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof factory !== "function") {
|
|
86
|
+
process.stderr.write(`[mock-pi-cli] extension did not export a default function\n`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await factory(pi);
|
|
91
|
+
|
|
92
|
+
const execute = tools.get(toolName);
|
|
93
|
+
if (!execute) {
|
|
94
|
+
process.stderr.write(`[mock-pi-cli] tool not found: ${toolName}\n`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const paramsJson of allParamsJson) {
|
|
99
|
+
const params = JSON.parse(paramsJson);
|
|
100
|
+
emit({ type: "tool_execution_start", toolName, args: params });
|
|
101
|
+
try {
|
|
102
|
+
const result = await execute(`mock-call-${Date.now()}`, params);
|
|
103
|
+
emit({ type: "tool_execution_end", toolName, result });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
emit({ type: "tool_execution_error", toolName, error: err?.message ?? String(err) });
|
|
106
|
+
emit({ type: "exit", code: 1 });
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
emit({ type: "exit", code: 0 });
|
|
112
|
+
|
|
113
|
+
function emit(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); }
|