@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 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
 
@@ -38,15 +38,13 @@ const sharedControlSchema = {
38
38
  };
39
39
 
40
40
  const firecrawlControlSchema = {
41
- firecrawl_api_key: Type.Optional(Type.String({ description: "Firecrawl API key override. Prefer FIRECRAWL_API_KEY." })),
42
- firecrawl_api_url: Type.Optional(Type.String({ description: "Firecrawl API URL override. Defaults to FIRECRAWL_API_URL or hosted." })),
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: "Crawl4AI API URL override. Defaults to CRAWL4AI_API_URL or http://172.30.55.22:11235." })),
48
- crawl4ai_api_token: Type.Optional(Type.String({ description: "Crawl4AI API token override. Prefer CRAWL4AI_API_TOKEN." })),
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 adaptively: SearXNG, Brave, Firecrawl.",
83
+ "Search the web. Auto-selects backends: SearXNG, Brave, Firecrawl.",
86
84
  promptSnippet: "Search current web results",
87
85
  promptGuidelines: [
88
- "Use web_search for source discovery, documentation lookup, current facts, and general web search.",
89
- "Backend auto-selection is adaptive: broad discovery uses SearXNG first; precision queries, site: searches, docs/source lookups, and include_content use Brave first; Firecrawl is last resort.",
90
- "If auto-selection returns poor-quality results, force backend: 'brave' or backend: 'searxng' for a different search index.",
91
- "Use the backend parameter to force a specific search engine (e.g., backend: 'brave').",
92
- "Use engines parameter for SearXNG tuning (e.g., engines: 'google,github' for technical queries).",
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: e.g., 'pw' (past week), 'pm' (past month), 'py' (past year), or a date range like '2024-01-01to2024-06-30'." })),
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: "Search backend: auto (default), searxng, brave, or firecrawl." },
99
+ { default: "auto", description: "auto, searxng, brave, firecrawl." },
105
100
  )),
106
- engines: Type.Optional(Type.String({ description: "SearXNG engine override (e.g., 'google,github' for technical queries). Only used when backend is searxng or auto." })),
107
- include_content: Type.Optional(Type.Boolean({ default: false, description: "Fetch page content alongside search results. Slower but provides inline 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 backend: static \u2192 dynamic \u2192 full.",
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
- "Use web_extract to get clean markdown content from a known URL.",
140
- "Use mode: 'static' for simple static pages (fast, no API key needed, uses JSDOM+Readability).",
141
- "Use mode: 'dynamic' for JavaScript-rendered pages (uses Firecrawl Scrape).",
142
- "Use mode: 'full' for pages that need Crawl4AI rendering when other modes fail (resource-intensive).",
143
- "Default mode 'auto' tries static → dynamic → full. Successive fallbacks are noted in output.",
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: "Extraction mode: auto (default), static, dynamic, or full." },
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: "Milliseconds to wait for Firecrawl dynamic rendering before extraction. Full mode uses Crawl4AI /md and may ignore this." })),
159
- mobile: Type.Optional(Type.Boolean({ default: false, description: "Emulate mobile viewport (dynamic mode only)." })),
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 from a site using Firecrawl Map.",
181
+ "Discover site URLs via Firecrawl Map.",
188
182
  promptSnippet: "Map site URLs",
189
183
  promptGuidelines: [
190
- "Use web_map to discover URLs from a site before crawling or targeted extraction.",
191
- "Works best on base domains (e.g., https://example.com). Returns fewer results on sub-paths; use sitemap: 'only' for sitemap-only discovery on sub-paths.",
192
- "Use sitemap: 'only' for sitemap-only discovery.",
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: "Optional search query to guide URL discovery (semantic map)." })),
201
- sitemap: Type.Optional(Type.Union([Type.Literal("only"), Type.Literal("include"), Type.Literal("skip")], { description: "Sitemap mode: include (default), only (sitemap-only), or skip." })),
202
- use_index: Type.Optional(Type.Boolean({ default: true, description: "Whether to use the Firecrawl search index for URL discovery." })),
203
- ignore_cache: Type.Optional(Type.Boolean({ default: false, description: "Ignore cached map results." })),
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 site pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
224
+ "Crawl pages. Firecrawl 'light' or Crawl4AI 'full' headless mode.",
232
225
  promptSnippet: "Crawl a small site section",
233
226
  promptGuidelines: [
234
- "Use web_crawl only when multiple pages from a site are truly needed; prefer web_map + web_extract for small numbers of pages.",
235
- "Use mode: 'light' (default) for conservative Firecrawl crawling of docs/sites.",
236
- "Use mode: 'full' for Crawl4AI crawling with full rendered data (JS, media, links). More resource-intensive.",
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 single-page crawl (mode: 'light')." })),
244
- urls: Type.Optional(Type.Array(Type.String(), { description: "URLs for Crawl4AI-style multi-URL crawl (mode: 'full'), up to 100." })),
245
- mode: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("full")], { default: "light", description: "Crawl mode: 'light' (Firecrawl, conservative) or 'full' (Crawl4AI, rendered data)." })),
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 path patterns to include (Firecrawl mode only)." })),
248
- exclude_paths: Type.Optional(Type.String({ description: "Comma-separated path patterns to exclude (Firecrawl mode only)." })),
249
- poll: Type.Optional(Type.Boolean({ default: false, description: "Poll for crawl completion (Firecrawl mode only)." })),
250
- browser_config: Type.Optional(Type.Any({ description: "Optional BrowserConfig JSON object (Crawl4AI mode only)." })),
251
- crawler_config: Type.Optional(Type.Any({ description: "Optional CrawlerRunConfig JSON object (Crawl4AI mode only)." })),
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
- "Capture a full-page PNG screenshot via Crawl4AI headless browser.",
312
+ "Full-page PNG screenshot via Crawl4AI.",
323
313
  promptSnippet: "Screenshot a webpage",
324
314
  promptGuidelines: [
325
- "Use web_screenshot when a visual snapshot of a rendered page is needed, or when web_extract fails on a heavily JS-dependent or bot-protected page.",
326
- "The screenshot is returned as a base64-encoded PNG string.",
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 capturing screenshot." })),
333
- wait_for_images: Type.Optional(Type.Boolean({ default: false, description: "Wait for images to load before capture." })),
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
- "Generate a PDF document via Crawl4AI headless browser.",
352
+ "PDF document via Crawl4AI.",
364
353
  promptSnippet: "PDF a webpage",
365
354
  promptGuidelines: [
366
- "Use web_pdf when a printable or archivable snapshot of a page is needed.",
367
- "The PDF is returned as a base64-encoded string.",
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 configuration status without printing secrets.",
393
- promptSnippet: "Check web provider configuration and server status",
382
+ "Show web provider config status without printing secrets.",
383
+ promptSnippet: "Check web provider config and server status",
394
384
  promptGuidelines: [
395
- "Use web_status when web tools fail due to missing credentials/config or to verify which backends are available.",
396
- "The output shows which backends are configured; if a backend is missing, configure its env vars.",
397
- "Never print API key values; this tool reports only presence and source.",
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({}, 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.4.5",
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": {
@@ -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
- });