@bacnh85/pi-web 0.4.6 → 0.5.1

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # pi-web v0.4
1
+ # pi-web v0.5
2
2
 
3
3
  Pi extension for **unified web search, content extraction, site crawling, and page capture**.
4
4
 
@@ -148,6 +148,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
148
148
  content_chars: Type.Optional(Type.Number({ default: 20000 })),
149
149
  wait_for: Type.Optional(Type.Number({ description: "Ms to wait for Firecrawl render before extraction." })),
150
150
  mobile: Type.Optional(Type.Boolean({ default: false, description: "Mobile viewport (dynamic mode only)." })),
151
+ ...crawl4aiControlSchema,
151
152
  ...sharedControlSchema,
152
153
  }),
153
154
  async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
@@ -160,6 +161,8 @@ export default function piWebExtension(pi: ExtensionAPI) {
160
161
  timeout_ms: params.timeout_ms as number | undefined,
161
162
  wait_for: params.wait_for as number | undefined,
162
163
  mobile: params.mobile as boolean | undefined,
164
+ crawl4ai_api_token: params.crawl4ai_api_token as string | undefined,
165
+ crawl4ai_api_url: params.crawl4ai_api_url as string | undefined,
163
166
  signal,
164
167
  _ctx: ctx,
165
168
  });
