import { afterEach, describe, expect, it } from "vitest"; import { gzipSync } from "node:zlib"; import type { RawDocument } from "./types.js"; import { Budget, applyRobotsDirectives, budgetDay, expectedCredits, gunzipLimit, looksBlockedOrEmpty, parseRetryAfter, robotsDirective, safeGunzip, setBudgetStore } from "./fetchers.js"; function doc(p: Partial): RawDocument { return { url: "https://x.com/a", finalUrl: "https://x.com/a", fetchedAt: "2026-09-12T00:00:00Z", status: 200, contentType: "text/html", body: Buffer.alloc(0), text: "", headers: {}, etag: null, lastModified: null, notModified: false, fetcher: "direct", level: 1, durationMs: 1, credits: 0, ...p }; } describe("looksBlockedOrEmpty", () => { const filler = "

" + "Real content about data centers. ".repeat(80) + "

"; it("status-based blocks", () => { for (const s of [401, 403, 429, 503]) expect(looksBlockedOrEmpty(doc({ status: s }))).toBe(true); expect(looksBlockedOrEmpty(doc({ status: 500 }))).toBe(false); expect(looksBlockedOrEmpty(doc({ status: 200, contentType: "application/json", text: "{}" }))).toBe(false); }); it("interstitial only when the page carries little text (Cloudflare beacon on real pages is fine)", () => { expect(looksBlockedOrEmpty(doc({ text: "Just a moment..." }))).toBe(true); expect(looksBlockedOrEmpty(doc({ text: `${filler}` }))).toBe(false); }); it("empty JS shells are blocked-looking, small real pages are not", () => { expect(looksBlockedOrEmpty(doc({ text: '
' }))).toBe(true); expect(looksBlockedOrEmpty(doc({ text: "

DC1

12 MW, Ashburn

" }))).toBe(false); }); }); describe("gzip bomb guard", () => { it("caps decompressed output at max(4×maxBytes, 64 MB) and returns an error above it", () => { expect(gunzipLimit(1024)).toBe(64 * 1024 * 1024); expect(gunzipLimit(25 * 1024 * 1024)).toBe(100 * 1024 * 1024); const bomb = gzipSync(Buffer.alloc(200 * 1024, 0)); // 200 KB of zeros → ~200 bytes gzipped const ok = safeGunzip(bomb, 1024 * 1024); expect(ok.error).toBeUndefined(); expect(ok.body.length).toBe(200 * 1024); // simulate a tiny cap by exercising the underlying option path: a 200 KB payload with a 64 MB floor is fine, so test // the error branch through a body far above a synthetic limit const huge = gzipSync(Buffer.alloc(70 * 1024 * 1024, 0)); const r = safeGunzip(huge, 1024); expect(r.error).toMatch(/exceeds/); }); it("leaves non-gzip bodies untouched", () => { const b = Buffer.from("plain"); expect(safeGunzip(b, 1024).body).toBe(b); }); }); describe("robots directives (noarchive / noindex)", () => { it("reads X-Robots-Tag and ", () => { expect(robotsDirective(doc({ headers: { "x-robots-tag": "noarchive, nosnippet" } }))).toBe("header:noarchive"); expect(robotsDirective(doc({ headers: { "x-robots-tag": "noindex" } }))).toBe("header:noindex"); expect(robotsDirective(doc({ text: '' }))).toBe("meta:noarchive"); expect(robotsDirective(doc({ text: '' }))).toBe("meta:noindex"); expect(robotsDirective(doc({ text: '' }))).toBeNull(); expect(robotsDirective(doc({ headers: { "x-robots-tag": "nosnippet" } }))).toBeNull(); expect(robotsDirective(doc({ contentType: "application/json", text: '' }))).toBeNull(); // only HTML bodies }); it("applyRobotsDirectives sets meta.noarchive and is idempotent", () => { const d = applyRobotsDirectives(doc({ headers: { "x-robots-tag": "noarchive" }, meta: { attempts: ["L1:200"] } })); expect(d.meta).toEqual({ attempts: ["L1:200"], noarchive: true, robotsDirective: "header:noarchive" }); expect(applyRobotsDirectives(d).meta).toEqual(d.meta); expect(applyRobotsDirectives(doc({})).meta).toBeUndefined(); }); }); describe("parseRetryAfter / expectedCredits", () => { it("accepts delta-seconds and HTTP dates, bounded to 7 days", () => { const now = Date.parse("2026-09-12T12:00:00Z"); expect(parseRetryAfter("120", now)).toBe(120_000); expect(parseRetryAfter("Sat, 12 Sep 2026 12:10:00 GMT", now)).toBe(600_000); expect(parseRetryAfter("Fri, 11 Sep 2026 12:00:00 GMT", now)).toBe(0); // in the past expect(parseRetryAfter("99999999", now)).toBe(7 * 86_400_000); expect(parseRetryAfter("soon", now)).toBeNull(); expect(parseRetryAfter(null, now)).toBeNull(); }); it("expected premium cost per level", () => { expect(expectedCredits(1)).toBe(0); expect(expectedCredits(2)).toBe(0); expect(expectedCredits(3)).toBe(1); expect(expectedCredits(4)).toBe(6); expect(expectedCredits(4, { renderJs: false })).toBe(1); }); }); describe("Budget", () => { afterEach(() => { setBudgetStore(null); delete process.env.DCI_TEST_BUDGET; }); it("tracks local spend against the env limit and rolls per UTC day", () => { process.env.DCI_TEST_BUDGET = "10"; const b = new Budget("scrapfly", "DCI_TEST_BUDGET", 400); expect(b.limit()).toBe(10); expect(b.remaining()).toBe(10); b.spend(4); expect(b.used()).toBe(4); expect(b.remaining()).toBe(6); b.spend(10); expect(b.remaining()).toBe(0); expect(b.snapshot()).toEqual({ day: budgetDay(), used: 14, local: 14, limit: 10 }); }); it("uses the fallback limit without env and never goes below its own spend when a shared store lags", () => { const b = new Budget("firecrawl", "DCI_TEST_BUDGET", 200); expect(b.limit()).toBe(200); const spent: number[] = []; setBudgetStore({ used: () => 50, spend: (_p, _d, n) => { spent.push(n); } }); expect(b.used()).toBe(50); // shared view b.spend(60); expect(spent).toEqual([60]); expect(b.used()).toBe(60); // local 60 > shared 50 setBudgetStore({ used: () => 100, spend: () => { throw new Error("redis down"); } }); expect(() => b.spend(1)).not.toThrow(); expect(b.used()).toBe(100); }); });