@bacnh85/pi-web 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
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
+ });
@@ -0,0 +1,69 @@
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
+ });
@@ -0,0 +1,134 @@
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
+ });