@bacnh85/pi-web 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/extensions/index.ts +63 -75
- package/extensions/lib/extract.ts +7 -1
- package/package.json +4 -2
- package/extensions/.mocharc.yml +0 -5
- package/extensions/package.json +0 -3
- package/extensions/test/unit/config.test.ts +0 -309
- package/extensions/test/unit/crawl4ai.test.ts +0 -69
- package/extensions/test/unit/extract.test.ts +0 -134
- package/extensions/test/unit/format.test.ts +0 -194
- package/extensions/test/unit/search.test.ts +0 -147
package/README.md
CHANGED
package/extensions/index.ts
CHANGED
|
@@ -38,15 +38,13 @@ const sharedControlSchema = {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
const firecrawlControlSchema = {
|
|
41
|
-
firecrawl_api_key: Type.Optional(Type.String({ description: "
|
|
42
|
-
firecrawl_api_url: Type.Optional(Type.String({ description: "
|
|
43
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
41
|
+
firecrawl_api_key: Type.Optional(Type.String({ description: "Override $FIRECRAWL_API_KEY." })),
|
|
42
|
+
firecrawl_api_url: Type.Optional(Type.String({ description: "Override $FIRECRAWL_API_URL." })),
|
|
44
43
|
};
|
|
45
44
|
|
|
46
45
|
const crawl4aiControlSchema = {
|
|
47
|
-
crawl4ai_api_url: Type.Optional(Type.String({ description: "
|
|
48
|
-
crawl4ai_api_token: Type.Optional(Type.String({ description: "
|
|
49
|
-
timeout_ms: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
|
46
|
+
crawl4ai_api_url: Type.Optional(Type.String({ description: "Override $CRAWL4AI_API_URL." })),
|
|
47
|
+
crawl4ai_api_token: Type.Optional(Type.String({ description: "Override $CRAWL4AI_API_TOKEN." })),
|
|
50
48
|
};
|
|
51
49
|
|
|
52
50
|
|
|
@@ -82,29 +80,26 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
82
80
|
name: "web_search",
|
|
83
81
|
label: "Web Search",
|
|
84
82
|
description:
|
|
85
|
-
"Search the web. Auto-selects backends
|
|
83
|
+
"Search the web. Auto-selects backends: SearXNG, Brave, Firecrawl.",
|
|
86
84
|
promptSnippet: "Search current web results",
|
|
87
85
|
promptGuidelines: [
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"Use
|
|
92
|
-
"
|
|
93
|
-
"include_content prefers Brave automatically because only Brave search fetches inline page content; for other result URLs, use web_extract.",
|
|
94
|
-
"Note: Firecrawl Search has poor semantic accuracy on domain-specific/ambiguous queries; prefer SearXNG or Brave for precision.",
|
|
95
|
-
"Cite result URLs when web results materially support the answer.",
|
|
86
|
+
"Source discovery, docs, facts, and general search.",
|
|
87
|
+
"Broad discovery uses SearXNG; precision/site/docs use Brave; Firecrawl is fallback.",
|
|
88
|
+
"Force backend via backend:'brave'|'searxng' for poor auto results.",
|
|
89
|
+
"Use engines='google,github' for SearXNG tuning.",
|
|
90
|
+
"Cite source URLs.",
|
|
96
91
|
],
|
|
97
92
|
parameters: Type.Object({
|
|
98
93
|
query: Type.String(),
|
|
99
94
|
count: Type.Optional(Type.Number({ default: 5 })),
|
|
100
|
-
freshness: Type.Optional(Type.String({ description: "Time filter:
|
|
95
|
+
freshness: Type.Optional(Type.String({ description: "Time filter: pw/pm/py or YYYY-MM-DDtoYYYY-MM-DD." })),
|
|
101
96
|
country: Type.Optional(Type.String({ default: "US" })),
|
|
102
97
|
backend: Type.Optional(Type.Union(
|
|
103
98
|
[Type.Literal("auto"), Type.Literal("searxng"), Type.Literal("brave"), Type.Literal("firecrawl")],
|
|
104
|
-
{ default: "auto", description: "
|
|
99
|
+
{ default: "auto", description: "auto, searxng, brave, firecrawl." },
|
|
105
100
|
)),
|
|
106
|
-
engines: Type.Optional(Type.String({ description: "SearXNG engine
|
|
107
|
-
include_content: Type.Optional(Type.Boolean({ default: false, description: "Fetch page content
|
|
101
|
+
engines: Type.Optional(Type.String({ description: "SearXNG engine list (google,github). Only for searxng/auto backend." })),
|
|
102
|
+
include_content: Type.Optional(Type.Boolean({ default: false, description: "Fetch inline page content (slower)." })),
|
|
108
103
|
content_chars: Type.Optional(Type.Number({ default: 5000 })),
|
|
109
104
|
...sharedControlSchema,
|
|
110
105
|
}),
|
|
@@ -133,30 +128,27 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
133
128
|
name: "web_extract",
|
|
134
129
|
label: "Web Content Extraction",
|
|
135
130
|
description:
|
|
136
|
-
"Extract readable content from a URL. Auto
|
|
131
|
+
"Extract readable content from a URL. Auto mode: static\u2192dynamic\u2192full.",
|
|
137
132
|
promptSnippet: "Extract readable webpage content as markdown",
|
|
138
133
|
promptGuidelines: [
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"Use
|
|
143
|
-
"
|
|
144
|
-
"⚠️ Dynamic mode (Firecrawl Scrape) may fail on bot-protected sites (Ansible docs, CDN-backed doc sites). Full mode (Crawl4AI) handles more sites but is heavier.",
|
|
145
|
-
"If all extraction modes fail, try web_screenshot for a visual snapshot, or the page may require interactive login.",
|
|
146
|
-
"Use the prompt and schema parameters for structured JSON extraction (dynamic mode only); structured JSON is returned in details and text when available.",
|
|
147
|
-
"Cite the source URL when using extracted content in an answer.",
|
|
134
|
+
"Clean markdown from a known URL.",
|
|
135
|
+
"mode: 'static' (no API key, JSDOM), 'dynamic' (Firecrawl JS), 'full' (Crawl4AI).",
|
|
136
|
+
"'auto' tries static\u2192dynamic\u2192full; see diagnostics for fallback chain.",
|
|
137
|
+
"Use prompt+schema for structured JSON extraction (dynamic mode only).",
|
|
138
|
+
"Cite the source URL.",
|
|
148
139
|
],
|
|
149
140
|
parameters: Type.Object({
|
|
150
141
|
url: Type.String(),
|
|
151
142
|
mode: Type.Optional(Type.Union(
|
|
152
143
|
[Type.Literal("auto"), Type.Literal("static"), Type.Literal("dynamic"), Type.Literal("full")],
|
|
153
|
-
{ default: "auto", description: "
|
|
144
|
+
{ default: "auto", description: "auto, static, dynamic, full." },
|
|
154
145
|
)),
|
|
155
146
|
prompt: Type.Optional(Type.String({ description: "Prompt for structured JSON extraction (dynamic mode only)." })),
|
|
156
147
|
schema: Type.Optional(Type.Any({ description: "JSON schema for structured extraction (dynamic mode only)." })),
|
|
157
148
|
content_chars: Type.Optional(Type.Number({ default: 20000 })),
|
|
158
|
-
wait_for: Type.Optional(Type.Number({ description: "
|
|
159
|
-
mobile: Type.Optional(Type.Boolean({ default: false, description: "
|
|
149
|
+
wait_for: Type.Optional(Type.Number({ description: "Ms to wait for Firecrawl render before extraction." })),
|
|
150
|
+
mobile: Type.Optional(Type.Boolean({ default: false, description: "Mobile viewport (dynamic mode only)." })),
|
|
151
|
+
...crawl4aiControlSchema,
|
|
160
152
|
...sharedControlSchema,
|
|
161
153
|
}),
|
|
162
154
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
@@ -169,6 +161,8 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
169
161
|
timeout_ms: params.timeout_ms as number | undefined,
|
|
170
162
|
wait_for: params.wait_for as number | undefined,
|
|
171
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,
|
|
172
166
|
signal,
|
|
173
167
|
_ctx: ctx,
|
|
174
168
|
});
|
|
@@ -184,24 +178,23 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
184
178
|
name: "web_map",
|
|
185
179
|
label: "Site URL Discovery",
|
|
186
180
|
description:
|
|
187
|
-
"Discover URLs
|
|
181
|
+
"Discover site URLs via Firecrawl Map.",
|
|
188
182
|
promptSnippet: "Map site URLs",
|
|
189
183
|
promptGuidelines: [
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"Use mapped URLs with web_extract for targeted extraction instead of web_crawl when only a few pages are needed.",
|
|
194
|
-
"Keep limits small unless broad site discovery is explicitly requested.",
|
|
184
|
+
"Discover site URLs before crawling. Prefer web_extract for small jobs.",
|
|
185
|
+
"Best on base domains. sitemap:'only' for sub-path discovery.",
|
|
186
|
+
"Keep limits small unless broad discovery is requested.",
|
|
195
187
|
],
|
|
196
188
|
parameters: Type.Object({
|
|
197
189
|
url: Type.String(),
|
|
198
190
|
limit: Type.Optional(Type.Number({ default: 100 })),
|
|
199
191
|
include_subdomains: Type.Optional(Type.Boolean({ default: false })),
|
|
200
|
-
search: Type.Optional(Type.String({ description: "
|
|
201
|
-
sitemap: Type.Optional(Type.Union([Type.Literal("only"), Type.Literal("include"), Type.Literal("skip")], { description: "
|
|
202
|
-
use_index: Type.Optional(Type.Boolean({ default: true, description: "
|
|
203
|
-
ignore_cache: Type.Optional(Type.Boolean({ default: false, description: "Ignore cached
|
|
192
|
+
search: Type.Optional(Type.String({ description: "Search query to guide URL discovery (semantic map)." })),
|
|
193
|
+
sitemap: Type.Optional(Type.Union([Type.Literal("only"), Type.Literal("include"), Type.Literal("skip")], { description: "only, include(default), skip." })),
|
|
194
|
+
use_index: Type.Optional(Type.Boolean({ default: true, description: "Use Firecrawl index for discovery." })),
|
|
195
|
+
ignore_cache: Type.Optional(Type.Boolean({ default: false, description: "Ignore cached results." })),
|
|
204
196
|
...firecrawlControlSchema,
|
|
197
|
+
...sharedControlSchema,
|
|
205
198
|
}),
|
|
206
199
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
207
200
|
const body: Record<string, unknown> = {
|
|
@@ -228,30 +221,27 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
228
221
|
name: "web_crawl",
|
|
229
222
|
label: "Site Crawl",
|
|
230
223
|
description:
|
|
231
|
-
"Crawl
|
|
224
|
+
"Crawl pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
|
|
232
225
|
promptSnippet: "Crawl a small site section",
|
|
233
226
|
promptGuidelines: [
|
|
234
|
-
"
|
|
235
|
-
"
|
|
236
|
-
"
|
|
237
|
-
"For Firecrawl mode ('light'), use the url parameter with optional include_paths/exclude_paths.",
|
|
238
|
-
"For Crawl4AI mode ('full'), use the urls (array) parameter for batch crawling up to 100 URLs.",
|
|
239
|
-
"Keep limits conservative (default 10) unless the user explicitly requests large crawls.",
|
|
240
|
-
"Try web_map first to identify candidate URLs and pass them to web_extract before resorting to a full crawl.",
|
|
227
|
+
"Prefer web_map + web_extract over crawl for small jobs.",
|
|
228
|
+
"'light'=Firecrawl (url param), 'full'=Crawl4AI (urls[] param).",
|
|
229
|
+
"Keep limit low (default 10).",
|
|
241
230
|
],
|
|
242
231
|
parameters: Type.Object({
|
|
243
|
-
url: Type.Optional(Type.String({ description: "URL for Firecrawl-style
|
|
244
|
-
urls: Type.Optional(Type.Array(Type.String(), { description: "URLs for Crawl4AI-style
|
|
245
|
-
mode: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("full")], { default: "light", description: "
|
|
232
|
+
url: Type.Optional(Type.String({ description: "URL for Firecrawl-style crawl (mode:'light')." })),
|
|
233
|
+
urls: Type.Optional(Type.Array(Type.String(), { description: "URLs for Crawl4AI-style crawl (mode:'full'), up to 100." })),
|
|
234
|
+
mode: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("full")], { default: "light", description: "'light'(Firecrawl) or 'full'(Crawl4AI)." })),
|
|
246
235
|
limit: Type.Optional(Type.Number({ default: 10 })),
|
|
247
|
-
include_paths: Type.Optional(Type.String({ description: "Comma-separated
|
|
248
|
-
exclude_paths: Type.Optional(Type.String({ description: "Comma-separated
|
|
249
|
-
poll: Type.Optional(Type.Boolean({ default: false, description: "Poll for
|
|
250
|
-
browser_config: Type.Optional(Type.Any({ description: "
|
|
251
|
-
crawler_config: Type.Optional(Type.Any({ description: "
|
|
236
|
+
include_paths: Type.Optional(Type.String({ description: "Comma-separated paths to include (Firecrawl mode)." })),
|
|
237
|
+
exclude_paths: Type.Optional(Type.String({ description: "Comma-separated paths to exclude (Firecrawl mode)." })),
|
|
238
|
+
poll: Type.Optional(Type.Boolean({ default: false, description: "Poll for completion (Firecrawl mode)." })),
|
|
239
|
+
browser_config: Type.Optional(Type.Any({ description: "BrowserConfig JSON (full mode only)." })),
|
|
240
|
+
crawler_config: Type.Optional(Type.Any({ description: "CrawlerRunConfig JSON (full mode only)." })),
|
|
252
241
|
content_chars: Type.Optional(Type.Number({ default: 20000 })),
|
|
253
242
|
...firecrawlControlSchema,
|
|
254
243
|
...crawl4aiControlSchema,
|
|
244
|
+
...sharedControlSchema,
|
|
255
245
|
}),
|
|
256
246
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
257
247
|
const mode = (params.mode as string) || "light";
|
|
@@ -319,19 +309,18 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
319
309
|
name: "web_screenshot",
|
|
320
310
|
label: "Web Page Screenshot",
|
|
321
311
|
description:
|
|
322
|
-
"
|
|
312
|
+
"Full-page PNG screenshot via Crawl4AI.",
|
|
323
313
|
promptSnippet: "Screenshot a webpage",
|
|
324
314
|
promptGuidelines: [
|
|
325
|
-
"Use
|
|
326
|
-
"
|
|
327
|
-
"Use wait_for to delay capture for dynamic content to render (default 2 seconds).",
|
|
328
|
-
"Use wait_for_images to ensure images are loaded before capture.",
|
|
315
|
+
"Use when web_extract fails on JS-heavy or bot-protected pages.",
|
|
316
|
+
"wait_for (default 2s) delays capture for dynamic content.",
|
|
329
317
|
],
|
|
330
318
|
parameters: Type.Object({
|
|
331
319
|
url: Type.String(),
|
|
332
|
-
wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before
|
|
333
|
-
wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images
|
|
320
|
+
wait_for: Type.Optional(Type.Number({ default: 2, description: "Seconds to wait before capture." })),
|
|
321
|
+
wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images before capture." })),
|
|
334
322
|
...crawl4aiControlSchema,
|
|
323
|
+
...sharedControlSchema,
|
|
335
324
|
}),
|
|
336
325
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
337
326
|
const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
@@ -360,15 +349,16 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
360
349
|
name: "web_pdf",
|
|
361
350
|
label: "Web Page PDF",
|
|
362
351
|
description:
|
|
363
|
-
"
|
|
352
|
+
"PDF document via Crawl4AI.",
|
|
364
353
|
promptSnippet: "PDF a webpage",
|
|
365
354
|
promptGuidelines: [
|
|
366
|
-
"
|
|
367
|
-
"
|
|
355
|
+
"Printable or archivable page snapshot.",
|
|
356
|
+
"Returns base64 PDF string.",
|
|
368
357
|
],
|
|
369
358
|
parameters: Type.Object({
|
|
370
359
|
url: Type.String(),
|
|
371
360
|
...crawl4aiControlSchema,
|
|
361
|
+
...sharedControlSchema,
|
|
372
362
|
}),
|
|
373
363
|
async execute(_id: string, params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
374
364
|
const config = loadCrawl4aiConfig(params as Record<string, unknown>, cwdFromContext(ctx), includeProjectEnv(ctx));
|
|
@@ -389,14 +379,12 @@ export default function piWebExtension(pi: ExtensionAPI) {
|
|
|
389
379
|
name: "web_status",
|
|
390
380
|
label: "Web Provider Status",
|
|
391
381
|
description:
|
|
392
|
-
"Show web provider
|
|
393
|
-
promptSnippet: "Check web provider
|
|
382
|
+
"Show web provider config status without printing secrets.",
|
|
383
|
+
promptSnippet: "Check web provider config and server status",
|
|
394
384
|
promptGuidelines: [
|
|
395
|
-
"
|
|
396
|
-
"
|
|
397
|
-
"
|
|
398
|
-
"Shows Crawl4AI server health, version, and auth status when the server is reachable.",
|
|
399
|
-
"For Firecrawl, `apiKeyFound: false` is normal for self-hosted instances without auth. Use the `ready` field to check whether Firecrawl is actually usable.",
|
|
385
|
+
"Check which backends are configured and their server status.",
|
|
386
|
+
"Never prints secrets — reports only presence and source.",
|
|
387
|
+
"apiKeyFound:false is normal for self-hosted Firecrawl; check ready field.",
|
|
400
388
|
],
|
|
401
389
|
parameters: Type.Object({}),
|
|
402
390
|
async execute(_id: string, _params: Record<string, unknown>, signal: AbortSignal, _onUpdate: unknown, ctx: any) {
|
|
@@ -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(
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -28,7 +28,9 @@
|
|
|
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": {
|
package/extensions/.mocharc.yml
DELETED
package/extensions/package.json
DELETED
|
@@ -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 & bar")).to.equal("foo & bar");
|
|
29
|
-
expect(sanitizeSnippet("<tag>")).to.equal("<tag>");
|
|
30
|
-
expect(sanitizeSnippet(""quoted"")).to.equal('"quoted"');
|
|
31
|
-
expect(sanitizeSnippet("hello world")).to.equal("hello world");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("decodes numeric HTML entities", () => {
|
|
35
|
-
expect(sanitizeSnippet("A")).to.equal("A");
|
|
36
|
-
expect(sanitizeSnippet("A")).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
|
-
});
|