@mendable/firecrawl-js 4.38.0 → 4.39.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 +7 -2
- package/dist/{chunk-KSTNOGOD.js → chunk-4DPCCKWG.js} +1 -1
- package/dist/index.cjs +39 -9
- package/dist/index.d.cts +152 -4
- package/dist/index.d.ts +152 -4
- package/dist/index.js +40 -10
- package/dist/{package-OS7RONXY.js → package-D2QZMRW7.js} +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/src/__tests__/e2e/v2/batch.test.ts +13 -11
- package/src/__tests__/e2e/v2/crawl.test.ts +24 -20
- package/src/__tests__/e2e/v2/extract.test.ts +32 -7
- package/src/__tests__/e2e/v2/map.test.ts +4 -3
- package/src/__tests__/e2e/v2/parse.test.ts +4 -3
- package/src/__tests__/e2e/v2/scrape.test.ts +10 -9
- package/src/__tests__/e2e/v2/search.test.ts +9 -8
- package/src/__tests__/e2e/v2/usage.test.ts +6 -5
- package/src/__tests__/e2e/v2/utils/rateLimit.ts +257 -0
- package/src/__tests__/e2e/v2/watcher.test.ts +4 -3
- package/src/__tests__/unit/v2/agent-effort-trace.unit.test.ts +1 -1
- package/src/__tests__/unit/v2/agent-thread.unit.test.ts +164 -0
- package/src/__tests__/unit/v2/e2eRateLimitRetry.test.ts +501 -0
- package/src/__tests__/unit/v2/extract.unit.test.ts +1 -1
- package/src/__tests__/unit/v2/scrapeAutoResume.test.ts +1 -1
- package/src/__tests__/unit/v2/search-country.unit.test.ts +32 -0
- package/src/__tests__/unit/v2/search-data-property.test.ts +1 -0
- package/src/__tests__/unit/v2/v1-aliases.test.ts +1 -0
- package/src/v2/client.ts +13 -1
- package/src/v2/methods/agent.ts +23 -1
- package/src/v2/methods/research.ts +14 -8
- package/src/v2/methods/search.ts +1 -0
- package/src/v2/types.ts +151 -34
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { describe, expect, jest, test } from "@jest/globals";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import Firecrawl from "../../../index";
|
|
5
|
+
import {
|
|
6
|
+
COMPOSITE_METHODS,
|
|
7
|
+
DEFAULT_JOB_TIMEOUT_MS,
|
|
8
|
+
RETRY_BUDGET_MS,
|
|
9
|
+
isRateLimitError,
|
|
10
|
+
rateLimitWaitMs,
|
|
11
|
+
testTimeoutMs,
|
|
12
|
+
waitForJob,
|
|
13
|
+
withRateLimitRetry,
|
|
14
|
+
} from "../../e2e/v2/utils/rateLimit";
|
|
15
|
+
|
|
16
|
+
type AsyncCall = () => Promise<string>;
|
|
17
|
+
|
|
18
|
+
/** Reproduces the error the API returns when the per-minute limit is spent. */
|
|
19
|
+
function rateLimitError(seconds: number): Error & { status: number } {
|
|
20
|
+
const err = new Error(
|
|
21
|
+
`Rate limit exceeded. Consumed (req/min): 3, Remaining (req/min): 0. ` +
|
|
22
|
+
`Upgrade your plan at https://firecrawl.dev/pricing for increased rate ` +
|
|
23
|
+
`limits or please retry after ${seconds}s, resets at Tue Sep 08 2026`,
|
|
24
|
+
) as Error & { status: number };
|
|
25
|
+
err.status = 429;
|
|
26
|
+
return err;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Source with comments blanked out, newlines kept.
|
|
31
|
+
*
|
|
32
|
+
* The scans below count brackets and calls. A comment holding `waitForJob(`,
|
|
33
|
+
* a client call or a lone brace would corrupt both, so drop comments first.
|
|
34
|
+
* Strings are not comments: a suite passes paths such as "/blog/*", and a
|
|
35
|
+
* scan that took that for a comment would swallow the rest of the file.
|
|
36
|
+
*/
|
|
37
|
+
function stripComments(source: string): string {
|
|
38
|
+
let out = "";
|
|
39
|
+
let quote: string | undefined;
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < source.length; i++) {
|
|
42
|
+
const c = source[i];
|
|
43
|
+
|
|
44
|
+
if (quote !== undefined) {
|
|
45
|
+
out += c;
|
|
46
|
+
if (c === "\\") {
|
|
47
|
+
out += source[i + 1] ?? "";
|
|
48
|
+
i += 1;
|
|
49
|
+
} else if (c === quote) {
|
|
50
|
+
quote = undefined;
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
56
|
+
quote = c;
|
|
57
|
+
out += c;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (source.startsWith("/*", i)) {
|
|
62
|
+
const close = source.indexOf("*/", i + 2);
|
|
63
|
+
const end = close < 0 ? source.length : close + 2;
|
|
64
|
+
// Keep the newlines so line structure survives.
|
|
65
|
+
out += source.slice(i, end).replace(/[^\n]/g, "");
|
|
66
|
+
i = end - 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (source.startsWith("//", i)) {
|
|
71
|
+
const line = source.indexOf("\n", i);
|
|
72
|
+
if (line < 0) break;
|
|
73
|
+
i = line - 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
out += c;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Text of each `name(...)` call in source, by matching parentheses.
|
|
85
|
+
*
|
|
86
|
+
* A regex cannot do this. The calls hold arrow functions, objects and
|
|
87
|
+
* template strings, so the closing bracket is not the first one.
|
|
88
|
+
*
|
|
89
|
+
* Pass source that stripComments has already cleaned.
|
|
90
|
+
*/
|
|
91
|
+
function callsOf(source: string, name: string): string[] {
|
|
92
|
+
const found: string[] = [];
|
|
93
|
+
|
|
94
|
+
for (const match of source.matchAll(new RegExp(`\\b${name}\\(`, "g"))) {
|
|
95
|
+
const from = match.index!;
|
|
96
|
+
let i = from + match[0].length - 1;
|
|
97
|
+
let depth = 0;
|
|
98
|
+
let quote: string | undefined;
|
|
99
|
+
|
|
100
|
+
for (; i < source.length; i++) {
|
|
101
|
+
const c = source[i];
|
|
102
|
+
if (quote !== undefined) {
|
|
103
|
+
if (c === "\\") i += 1;
|
|
104
|
+
else if (c === quote) quote = undefined;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
108
|
+
quote = c;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (c === "(" || c === "[" || c === "{") depth += 1;
|
|
112
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
113
|
+
depth -= 1;
|
|
114
|
+
if (depth === 0) break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
found.push(source.slice(from, i + 1));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Body of each test in a suite, including the test.each form.
|
|
125
|
+
*
|
|
126
|
+
* test.each takes the cases first and the test second, so the test body is
|
|
127
|
+
* the group after the cases.
|
|
128
|
+
*/
|
|
129
|
+
function testBodies(source: string): string[] {
|
|
130
|
+
const found: string[] = [];
|
|
131
|
+
|
|
132
|
+
for (const match of source.matchAll(/\btest(\.each)?\s*\(/g)) {
|
|
133
|
+
if (!match[1]) {
|
|
134
|
+
found.push(callsOf(source.slice(match.index!), "test")[0]);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const cases = callsOf(source.slice(match.index!), "test.each")[0];
|
|
138
|
+
const rest = source.slice(match.index! + cases.length);
|
|
139
|
+
const open = rest.indexOf("(");
|
|
140
|
+
// Reuse the matcher by giving it a name to anchor on.
|
|
141
|
+
const group = callsOf(`each${rest.slice(open)}`, "each")[0];
|
|
142
|
+
found.push(group);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Local async helpers in a suite, by name, with the body each one runs. */
|
|
149
|
+
function localHelpers(source: string): Map<string, string> {
|
|
150
|
+
const found = new Map<string, string>();
|
|
151
|
+
|
|
152
|
+
for (const match of source.matchAll(/\n(\s*)async function (\w+)\(/g)) {
|
|
153
|
+
const start = match.index! + 1;
|
|
154
|
+
const close = source.indexOf(`\n${match[1]}}`, start);
|
|
155
|
+
found.set(match[2], source.slice(start, close < 0 ? undefined : close));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Worst case of one test body, and the wrapped calls it counts.
|
|
163
|
+
*
|
|
164
|
+
* Each waitForJob costs its bound, one poll interval and the retry budget of
|
|
165
|
+
* the status read. Each other wrapped client call costs the retry budget on
|
|
166
|
+
* its own. A local helper runs inside the test, so its calls count too.
|
|
167
|
+
*/
|
|
168
|
+
function worstCaseMs(
|
|
169
|
+
body: string,
|
|
170
|
+
helpers: Map<string, string>,
|
|
171
|
+
): { calls: number; ms: number } {
|
|
172
|
+
let calls = 0;
|
|
173
|
+
let ms = 0;
|
|
174
|
+
|
|
175
|
+
const waits = callsOf(body, "waitForJob");
|
|
176
|
+
for (const wait of waits) {
|
|
177
|
+
const timeout = wait.match(/timeout:\s*(\d+)/);
|
|
178
|
+
const poll = wait.match(/pollInterval:\s*(\d+)/);
|
|
179
|
+
const bound = timeout
|
|
180
|
+
? Number(timeout[1]) * 1000
|
|
181
|
+
: DEFAULT_JOB_TIMEOUT_MS;
|
|
182
|
+
ms +=
|
|
183
|
+
bound +
|
|
184
|
+
Math.max(1000, (poll ? Number(poll[1]) : 2) * 1000) +
|
|
185
|
+
RETRY_BUDGET_MS;
|
|
186
|
+
calls += 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The status read sits inside waitForJob, so drop those calls before
|
|
190
|
+
// counting the rest. Longest first, so a shorter call is not cut twice.
|
|
191
|
+
let rest = body;
|
|
192
|
+
for (const wait of [...waits].sort((a, b) => b.length - a.length)) {
|
|
193
|
+
rest = rest.split(wait).join("");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const direct = rest.match(/client\.\w+\s*\(/g) ?? [];
|
|
197
|
+
ms += direct.length * RETRY_BUDGET_MS;
|
|
198
|
+
calls += direct.length;
|
|
199
|
+
|
|
200
|
+
for (const [name, helperBody] of helpers) {
|
|
201
|
+
const uses = rest.match(new RegExp(`\\b${name}\\s*\\(`, "g")) ?? [];
|
|
202
|
+
if (uses.length === 0) continue;
|
|
203
|
+
const inner = worstCaseMs(helperBody, new Map());
|
|
204
|
+
ms += uses.length * inner.ms;
|
|
205
|
+
calls += uses.length * inner.calls;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { calls, ms };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
describe("e2e rate-limit retry helper", () => {
|
|
212
|
+
test("recognises the rate-limit error and reads its reset time", () => {
|
|
213
|
+
expect(isRateLimitError(rateLimitError(1))).toBe(true);
|
|
214
|
+
expect(isRateLimitError({ status: 429 })).toBe(true);
|
|
215
|
+
expect(isRateLimitError(new Error("Bad request"))).toBe(false);
|
|
216
|
+
expect(isRateLimitError(undefined)).toBe(false);
|
|
217
|
+
|
|
218
|
+
// 1s reset plus the 1s buffer.
|
|
219
|
+
expect(rateLimitWaitMs(rateLimitError(1))).toBe(2000);
|
|
220
|
+
// A reset longer than the window is capped.
|
|
221
|
+
expect(rateLimitWaitMs(rateLimitError(600))).toBe(75_000);
|
|
222
|
+
// No reset time in the error, so the fallback applies.
|
|
223
|
+
expect(rateLimitWaitMs(new Error("Rate limit exceeded."))).toBe(30_000);
|
|
224
|
+
expect(rateLimitWaitMs({ details: { retryAfter: 2 } })).toBe(3000);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("runs the call again after a rate-limit error", async () => {
|
|
228
|
+
const scrape = jest
|
|
229
|
+
.fn<() => Promise<string>>()
|
|
230
|
+
.mockRejectedValueOnce(rateLimitError(1))
|
|
231
|
+
.mockResolvedValueOnce("ok");
|
|
232
|
+
const client = withRateLimitRetry({ scrape });
|
|
233
|
+
|
|
234
|
+
await expect(client.scrape()).resolves.toBe("ok");
|
|
235
|
+
expect(scrape).toHaveBeenCalledTimes(2);
|
|
236
|
+
}, 30_000);
|
|
237
|
+
|
|
238
|
+
test("stops after the attempt bound and surfaces the error", async () => {
|
|
239
|
+
const scrape = jest
|
|
240
|
+
.fn<() => Promise<string>>()
|
|
241
|
+
.mockRejectedValue(rateLimitError(1));
|
|
242
|
+
const client = withRateLimitRetry({ scrape });
|
|
243
|
+
|
|
244
|
+
await expect(client.scrape()).rejects.toThrow(/Rate limit exceeded/);
|
|
245
|
+
expect(scrape).toHaveBeenCalledTimes(3);
|
|
246
|
+
}, 30_000);
|
|
247
|
+
|
|
248
|
+
test("passes other errors through without waiting", async () => {
|
|
249
|
+
const scrape = jest
|
|
250
|
+
.fn<() => Promise<string>>()
|
|
251
|
+
.mockRejectedValue(new Error("invalid url"));
|
|
252
|
+
const client = withRateLimitRetry({ scrape });
|
|
253
|
+
|
|
254
|
+
await expect(client.scrape()).rejects.toThrow("invalid url");
|
|
255
|
+
expect(scrape).toHaveBeenCalledTimes(1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("never runs a composite method again after a rate-limit error", async () => {
|
|
259
|
+
for (const name of COMPOSITE_METHODS) {
|
|
260
|
+
const method = jest.fn<AsyncCall>().mockRejectedValue(rateLimitError(1));
|
|
261
|
+
const client = withRateLimitRetry<Record<string, AsyncCall>>({
|
|
262
|
+
[name]: method,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const startedAt = Date.now();
|
|
266
|
+
await expect(client[name]()).rejects.toThrow(/Rate limit exceeded/);
|
|
267
|
+
|
|
268
|
+
// One call only. A second call would start a second job.
|
|
269
|
+
expect(method).toHaveBeenCalledTimes(1);
|
|
270
|
+
// The error surfaces at once, so no wait ran either.
|
|
271
|
+
expect(Date.now() - startedAt).toBeLessThan(1_000);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("returns the result of a composite method that succeeds", async () => {
|
|
276
|
+
const crawl = jest.fn<AsyncCall>().mockResolvedValue("job");
|
|
277
|
+
const client = withRateLimitRetry({ crawl });
|
|
278
|
+
|
|
279
|
+
await expect(client.crawl()).resolves.toBe("job");
|
|
280
|
+
expect(crawl).toHaveBeenCalledTimes(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("every composite name is a method on the client", () => {
|
|
284
|
+
const client = new Firecrawl({ apiKey: "test-key" }) as unknown as Record<
|
|
285
|
+
string,
|
|
286
|
+
unknown
|
|
287
|
+
>;
|
|
288
|
+
|
|
289
|
+
for (const name of COMPOSITE_METHODS) {
|
|
290
|
+
expect(typeof client[name]).toBe("function");
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("the client has no composite method the list misses", () => {
|
|
295
|
+
// Guard for a new waiter method. A composite starts a job through a
|
|
296
|
+
// *Waiter call, or forwards to another composite on this.
|
|
297
|
+
const source = readFileSync(
|
|
298
|
+
path.resolve(process.cwd(), "src/v2/client.ts"),
|
|
299
|
+
"utf-8",
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const found = new Set<string>();
|
|
303
|
+
let current: string | undefined;
|
|
304
|
+
for (const line of source.split("\n")) {
|
|
305
|
+
const signature = line.match(/^\s{2}(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/);
|
|
306
|
+
if (signature) current = signature[1];
|
|
307
|
+
if (!current) continue;
|
|
308
|
+
const startsJob =
|
|
309
|
+
/\b\w+Waiter\s*\(/.test(line) ||
|
|
310
|
+
new RegExp(`this\\.(${[...COMPOSITE_METHODS].join("|")})\\s*\\(`).test(
|
|
311
|
+
line,
|
|
312
|
+
);
|
|
313
|
+
if (startsJob) found.add(current);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The scan must see the known composites, or it stopped working.
|
|
317
|
+
expect([...found].sort()).toEqual([...COMPOSITE_METHODS].sort());
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("no e2e suite calls a composite method", () => {
|
|
321
|
+
// A composite call in a suite has no retry, so a rate limit fails the test
|
|
322
|
+
// at once. The suites start the job and poll it instead.
|
|
323
|
+
const dir = path.resolve(process.cwd(), "src/__tests__/e2e/v2");
|
|
324
|
+
const suites = readdirSync(dir).filter(name => name.endsWith(".test.ts"));
|
|
325
|
+
expect(suites.length).toBeGreaterThan(0);
|
|
326
|
+
|
|
327
|
+
const calls = new RegExp(
|
|
328
|
+
`client\\.(${[...COMPOSITE_METHODS].join("|")})\\s*\\(`,
|
|
329
|
+
);
|
|
330
|
+
const offenders = suites.filter(name =>
|
|
331
|
+
calls.test(readFileSync(path.join(dir, name), "utf-8")),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(offenders).toEqual([]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("waitForJob polls until the job reaches a terminal state", async () => {
|
|
338
|
+
const getStatus = jest
|
|
339
|
+
.fn<() => Promise<{ status: string }>>()
|
|
340
|
+
.mockResolvedValueOnce({ status: "scraping" })
|
|
341
|
+
.mockResolvedValueOnce({ status: "scraping" })
|
|
342
|
+
.mockResolvedValueOnce({ status: "completed" });
|
|
343
|
+
|
|
344
|
+
// pollInterval floors at 1s, so three reads take about two seconds.
|
|
345
|
+
await expect(
|
|
346
|
+
waitForJob(getStatus, { pollInterval: 1, timeout: 30 }),
|
|
347
|
+
).resolves.toEqual({ status: "completed" });
|
|
348
|
+
expect(getStatus).toHaveBeenCalledTimes(3);
|
|
349
|
+
}, 30_000);
|
|
350
|
+
|
|
351
|
+
test("waitForJob gives up when the job outlives its timeout", async () => {
|
|
352
|
+
const getStatus = jest
|
|
353
|
+
.fn<() => Promise<{ status: string }>>()
|
|
354
|
+
.mockResolvedValue({ status: "scraping" });
|
|
355
|
+
|
|
356
|
+
await expect(
|
|
357
|
+
waitForJob(getStatus, { pollInterval: 1, timeout: 1 }),
|
|
358
|
+
).rejects.toThrow(/did not finish in 1s/);
|
|
359
|
+
}, 30_000);
|
|
360
|
+
|
|
361
|
+
test("waitForJob bounds a caller that passes no timeout", async () => {
|
|
362
|
+
const getStatus = jest
|
|
363
|
+
.fn<() => Promise<{ status: string }>>()
|
|
364
|
+
.mockResolvedValue({ status: "scraping" });
|
|
365
|
+
|
|
366
|
+
// The default is a backstop for a caller that forgets a timeout, not a
|
|
367
|
+
// work allowance. A caller that waits for real work passes its own bound.
|
|
368
|
+
// So the default only has to fit the smallest base any suite uses, which
|
|
369
|
+
// is 60_000 ms. The last read can start just inside the bound and then
|
|
370
|
+
// spend the whole retry budget, so count both.
|
|
371
|
+
expect(DEFAULT_JOB_TIMEOUT_MS + 2_000 + RETRY_BUDGET_MS).toBeLessThan(
|
|
372
|
+
testTimeoutMs(60_000),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Fake timers run the whole bound without waiting for it.
|
|
376
|
+
jest.useFakeTimers();
|
|
377
|
+
try {
|
|
378
|
+
const settled = expect(waitForJob(getStatus)).rejects.toThrow(
|
|
379
|
+
new RegExp(`did not finish in ${DEFAULT_JOB_TIMEOUT_MS / 1000}s`),
|
|
380
|
+
);
|
|
381
|
+
await jest.advanceTimersByTimeAsync(DEFAULT_JOB_TIMEOUT_MS + 5_000);
|
|
382
|
+
await settled;
|
|
383
|
+
} finally {
|
|
384
|
+
jest.useRealTimers();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
expect(getStatus).toHaveBeenCalled();
|
|
388
|
+
}, 30_000);
|
|
389
|
+
|
|
390
|
+
test("the scan reads past comments and strings", () => {
|
|
391
|
+
// The guard is only as good as its parser, so hold the parser too. Each
|
|
392
|
+
// trap below broke an earlier version: a block comment with a call and a
|
|
393
|
+
// lone brace, a commented-out call, and a path that starts with "/*".
|
|
394
|
+
const suite = [
|
|
395
|
+
'describe("fixture", () => {',
|
|
396
|
+
' test("one start and one wait", async () => {',
|
|
397
|
+
' /* waitForJob( here, with an unbalanced { brace */',
|
|
398
|
+
' // client.notCounted(',
|
|
399
|
+
' const started = await client.startThing({',
|
|
400
|
+
' includePaths: ["/blog/*", "/docs/*"],',
|
|
401
|
+
' });',
|
|
402
|
+
' const job = await waitForJob(() => client.getThing(started.id), {',
|
|
403
|
+
' pollInterval: 1,',
|
|
404
|
+
' timeout: 60,',
|
|
405
|
+
' });',
|
|
406
|
+
' expect(job.status).toBe("completed");',
|
|
407
|
+
' }, testTimeoutMs(220_000));',
|
|
408
|
+
"});",
|
|
409
|
+
].join("\n");
|
|
410
|
+
|
|
411
|
+
const source = stripComments(suite);
|
|
412
|
+
|
|
413
|
+
// The traps are gone, and the code around them survived.
|
|
414
|
+
expect(source).not.toMatch(/waitForJob\( here/);
|
|
415
|
+
expect(source).not.toMatch(/notCounted/);
|
|
416
|
+
expect(source).toContain('includePaths: ["/blog/*", "/docs/*"]');
|
|
417
|
+
expect(source).toContain("timeout: 60,");
|
|
418
|
+
|
|
419
|
+
const bodies = testBodies(source);
|
|
420
|
+
expect(bodies).toHaveLength(1);
|
|
421
|
+
|
|
422
|
+
// One start call at the retry budget, and one wait at bound plus poll
|
|
423
|
+
// plus the retry budget of its status read.
|
|
424
|
+
const { calls, ms } = worstCaseMs(bodies[0], new Map());
|
|
425
|
+
expect(calls).toBe(2);
|
|
426
|
+
expect(ms).toBe(RETRY_BUDGET_MS + 60_000 + 1_000 + RETRY_BUDGET_MS);
|
|
427
|
+
expect(ms).toBeLessThan(testTimeoutMs(220_000));
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("every wrapped call in a test fits its budget", () => {
|
|
431
|
+
// Guard for the whole class of budget faults. A test spends the retry
|
|
432
|
+
// budget once per wrapped call, not once in total, so the budget has to
|
|
433
|
+
// cover the sum. Otherwise jest fires first and hides the specific
|
|
434
|
+
// message this helper exists to surface.
|
|
435
|
+
const dir = path.resolve(process.cwd(), "src/__tests__/e2e/v2");
|
|
436
|
+
const suites = readdirSync(dir).filter(name => name.endsWith(".test.ts"));
|
|
437
|
+
expect(suites.length).toBeGreaterThan(0);
|
|
438
|
+
|
|
439
|
+
let checked = 0;
|
|
440
|
+
let widest = 0;
|
|
441
|
+
|
|
442
|
+
for (const name of suites) {
|
|
443
|
+
const source = stripComments(
|
|
444
|
+
readFileSync(path.join(dir, name), "utf-8"),
|
|
445
|
+
);
|
|
446
|
+
const helpers = localHelpers(source);
|
|
447
|
+
|
|
448
|
+
// A helper that calls another helper would hide calls from the sum.
|
|
449
|
+
for (const [self, body] of helpers) {
|
|
450
|
+
for (const other of helpers.keys()) {
|
|
451
|
+
if (other === self) continue;
|
|
452
|
+
expect(body).not.toMatch(new RegExp(`\\b${other}\\s*\\(`));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const bodies = testBodies(source);
|
|
457
|
+
// The scan must see every test, or a budget goes unchecked.
|
|
458
|
+
expect(bodies.length).toBe(
|
|
459
|
+
(source.match(/\btest(?:\.each)?\s*\(/g) ?? []).length,
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
for (const body of bodies) {
|
|
463
|
+
const { calls, ms } = worstCaseMs(body, helpers);
|
|
464
|
+
if (calls === 0) continue;
|
|
465
|
+
|
|
466
|
+
// A test that calls the wrapped client needs a budget of its own.
|
|
467
|
+
// Without one jest allows 5s, which any single wait outlasts.
|
|
468
|
+
const budgets = body.match(/testTimeoutMs\((\d[\d_]*)\)/g);
|
|
469
|
+
expect(budgets).not.toBeNull();
|
|
470
|
+
|
|
471
|
+
const base = Number(budgets![budgets!.length - 1].replace(/\D/g, ""));
|
|
472
|
+
expect(ms).toBeLessThan(testTimeoutMs(base));
|
|
473
|
+
|
|
474
|
+
checked += 1;
|
|
475
|
+
widest = Math.max(widest, calls);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// The scan must see the known tests, or it stopped working.
|
|
480
|
+
expect(checked).toBeGreaterThanOrEqual(45);
|
|
481
|
+
expect(widest).toBeGreaterThanOrEqual(3);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("the test timeout fits the worst-case serial retry", () => {
|
|
485
|
+
// Two waits at the 75s cap follow the first attempt.
|
|
486
|
+
expect(RETRY_BUDGET_MS).toBe(150_000);
|
|
487
|
+
expect(testTimeoutMs(60_000)).toBe(210_000);
|
|
488
|
+
|
|
489
|
+
// No pair of waits the helper can ask for exceeds the budget.
|
|
490
|
+
const longestWait = rateLimitWaitMs(rateLimitError(600));
|
|
491
|
+
expect(longestWait * 2).toBeLessThanOrEqual(RETRY_BUDGET_MS);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("leaves values that are not promises alone", () => {
|
|
495
|
+
const watcher = jest.fn(() => ({ kind: "crawl" }));
|
|
496
|
+
const client = withRateLimitRetry({ watcher, apiUrl: "https://example.com" });
|
|
497
|
+
|
|
498
|
+
expect(client.watcher()).toEqual({ kind: "crawl" });
|
|
499
|
+
expect(client.apiUrl).toBe("https://example.com");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, jest, test } from "@jest/globals";
|
|
2
|
+
import { search } from "../../../v2/methods/search";
|
|
3
|
+
|
|
4
|
+
function httpMock() {
|
|
5
|
+
return {
|
|
6
|
+
post: jest.fn(async () => ({ status: 200, data: { success: true } })),
|
|
7
|
+
} as any;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("v2 search country", () => {
|
|
11
|
+
test("forwards the country option", async () => {
|
|
12
|
+
const http = httpMock();
|
|
13
|
+
|
|
14
|
+
await search(http, { query: "firecrawl", country: "de" });
|
|
15
|
+
|
|
16
|
+
expect(http.post).toHaveBeenCalledWith(
|
|
17
|
+
"/v2/search",
|
|
18
|
+
{ query: "firecrawl", country: "de" },
|
|
19
|
+
{},
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("omits country when the caller does not set it", async () => {
|
|
24
|
+
const http = httpMock();
|
|
25
|
+
|
|
26
|
+
await search(http, { query: "firecrawl" });
|
|
27
|
+
|
|
28
|
+
expect(http.post).toHaveBeenCalledWith("/v2/search", { query: "firecrawl" }, {});
|
|
29
|
+
const payload = http.post.mock.calls[0][1] as Record<string, unknown>;
|
|
30
|
+
expect(payload).not.toHaveProperty("country");
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/v2/client.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
batchScrape as batchWaiter,
|
|
27
27
|
} from "./methods/batch";
|
|
28
28
|
import { startExtract, getExtractStatus, extract as extractWaiter } from "./methods/extract";
|
|
29
|
-
import { startAgent, getAgentStatus, getAgentTrace, getAgentSnapshot, cancelAgent, listAgents, agent as agentWaiter } from "./methods/agent";
|
|
29
|
+
import { startAgent, getAgentStatus, getAgentThread, getAgentTrace, getAgentSnapshot, cancelAgent, listAgents, agent as agentWaiter } from "./methods/agent";
|
|
30
30
|
import {
|
|
31
31
|
browser as browserMethod,
|
|
32
32
|
browserExecute,
|
|
@@ -70,6 +70,7 @@ import type {
|
|
|
70
70
|
AgentStatusResponse,
|
|
71
71
|
AgentTraceResponse,
|
|
72
72
|
AgentSnapshotResponse,
|
|
73
|
+
AgentThreadResponse,
|
|
73
74
|
AgentListOptions,
|
|
74
75
|
AgentListResponse,
|
|
75
76
|
CrawlOptions,
|
|
@@ -284,6 +285,9 @@ export class FirecrawlClient {
|
|
|
284
285
|
* Access the v2 research endpoints — Firecrawl's **research paper index**
|
|
285
286
|
* (~43M paper abstracts) plus GitHub history/readmes.
|
|
286
287
|
*
|
|
288
|
+
* `research.searchGithub()` is deprecated and stops responding after
|
|
289
|
+
* 2026-11-03. Use `developerSearch()` instead.
|
|
290
|
+
*
|
|
287
291
|
* The paper corpus is roughly 90% biomedical and life sciences — PubMed,
|
|
288
292
|
* bioRxiv and medRxiv — with arXiv covering physics, mathematics and
|
|
289
293
|
* computer science.
|
|
@@ -566,6 +570,14 @@ export class FirecrawlClient {
|
|
|
566
570
|
async getAgentSnapshot(jobId: string, snapshotId: string): Promise<AgentSnapshotResponse> {
|
|
567
571
|
return getAgentSnapshot(this.http, jobId, snapshotId);
|
|
568
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* Get a thread and its runs, oldest turn first.
|
|
575
|
+
* @param threadId Thread id, as returned by startAgent or getAgentStatus.
|
|
576
|
+
* @param options.includeData Inline each succeeded run's data.
|
|
577
|
+
*/
|
|
578
|
+
async getAgentThread(threadId: string, options?: { includeData?: boolean }): Promise<AgentThreadResponse> {
|
|
579
|
+
return getAgentThread(this.http, threadId, options);
|
|
580
|
+
}
|
|
569
581
|
|
|
570
582
|
// Browser
|
|
571
583
|
/**
|
package/src/v2/methods/agent.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentEffort, type AgentListOptions, type AgentListResponse, type AgentResponse, type AgentSnapshotResponse, type AgentStatusResponse, type AgentTraceResponse, type AgentWebhookConfig, type AuditMetadata, type ThreatProtectionOptions } from "../types";
|
|
1
|
+
import { type AgentEffort, type AgentExchangeOptions, type AgentListOptions, type AgentListResponse, type AgentMode, type AgentResponse, type AgentSnapshotResponse, type AgentStatusResponse, type AgentThreadResponse, type AgentTraceResponse, type AgentWebhookConfig, type AuditMetadata, type ThreatProtectionOptions } from "../types";
|
|
2
2
|
import { HttpClient } from "../utils/httpClient";
|
|
3
3
|
import { normalizeAxiosError, throwForBadResponse } from "../utils/errorHandler";
|
|
4
4
|
import { isZodSchema, zodSchemaToJsonSchema } from "../../utils/zodSchemaToJson";
|
|
@@ -17,6 +17,9 @@ function prepareAgentPayload(args: {
|
|
|
17
17
|
webhook?: string | AgentWebhookConfig;
|
|
18
18
|
threatProtection?: ThreatProtectionOptions;
|
|
19
19
|
auditMetadata?: AuditMetadata;
|
|
20
|
+
threadId?: string;
|
|
21
|
+
mode?: AgentMode;
|
|
22
|
+
exchange?: AgentExchangeOptions;
|
|
20
23
|
}): Record<string, unknown> {
|
|
21
24
|
const body: Record<string, unknown> = {};
|
|
22
25
|
if (args.urls) body.urls = args.urls;
|
|
@@ -35,6 +38,9 @@ function prepareAgentPayload(args: {
|
|
|
35
38
|
body.threatProtection = args.threatProtection;
|
|
36
39
|
if (args.auditMetadata != null)
|
|
37
40
|
body.auditMetadata = args.auditMetadata;
|
|
41
|
+
if (args.threadId != null) body.threadId = args.threadId;
|
|
42
|
+
if (args.mode != null) body.mode = args.mode;
|
|
43
|
+
if (args.exchange != null) body.exchange = args.exchange;
|
|
38
44
|
return body;
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -129,6 +135,22 @@ export async function getAgentSnapshot(
|
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
export async function getAgentThread(
|
|
139
|
+
http: HttpClient,
|
|
140
|
+
threadId: string,
|
|
141
|
+
options?: { includeData?: boolean }
|
|
142
|
+
): Promise<AgentThreadResponse> {
|
|
143
|
+
try {
|
|
144
|
+
const query = options?.includeData ? "?includeData=true" : "";
|
|
145
|
+
const res = await http.get<AgentThreadResponse>(`/v2/agent/threads/${threadId}${query}`);
|
|
146
|
+
if (res.status !== 200) throwForBadResponse(res, "agent thread");
|
|
147
|
+
return res.data;
|
|
148
|
+
} catch (err: any) {
|
|
149
|
+
if (err?.isAxiosError) return normalizeAxiosError(err, "agent thread");
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
132
154
|
export async function cancelAgent(http: HttpClient, jobId: string): Promise<boolean> {
|
|
133
155
|
try {
|
|
134
156
|
const res = await http.delete<{ success: boolean }>(`/v2/agent/${jobId}`);
|
|
@@ -159,9 +159,9 @@ export class ResearchClient {
|
|
|
159
159
|
appendParam(params, "query", options.query);
|
|
160
160
|
appendParam(params, "k", options.k);
|
|
161
161
|
try {
|
|
162
|
-
const res = await this.http.get<
|
|
163
|
-
|
|
164
|
-
);
|
|
162
|
+
const res = await this.http.get<
|
|
163
|
+
PaperMetadataResponse | ReadPaperResponse
|
|
164
|
+
>(withQuery(`${BASE}/papers/${encodeURIComponent(id)}`, params));
|
|
165
165
|
if (res.status !== 200) throwForBadResponse(res, "get paper");
|
|
166
166
|
return res.data;
|
|
167
167
|
} catch (err) {
|
|
@@ -191,10 +191,7 @@ export class ResearchClient {
|
|
|
191
191
|
appendParam(params, "anchor", options.anchor);
|
|
192
192
|
try {
|
|
193
193
|
const res = await this.http.get<SimilarPapersResponse>(
|
|
194
|
-
withQuery(
|
|
195
|
-
`${BASE}/papers/${encodeURIComponent(id)}/similar`,
|
|
196
|
-
params,
|
|
197
|
-
),
|
|
194
|
+
withQuery(`${BASE}/papers/${encodeURIComponent(id)}/similar`, params),
|
|
198
195
|
);
|
|
199
196
|
if (res.status !== 200) throwForBadResponse(res, "find similar papers");
|
|
200
197
|
return res.data;
|
|
@@ -204,7 +201,16 @@ export class ResearchClient {
|
|
|
204
201
|
}
|
|
205
202
|
|
|
206
203
|
/**
|
|
207
|
-
* Search GitHub issue/PR history and repository
|
|
204
|
+
* Search the research index GitHub slice: issue/PR history and repository
|
|
205
|
+
* readmes.
|
|
206
|
+
*
|
|
207
|
+
* @deprecated Use `developerSearch()` on the client instead. This
|
|
208
|
+
* endpoint stops responding after 2026-11-03. The developer index searches
|
|
209
|
+
* GitHub issues, pull requests and readmes plus curated documentation
|
|
210
|
+
* sources, returns matched passages, and adds filters for repo, language,
|
|
211
|
+
* license and stars. It does not carry over the `scores` breakdown or the
|
|
212
|
+
* `resultType: "web"` fallback results. See
|
|
213
|
+
* https://docs.firecrawl.dev/features/developer.
|
|
208
214
|
* @param query Search query.
|
|
209
215
|
* @param options Optional `k`.
|
|
210
216
|
*/
|