@@ -24,6 +24,8 @@ export interface ExtractParams {
24
24
  timeout_ms?: number;
25
25
  wait_for?: number;
26
26
  mobile?: boolean;
27
+ crawl4ai_api_token?: string;
28
+ crawl4ai_api_url?: string;
27
29
  signal?: AbortSignal;
28
30
  /** Internal: caller-provided ctx for env lookup. */
29
31
  _ctx?: Record<string, unknown>;
@@ -126,7 +128,11 @@ async function extractDynamic(params: ExtractParams, ctx?: Record<string, unknow
126
128
  async function extractFull(params: ExtractParams, ctx?: Record<string, unknown>): Promise<ExtractResult | null> {
127
129
  let c4aiConfig: Crawl4aiConfig;
128
130
  try {
129
- c4aiConfig = loadCrawl4aiConfig({}, cwdFromContext(ctx ?? {}), includeProjectEnv(ctx ?? {}));
131
+ c4aiConfig = loadCrawl4aiConfig(
132
+ { crawl4ai_api_token: params.crawl4ai_api_token, crawl4ai_api_url: params.crawl4ai_api_url },
133
+ cwdFromContext(ctx ?? {}),
134
+ includeProjectEnv(ctx ?? {}),
135
+ );
130
136
  } catch (e) {
131
137
  throw new Error(`Crawl4AI configuration unavailable: ${sanitizeError(e)}`);
132
138
  }
@@ -175,11 +181,20 @@ export async function extractWithDiagnostics(params: ExtractParams): Promise<Ext
175
181
  }
176
182
  }
177
183
 
178
- const diagnostics = attempts.map((a) => `${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n");
179
- const mode = params.mode ?? "auto";
180
- throw new Error(
181
- `Extraction failed for ${params.url} with mode="${mode}".\n${diagnostics}\n` +
182
- "Try a different mode or use web_screenshot for a visual snapshot.",
183
- );
184
+ // All modes exhausted return the best attempt with diagnostics instead of throwing
185
+ const lastAttempt = attempts[attempts.length - 1];
186
+ const bestResult: ExtractResult = {
187
+ title: "",
188
+ markdown: `[All extraction modes failed for ${params.url}]\n\n` +
189
+ attempts.map((a) => ` ${a.mode}: ${a.status}${a.message ? ` (${a.message})` : ""}`).join("\n") +
190
+ "\n\nTry web_screenshot for a visual snapshot, or verify the URL is accessible.",
191
+ backend: lastAttempt?.backend ?? "none",
192
+ };
193
+ return {
194
+ result: bestResult,
195
+ attempts,
196
+ selectedMode: lastAttempt?.mode ?? "static",
197
+ fallbackUsed: true,
198
+ };
184
199
  }
185
200
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
4
4
  "description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, and Crawl4AI headless browser crawling.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,11 +24,13 @@
24
24
  "crawling"
25
25
  ],
26
26
  "scripts": {
27
- "test": "cd extensions && mocha"
27
+ "test": "cd extensions && npx mocha"
28
28
  },
29
29
  "files": [
30
30
  "README.md",
31
- "extensions/",
31
+ "extensions/index.ts",
32
+ "extensions/lib/",
33
+ "extensions/types.d.ts",
32
34
  "skills/"
33
35
  ],
34
36
  "pi": {
@@ -1,5 +0,0 @@
1
- require:
2
- - tsx
3
- spec:
4
- - test/**/*.test.ts
5
- timeout: 10000
@@ -1,3 +0,0 @@
1
- {
2
- "type": "module"
3
- }
@@ -1,309 +0,0 @@
1
- /**
2
- * Unit tests for pi-web config module.
3
- */
4
-
5
- import { expect } from "chai";
6
- import {
7
- stripInlineComment,
8
- parseDotenvValue,
9
- parseDotenvFile,
10
- piConfigDirs,
11
- envFileCandidates,
12
- findEnvValue,
13
- normalizeSearxngBaseUrl,
14
- normalizeFirecrawlBaseUrl,
15
- normalizeCrawl4aiApiUrl,
16
- loadCrawl4aiConfig,
17
- DEFAULT_SEARXNG_BASE_URL,
18
- DEFAULT_CRAWL4AI_API_URL,
19
- HOSTED_FIRECRAWL_BASE_URL,
20
- } from "../../lib/config";
21
-
22
- describe("stripInlineComment", () => {
23
- it("returns full value when no comment", () => {
24
- expect(stripInlineComment("hello")).to.equal("hello");
25
- expect(stripInlineComment("")).to.equal("");
26
- });
27
-
28
- it("strips comment after space", () => {
29
- expect(stripInlineComment("hello # world")).to.equal("hello ");
30
- expect(stripInlineComment("value #comment")).to.equal("value ");
31
- });
32
-
33
- it("preserves # inside single quotes", () => {
34
- expect(stripInlineComment("'hello # world'")).to.equal("'hello # world'");
35
- });
36
-
37
- it("preserves # inside double quotes", () => {
38
- expect(stripInlineComment('"hello # world"')).to.equal('"hello # world"');
39
- });
40
-
41
- it("handles escaped characters inside quotes", () => {
42
- expect(stripInlineComment('"hello \\" world" # comment')).to.equal(
43
- '"hello \\" world" ',
44
- );
45
- });
46
-
47
- it("strips comment when # is first character", () => {
48
- expect(stripInlineComment("# comment")).to.equal("");
49
- });
50
-
51
- it("handles mixed quoted and unquoted content", () => {
52
- expect(stripInlineComment("key='val' # comment")).to.equal("key='val' ");
53
- });
54
-
55
- it("handles escaped # with backslash", () => {
56
- expect(stripInlineComment("hello \\# not a comment")).to.equal(
57
- "hello \\# not a comment",
58
- );
59
- });
60
- });
61
-
62
- describe("parseDotenvValue", () => {
63
- it("trims and returns unquoted value", () => {
64
- expect(parseDotenvValue(" hello ")).to.equal("hello");
65
- });
66
-
67
- it("strips double quotes", () => {
68
- expect(parseDotenvValue('"hello"')).to.equal("hello");
69
- });
70
-
71
- it("strips single quotes", () => {
72
- expect(parseDotenvValue("'hello'")).to.equal("hello");
73
- });
74
-
75
- it("handles escape sequences in double-quoted values", () => {
76
- expect(parseDotenvValue('"hello\\nworld"')).to.equal("hello\nworld");
77
- expect(parseDotenvValue('"path\\tto\\truby"')).to.equal("path\tto\truby");
78
- });
79
-
80
- it("trims whitespace in unquoted values", () => {
81
- expect(parseDotenvValue(" hello world ")).to.equal("hello world");
82
- });
83
-
84
- it("refuses to remove double quotes from single-quoted value", () => {
85
- expect(parseDotenvValue("'hello\"'")).to.equal('hello"');
86
- });
87
-
88
- it("strips inline comment from unquoted value", () => {
89
- expect(parseDotenvValue("hello # comment")).to.equal("hello");
90
- });
91
- });
92
-
93
- describe("parseDotenvFile", () => {
94
- it("returns null for non-existent file", () => {
95
- const result = parseDotenvFile("/tmp/nonexistent-file-12345.env");
96
- expect(result).to.be.null;
97
- });
98
-
99
- it("parses a simple env file content via temp testing", async () => {
100
- const fs = await import("node:fs");
101
- const path = await import("node:path");
102
- const tmpDir = fs.mkdtempSync("/tmp/pi-web-test-");
103
- const testFile = path.join(tmpDir, ".env");
104
- fs.writeFileSync(
105
- testFile,
106
- "FOO=bar\nBAZ=qux # inline comment\n# FULL LINE COMMENT\nEMPTY=\n",
107
- "utf8",
108
- );
109
- try {
110
- const result = parseDotenvFile(testFile);
111
- expect(result).to.deep.equal({ FOO: "bar", BAZ: "qux", EMPTY: "" });
112
- } finally {
113
- fs.rmSync(tmpDir, { recursive: true, force: true });
114
- }
115
- });
116
- });
117
-
118
- describe("piConfigDirs", () => {
119
- const origEnv = process.env.PI_CODING_AGENT_DIR;
120
-
121
- afterEach(() => {
122
- if (origEnv === undefined) {
123
- delete process.env.PI_CODING_AGENT_DIR;
124
- } else {
125
- process.env.PI_CODING_AGENT_DIR = origEnv;
126
- }
127
- });
128
-
129
- it("returns custom dir when PI_CODING_AGENT_DIR is set", () => {
130
- process.env.PI_CODING_AGENT_DIR = "/custom/pi";
131
- const dirs = piConfigDirs();
132
- expect(dirs).to.deep.equal(["/custom/pi"]);
133
- });
134
-
135
- it("returns default dir when PI_CODING_AGENT_DIR is not set", () => {
136
- delete process.env.PI_CODING_AGENT_DIR;
137
- const dirs = piConfigDirs();
138
- expect(dirs).to.have.length(1);
139
- expect(dirs[0]).to.include(".pi/agent");
140
- });
141
- });
142
-
143
- describe("envFileCandidates", () => {
144
- it("includes cwd files and pi config dirs", () => {
145
- const candidates = envFileCandidates("/test/project", true);
146
- expect(candidates[0]).to.equal("/test/project/.env.local");
147
- expect(candidates[1]).to.equal("/test/project/.env");
148
- });
149
-
150
- it("omits cwd files when includeCwd is false", () => {
151
- const candidates = envFileCandidates("/test/project", false);
152
- expect(candidates[0]).not.to.include("/test/project");
153
- });
154
-
155
- it("returns pi config dir entries", () => {
156
- const candidates = envFileCandidates("/test/project", true);
157
- expect(candidates.length).to.be.at.least(2); // cwd .env.local, cwd .env
158
- });
159
- });
160
-
161
- describe("normalizeSearxngBaseUrl", () => {
162
- it("returns default when nothing provided", () => {
163
- expect(normalizeSearxngBaseUrl()).to.equal(DEFAULT_SEARXNG_BASE_URL);
164
- });
165
-
166
- it("preserves full URL with scheme", () => {
167
- expect(normalizeSearxngBaseUrl("https://search.example.com")).to.equal(
168
- "https://search.example.com",
169
- );
170
- });
171
-
172
- it("adds http for localhost", () => {
173
- expect(normalizeSearxngBaseUrl("localhost:8888")).to.equal(
174
- "http://localhost:8888",
175
- );
176
- });
177
-
178
- it("adds http for private IP", () => {
179
- expect(normalizeSearxngBaseUrl("192.168.1.1:8888")).to.equal(
180
- "http://192.168.1.1:8888",
181
- );
182
- });
183
-
184
- it("adds https for public hostname", () => {
185
- expect(normalizeSearxngBaseUrl("search.example.com")).to.equal(
186
- "https://search.example.com",
187
- );
188
- });
189
-
190
- it("removes trailing slashes", () => {
191
- expect(normalizeSearxngBaseUrl("http://localhost:8888/")).to.equal(
192
- "http://localhost:8888",
193
- );
194
- });
195
- });
196
-
197
- describe("normalizeFirecrawlBaseUrl", () => {
198
- it("returns hosted default when nothing provided", () => {
199
- expect(normalizeFirecrawlBaseUrl()).to.equal(HOSTED_FIRECRAWL_BASE_URL);
200
- });
201
-
202
- it("appends /v2 if no version segment", () => {
203
- expect(
204
- normalizeFirecrawlBaseUrl("http://localhost:3002"),
205
- ).to.equal("http://localhost:3002/v2");
206
- });
207
-
208
- it("preserves existing version segment", () => {
209
- expect(
210
- normalizeFirecrawlBaseUrl("http://localhost:3002/v1"),
211
- ).to.equal("http://localhost:3002/v1");
212
- });
213
-
214
- it("removes trailing slash before appending version", () => {
215
- expect(
216
- normalizeFirecrawlBaseUrl("http://localhost:3002/"),
217
- ).to.equal("http://localhost:3002/v2");
218
- });
219
- });
220
-
221
- describe("findEnvValue", () => {
222
- it("reads from process.env first", () => {
223
- process.env.TEST_PI_WEB_VAR = "from_process";
224
- try {
225
- const result = findEnvValue("TEST_PI_WEB_VAR", "/tmp", false);
226
- expect(result.value).to.equal("from_process");
227
- expect(result.source).to.equal("process.env");
228
- } finally {
229
- delete process.env.TEST_PI_WEB_VAR;
230
- }
231
- });
232
-
233
- it("returns undefined when not found", () => {
234
- const result = findEnvValue("THIS_VAR_DOES_NOT_EXIST_12345", "/tmp", false);
235
- expect(result.value).to.be.undefined;
236
- expect(result.source).to.equal("");
237
- });
238
- });
239
-
240
- describe("normalizeCrawl4aiApiUrl", () => {
241
- it("returns default when nothing provided", () => {
242
- expect(normalizeCrawl4aiApiUrl()).to.equal(DEFAULT_CRAWL4AI_API_URL);
243
- });
244
-
245
- it("preserves full URL with scheme", () => {
246
- expect(normalizeCrawl4aiApiUrl("https://crawl.example.com")).to.equal(
247
- "https://crawl.example.com",
248
- );
249
- });
250
-
251
- it("adds http for private IP", () => {
252
- expect(normalizeCrawl4aiApiUrl("172.30.55.22:11235")).to.equal(
253
- "http://172.30.55.22:11235",
254
- );
255
- });
256
-
257
- it("adds https for public hostname", () => {
258
- expect(normalizeCrawl4aiApiUrl("crawl.example.com")).to.equal(
259
- "https://crawl.example.com",
260
- );
261
- });
262
-
263
- it("removes trailing slashes", () => {
264
- expect(normalizeCrawl4aiApiUrl("http://localhost:11235/")).to.equal(
265
- "http://localhost:11235",
266
- );
267
- });
268
-
269
- it("does not append /v2 like Firecrawl", () => {
270
- expect(normalizeCrawl4aiApiUrl("http://localhost:11235")).to.equal(
271
- "http://localhost:11235",
272
- );
273
- });
274
- });
275
-
276
- describe("loadCrawl4aiConfig", () => {
277
- it("returns default config from defaults", () => {
278
- const config = loadCrawl4aiConfig({}, "/tmp", false);
279
- expect(config.baseUrl).to.equal(DEFAULT_CRAWL4AI_API_URL);
280
- expect(config.timeoutMs).to.equal(60000);
281
- });
282
-
283
- it("accepts explicit API URL from params", () => {
284
- const config = loadCrawl4aiConfig(
285
- { crawl4ai_api_url: "http://custom:12345" },
286
- "/tmp",
287
- false,
288
- );
289
- expect(config.baseUrl).to.equal("http://custom:12345");
290
- });
291
-
292
- it("accepts explicit API token from params", () => {
293
- const config = loadCrawl4aiConfig(
294
- { crawl4ai_api_token: "my-token" },
295
- "/tmp",
296
- false,
297
- );
298
- expect(config.apiToken).to.equal("my-token");
299
- });
300
-
301
- it("reads timeout from params", () => {
302
- const config = loadCrawl4aiConfig({ timeout_ms: 30000 }, "/tmp", false);
303
- expect(config.timeoutMs).to.equal(30000);
304
- });
305
-
306
- it("throws on invalid timeout", () => {
307
- expect(() => loadCrawl4aiConfig({ timeout_ms: 500 }, "/tmp", false)).to.throw();
308
- });
309
- });
@@ -1,69 +0,0 @@
1
- /**
2
- * Unit tests for pi-web Crawl4AI client.
3
- */
4
-
5
- import { expect } from "chai";
6
- import { Crawl4aiHttpError } from "../../lib/crawl4ai";
7
-
8
- describe("Crawl4aiHttpError", () => {
9
- it("formats error message with status and text", () => {
10
- const err = new Crawl4aiHttpError(401, "Unauthorized", '{"detail":"bad token"}');
11
- expect(err.status).to.equal(401);
12
- expect(err.message).to.include("HTTP 401: Unauthorized");
13
- expect(err.message).to.include('{"detail":"bad token"}');
14
- });
15
-
16
- it("handles empty response text", () => {
17
- const err = new Crawl4aiHttpError(500, "Internal Server Error", "");
18
- expect(err.status).to.equal(500);
19
- expect(err.message).to.equal("HTTP 500: Internal Server Error");
20
- });
21
-
22
- it("is instance of Error", () => {
23
- const err = new Crawl4aiHttpError(403, "Forbidden", "rate limit");
24
- expect(err).to.be.instanceOf(Error);
25
- });
26
- });
27
-
28
- // ---------------------------------------------------------------------------
29
- // Config + format integration: verify imported functions exist with correct sigs
30
- // ---------------------------------------------------------------------------
31
-
32
- import type { Crawl4aiConfig } from "../../lib/config";
33
-
34
- describe("Crawl4aiConfig interface", () => {
35
- it("has expected shape", () => {
36
- const config: Crawl4aiConfig = {
37
- baseUrl: "http://localhost:11235",
38
- apiToken: "token123",
39
- timeoutMs: 30000,
40
- };
41
- expect(config.baseUrl).to.equal("http://localhost:11235");
42
- expect(config.apiToken).to.equal("token123");
43
- expect(config.timeoutMs).to.equal(30000);
44
- });
45
- });
46
-
47
- // ---------------------------------------------------------------------------
48
- // Verify client exports exist
49
- // ---------------------------------------------------------------------------
50
-
51
- import {
52
- fetchCrawl4aiMarkdown,
53
- fetchCrawl4aiCrawl,
54
- fetchCrawl4aiScreenshot,
55
- fetchCrawl4aiPdf,
56
- fetchCrawl4aiHealth,
57
- crawl4aiRequest,
58
- } from "../../lib/crawl4ai";
59
-
60
- describe("Crawl4AI client exports", () => {
61
- it("exports all expected functions", () => {
62
- expect(fetchCrawl4aiMarkdown).to.be.a("function");
63
- expect(fetchCrawl4aiCrawl).to.be.a("function");
64
- expect(fetchCrawl4aiScreenshot).to.be.a("function");
65
- expect(fetchCrawl4aiPdf).to.be.a("function");
66
- expect(fetchCrawl4aiHealth).to.be.a("function");
67
- expect(crawl4aiRequest).to.be.a("function");
68
- });
69
- });
@@ -1,134 +0,0 @@
1
- import { expect } from "chai";
2
-
3
- import {
4
- extractWithDiagnostics,
5
- type ExtractMode,
6
- type ExtractParams,
7
- type ExtractResult,
8
- } from "../../lib/extract";
9
-
10
- const ORIGINAL_ENV = { ...process.env };
11
- const ORIGINAL_FETCH = globalThis.fetch;
12
-
13
- function jsonResponse(body: unknown, status = 200): Response {
14
- return new Response(JSON.stringify(body), {
15
- status,
16
- headers: { "content-type": "application/json" },
17
- });
18
- }
19
-
20
- function htmlResponse(body: string, status = 200): Response {
21
- return new Response(body, {
22
- status,
23
- headers: { "content-type": "text/html" },
24
- });
25
- }
26
-
27
- function installMockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>): string[] {
28
- const calls: string[] = [];
29
- globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
30
- const url = String(input);
31
- calls.push(url);
32
- return handler(url, init);
33
- }) as typeof fetch;
34
- return calls;
35
- }
36
-
37
- function restoreEnv(): void {
38
- process.env = { ...ORIGINAL_ENV };
39
- }
40
-
41
- describe("ExtractParams and ExtractResult types", () => {
42
- it("accept expected fields", () => {
43
- const modes: ExtractMode[] = ["auto", "static", "dynamic", "full"];
44
- const params: ExtractParams = { url: "https://example.com", mode: modes[0], wait_for: 1000, mobile: true };
45
- const result: ExtractResult = { title: "Title", markdown: "Content", backend: "static", structured: { ok: true } };
46
- expect(params.mode).to.equal("auto");
47
- expect(result.structured).to.deep.equal({ ok: true });
48
- });
49
- });
50
-
51
- describe("extractWithDiagnostics", () => {
52
- beforeEach(() => {
53
- restoreEnv();
54
- process.env.FIRECRAWL_API_URL = "http://firecrawl.test/v2";
55
- process.env.CRAWL4AI_API_URL = "http://crawl4ai.test";
56
- });
57
-
58
- afterEach(() => {
59
- globalThis.fetch = ORIGINAL_FETCH;
60
- restoreEnv();
61
- });
62
-
63
- it("uses static extraction when it returns useful content", async () => {
64
- installMockFetch((url) => {
65
- if (url === "https://example.com/static") {
66
- return htmlResponse("<html><head><title>Static</title></head><body><main><h1>Static</h1><p>This static article has enough readable text to pass the useful-content threshold in auto mode without falling back.</p><p>Additional words make it reliably longer than the minimum threshold.</p></main></body></html>");
67
- }
68
- throw new Error(`unexpected fetch ${url}`);
69
- });
70
-
71
- const diagnostics = await extractWithDiagnostics({ url: "https://example.com/static" });
72
- expect(diagnostics.selectedMode).to.equal("static");
73
- expect(diagnostics.fallbackUsed).to.be.false;
74
- expect(diagnostics.result.markdown).to.include("static article");
75
- });
76
-
77
- it("falls through from short static content to dynamic extraction", async () => {
78
- const calls = installMockFetch((url) => {
79
- if (url === "https://example.com/short") return htmlResponse("<html><body><main>short</main></body></html>");
80
- if (url === "http://firecrawl.test/v2/scrape") {
81
- return jsonResponse({ data: { markdown: "# Dynamic\n\nDynamic content from Firecrawl after static extraction was too short.", metadata: { title: "Dynamic", sourceURL: "https://example.com/short" } } });
82
- }
83
- throw new Error(`unexpected fetch ${url}`);
84
- });
85
-
86
- const diagnostics = await extractWithDiagnostics({ url: "https://example.com/short" });
87
- expect(diagnostics.selectedMode).to.equal("dynamic");
88
- expect(diagnostics.fallbackUsed).to.be.true;
89
- expect(diagnostics.attempts.map((a) => a.mode)).to.deep.equal(["static", "dynamic"]);
90
- expect(diagnostics.result.markdown).to.include("fell back to Firecrawl");
91
- expect(calls).to.deep.equal(["https://example.com/short", "http://firecrawl.test/v2/scrape"]);
92
- });
93
-
94
- it("explicit static mode does not fall through", async () => {
95
- installMockFetch((url) => {
96
- if (url === "https://example.com/short") return htmlResponse("<html><body><main>short</main></body></html>");
97
- throw new Error(`unexpected fetch ${url}`);
98
- });
99
-
100
- const diagnostics = await extractWithDiagnostics({ url: "https://example.com/short", mode: "static" });
101
- expect(diagnostics.selectedMode).to.equal("static");
102
- expect(diagnostics.result.markdown).to.include("short");
103
- });
104
-
105
- it("dynamic mode includes structured JSON output when present", async () => {
106
- installMockFetch((url, init) => {
107
- if (url === "http://firecrawl.test/v2/scrape") {
108
- const body = JSON.parse(String(init?.body));
109
- expect(body.formats).to.deep.include({ type: "json", prompt: "Extract title" });
110
- return jsonResponse({ data: { markdown: "# Dynamic\n\nBody", json: { title: "Structured" }, metadata: { title: "Dynamic" } } });
111
- }
112
- throw new Error(`unexpected fetch ${url}`);
113
- });
114
-
115
- const diagnostics = await extractWithDiagnostics({ url: "https://example.com/dynamic", mode: "dynamic", prompt: "Extract title" });
116
- expect(diagnostics.result.structured).to.deep.equal({ title: "Structured" });
117
- expect(diagnostics.result.markdown).to.include("## Structured extraction");
118
- });
119
-
120
- it("falls through to full mode when static and dynamic fail", async () => {
121
- installMockFetch((url) => {
122
- if (url === "https://example.com/full") return htmlResponse("<html><body><main>tiny</main></body></html>");
123
- if (url === "http://firecrawl.test/v2/scrape") return jsonResponse({ error: "blocked" }, 500);
124
- if (url === "http://crawl4ai.test/md") return jsonResponse({ success: true, markdown: "# Full\n\nCrawl4AI markdown content" });
125
- throw new Error(`unexpected fetch ${url}`);
126
- });
127
-
128
- const diagnostics = await extractWithDiagnostics({ url: "https://example.com/full" });
129
- expect(diagnostics.selectedMode).to.equal("full");
130
- expect(diagnostics.attempts.map((a) => a.mode)).to.deep.equal(["static", "dynamic", "full"]);
131
- expect(diagnostics.result.markdown).to.include("Crawl4AI");
132
- });
133
-
134
- });
@@ -1,194 +0,0 @@
1
- /**
2
- * Unit tests for pi-web format module.
3
- */
4
-
5
- import { expect } from "chai";
6
- import {
7
- sanitizeSnippet,
8
- truncateText,
9
- formatFirecrawlScrape,
10
- formatCrawl4aiResult,
11
- type SearchResultItem,
12
- } from "../../lib/format";
13
-
14
- const OUTPUT_MAX_BYTES = 50 * 1024;
15
- const OUTPUT_MAX_LINES = 2_000;
16
-
17
- describe("sanitizeSnippet", () => {
18
- it("returns empty string for empty input", () => {
19
- expect(sanitizeSnippet()).to.equal("");
20
- expect(sanitizeSnippet("")).to.equal("");
21
- });
22
-
23
- it("strips HTML tags", () => {
24
- expect(sanitizeSnippet("<b>hello</b> world")).to.equal("hello world");
25
- });
26
-
27
- it("decodes HTML entities", () => {
28
- expect(sanitizeSnippet("foo &amp; bar")).to.equal("foo & bar");
29
- expect(sanitizeSnippet("&lt;tag&gt;")).to.equal("<tag>");
30
- expect(sanitizeSnippet("&quot;quoted&quot;")).to.equal('"quoted"');
31
- expect(sanitizeSnippet("hello&nbsp;world")).to.equal("hello world");
32
- });
33
-
34
- it("decodes numeric HTML entities", () => {
35
- expect(sanitizeSnippet("&#65;")).to.equal("A");
36
- expect(sanitizeSnippet("&#x41;")).to.equal("A");
37
- });
38
-
39
- it("collapses whitespace", () => {
40
- expect(sanitizeSnippet("hello world")).to.equal("hello world");
41
- expect(sanitizeSnippet(" hello ")).to.equal("hello");
42
- });
43
- });
44
-
45
- describe("truncateText", () => {
46
- it("returns short text as-is", () => {
47
- const text = "hello world";
48
- expect(truncateText(text)).to.equal(text);
49
- });
50
-
51
- it("truncates by line count when exceeding OUTPUT_MAX_LINES", () => {
52
- const lines = Array.from({ length: OUTPUT_MAX_LINES + 10 }, (_, i) => `line ${i}`);
53
- const text = lines.join("\n");
54
- const result = truncateText(text);
55
- expect(result).to.include("[Web output truncated to");
56
- expect(result.split("\n").length).to.be.lessThan(OUTPUT_MAX_LINES + 15);
57
- });
58
-
59
- it("truncates by byte count when exceeding OUTPUT_MAX_BYTES", () => {
60
- const text = "x".repeat(OUTPUT_MAX_BYTES + 50000);
61
- const result = truncateText(text);
62
- expect(result).to.include("[Web output truncated to");
63
- });
64
-
65
- it("handles empty string", () => {
66
- expect(truncateText("")).to.equal("");
67
- });
68
- });
69
-
70
- describe("formatFirecrawlScrape", () => {
71
- it("formats scrape data with metadata", () => {
72
- const data = {
73
- data: {
74
- metadata: {
75
- title: "Test Page",
76
- sourceURL: "https://example.com",
77
- statusCode: 200,
78
- },
79
- markdown: "# Hello\n\nThis is content.",
80
- },
81
- };
82
- const output = formatFirecrawlScrape(data);
83
- expect(output).to.include("# Test Page");
84
- expect(output).to.include("Source: https://example.com");
85
- expect(output).to.include("Status: 200");
86
- expect(output).to.include("# Hello");
87
- expect(output).to.include("This is content.");
88
- });
89
-
90
- it("resolves data at top level when no nested data", () => {
91
- const data = {
92
- metadata: { title: "Direct" },
93
- markdown: "Content",
94
- };
95
- const output = formatFirecrawlScrape(data);
96
- expect(output).to.include("# Direct");
97
- expect(output).to.include("Content");
98
- });
99
-
100
- it("includes warning when present", () => {
101
- const data = { data: { markdown: "x" }, warning: "Rate limited" };
102
- const output = formatFirecrawlScrape(data);
103
- expect(output).to.include("Warning: Rate limited");
104
- });
105
-
106
- it("includes links when present", () => {
107
- const data = {
108
- data: {
109
- markdown: "body",
110
- links: ["https://a.com", "https://b.com"],
111
- },
112
- };
113
- const output = formatFirecrawlScrape(data);
114
- expect(output).to.include("## Links");
115
- expect(output).to.include("- https://a.com");
116
- });
117
-
118
- it("returns JSON stringify fallback for empty data", () => {
119
- const data = { foo: "bar" };
120
- const output = formatFirecrawlScrape(data);
121
- expect(output).to.include("foo");
122
- });
123
- });
124
-
125
- describe("formatCrawl4aiResult", () => {
126
- it("formats a single successful crawl result with markdown", () => {
127
- const data = {
128
- url: "https://example.com",
129
- success: true,
130
- status_code: 200,
131
- markdown: { fit_markdown: "# Hello\n\nWorld content." },
132
- };
133
- const output = formatCrawl4aiResult(data);
134
- expect(output).to.include("URL: https://example.com");
135
- expect(output).to.include("Status: 200");
136
- expect(output).to.include("# Hello");
137
- });
138
-
139
- it("shows error for failed crawl", () => {
140
- const data = {
141
- url: "https://example.com/404",
142
- success: false,
143
- error_message: "Not Found",
144
- };
145
- const output = formatCrawl4aiResult(data);
146
- expect(output).to.include("Error: Not Found");
147
- expect(output).to.include("URL: https://example.com/404");
148
- });
149
-
150
- it("formats multiple results from wrapped response", () => {
151
- const data = {
152
- results: [
153
- { url: "https://a.com", success: true, markdown: { raw_markdown: "Page A" } },
154
- { url: "https://b.com", success: true, markdown: { raw_markdown: "Page B" } },
155
- ],
156
- };
157
- const output = formatCrawl4aiResult(data);
158
- expect(output).to.include("=== Result 1 ===");
159
- expect(output).to.include("=== Result 2 ===");
160
- expect(output).to.include("Page A");
161
- expect(output).to.include("Page B");
162
- });
163
-
164
- it("falls back to raw_markdown when fit_markdown is absent", () => {
165
- const data = {
166
- url: "https://example.com",
167
- success: true,
168
- markdown: { raw_markdown: "Raw content only" },
169
- };
170
- const output = formatCrawl4aiResult(data);
171
- expect(output).to.include("Raw content only");
172
- });
173
-
174
- it("includes links when present", () => {
175
- const data = {
176
- url: "https://example.com",
177
- success: true,
178
- markdown: { fit_markdown: "body" },
179
- links: {
180
- internal: [{ href: "https://example.com/about" }],
181
- external: [{ href: "https://other.com" }],
182
- },
183
- };
184
- const output = formatCrawl4aiResult(data);
185
- expect(output).to.include("Links");
186
- expect(output).to.include("https://example.com/about");
187
- });
188
-
189
- it("returns fallback for empty data", () => {
190
- expect(formatCrawl4aiResult({})).to.equal("(No data)");
191
- });
192
- });
193
-
194
-
@@ -1,147 +0,0 @@
1
- import { expect } from "chai";
2
-
3
- import {
4
- searchWithDiagnostics,
5
- selectSearchBackendOrder,
6
- type SearchParams,
7
- type SearchResult,
8
- } from "../../lib/search";
9
-
10
- const ORIGINAL_ENV = { ...process.env };
11
- const ORIGINAL_FETCH = globalThis.fetch;
12
-
13
- function jsonResponse(body: unknown, status = 200): Response {
14
- return new Response(JSON.stringify(body), {
15
- status,
16
- headers: { "content-type": "application/json" },
17
- });
18
- }
19
-
20
- function htmlResponse(body: string, status = 200): Response {
21
- return new Response(body, {
22
- status,
23
- headers: { "content-type": "text/html" },
24
- });
25
- }
26
-
27
- function installMockFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>): string[] {
28
- const calls: string[] = [];
29
- globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
30
- const url = String(input);
31
- calls.push(url);
32
- return handler(url, init);
33
- }) as typeof fetch;
34
- return calls;
35
- }
36
-
37
- function restoreEnv(): void {
38
- process.env = { ...ORIGINAL_ENV };
39
- }
40
-
41
- describe("SearchParams and SearchResult types", () => {
42
- it("accept expected fields", () => {
43
- const params: SearchParams = { query: "test", backend: "auto", signal: new AbortController().signal };
44
- const result: SearchResult = { title: "T", url: "https://example.com", snippet: "S", age: "", content: "", backend: "brave" };
45
- expect(params.backend).to.equal("auto");
46
- expect(result.backend).to.equal("brave");
47
- });
48
- });
49
-
50
- describe("selectSearchBackendOrder", () => {
51
- it("selects Brave first for include_content", () => {
52
- expect(selectSearchBackendOrder({ query: "homelab ansible", include_content: true })).to.deep.equal(["brave", "searxng", "firecrawl"]);
53
- });
54
-
55
- it("selects Brave first for site: and precision queries", () => {
56
- expect(selectSearchBackendOrder({ query: "site:docs.ansible.com podman quadlet" })).to.deep.equal(["brave", "searxng", "firecrawl"]);
57
- expect(selectSearchBackendOrder({ query: '"exact phrase" release notes' })).to.deep.equal(["brave", "searxng", "firecrawl"]);
58
- });
59
-
60
- it("keeps SearXNG first for broad discovery and explicit engines", () => {
61
- expect(selectSearchBackendOrder({ query: "homelab ansible ideas" })).to.deep.equal(["searxng", "brave", "firecrawl"]);
62
- expect(selectSearchBackendOrder({ query: "docs api", engines: "google,github" })).to.deep.equal(["searxng", "brave", "firecrawl"]);
63
- });
64
-
65
- it("honors explicit backend", () => {
66
- expect(selectSearchBackendOrder({ query: "test", backend: "firecrawl" })).to.deep.equal(["firecrawl"]);
67
- });
68
- });
69
-
70
- describe("searchWithDiagnostics", () => {
71
- beforeEach(() => {
72
- restoreEnv();
73
- process.env.BRAVE_API_KEY = "test-brave-key";
74
- process.env.SEARXNG_BASE_URL = "http://searxng.test";
75
- process.env.FIRECRAWL_API_URL = "http://firecrawl.test/v2";
76
- });
77
-
78
- afterEach(() => {
79
- globalThis.fetch = ORIGINAL_FETCH;
80
- restoreEnv();
81
- });
82
-
83
- it("uses Brave first for include_content and fetches inline content", async () => {
84
- const calls = installMockFetch((url) => {
85
- if (url.startsWith("https://api.search.brave.com/")) {
86
- return jsonResponse({ web: { results: [{ title: "Brave", url: "https://example.com/page", description: "Snippet" }] } });
87
- }
88
- if (url === "https://example.com/page") {
89
- return htmlResponse("<html><head><title>Page</title></head><body><main><h1>Page</h1><p>This is long enough readable content for the inline content fetch test.</p></main></body></html>");
90
- }
91
- throw new Error(`unexpected fetch ${url}`);
92
- });
93
-
94
- const result = await searchWithDiagnostics({ query: "homelab ansible", include_content: true });
95
- expect(result.selectedBackend).to.equal("brave");
96
- expect(result.backendOrder[0]).to.equal("brave");
97
- expect(result.results[0].content).to.include("readable content");
98
- expect(calls[0]).to.include("api.search.brave.com");
99
- });
100
-
101
- it("uses SearXNG first for broad discovery", async () => {
102
- const calls = installMockFetch((url) => {
103
- if (url.startsWith("http://searxng.test/search")) {
104
- return jsonResponse({ results: [{ title: "SearXNG", url: "https://example.com", content: "Snippet" }] });
105
- }
106
- throw new Error(`unexpected fetch ${url}`);
107
- });
108
-
109
- const result = await searchWithDiagnostics({ query: "homelab ansible ideas" });
110
- expect(result.selectedBackend).to.equal("searxng");
111
- expect(result.attempts).to.deep.include({ backend: "searxng", status: "success", message: "Selected searxng", resultCount: 1 });
112
- expect(calls[0]).to.include("searxng.test");
113
- });
114
-
115
- it("captures backend errors and falls through to Firecrawl last", async () => {
116
- installMockFetch((url) => {
117
- if (url.startsWith("http://searxng.test/search")) return jsonResponse({ results: [] });
118
- if (url.startsWith("https://api.search.brave.com/")) return jsonResponse({ error: "rate limited" }, 429);
119
- if (url.startsWith("http://firecrawl.test/v2/search")) {
120
- return jsonResponse({ data: { web: [{ title: "Fire", url: "https://fire.example", description: "Fallback" }] } });
121
- }
122
- throw new Error(`unexpected fetch ${url}`);
123
- });
124
-
125
- const result = await searchWithDiagnostics({ query: "homelab ansible ideas" });
126
- expect(result.selectedBackend).to.equal("firecrawl");
127
- expect(result.attempts.map((a) => a.backend)).to.deep.equal(["searxng", "brave", "firecrawl"]);
128
- expect(result.attempts[1].status).to.equal("error");
129
- expect(result.attempts[1].message).not.to.include("test-brave-key");
130
- });
131
-
132
- it("explicit backend tries only that backend", async () => {
133
- const calls = installMockFetch((url) => {
134
- if (url.startsWith("http://searxng.test/search")) return jsonResponse({ results: [] });
135
- throw new Error(`unexpected fetch ${url}`);
136
- });
137
-
138
- try {
139
- await searchWithDiagnostics({ query: "nothing", backend: "searxng" });
140
- expect.fail("expected search to fail");
141
- } catch (e: any) {
142
- expect(e.message).to.include("searxng: empty");
143
- expect(calls).to.have.length(1);
144
- }
145
- });
146
-
147
- });