import { createServer, type Server } from "node:http"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { DecodoProvider, DirectProvider, OxylabsProvider, ProviderError, ProviderRegistry, SoaxProvider, type ProviderRequest, type ProxyProvider } from "../src/index"; const env = { OXYLABS_USERNAME: "customer-acme", OXYLABS_PASSWORD: "pw1", DECODO_USERNAME: "acme", DECODO_PASSWORD: "pw2", SOAX_USERNAME: "package-123", SOAX_PASSWORD: "pw3", FETCHA_DIRECT_EGRESS: "1", } as NodeJS.ProcessEnv; const baseReq = (url: string, extra: Partial = {}): ProviderRequest => ({ requestId: "req_test", attemptId: "att_test", url, method: "GET", headers: {}, timeoutMs: 5000, network: "residential", geo: { country: null, region: null, city: null }, followRedirects: true, maxRedirects: 5, maxResponseBytes: 1_000_000, ...extra, }); /** Contract every adapter must satisfy. */ function contract(name: string, make: () => ProxyProvider) { describe(`${name} contract`, () => { const p = make(); it("is configured when credentials are present", () => expect(p.isConfigured()).toBe(true)); it("declares at least one network", () => expect(p.networks.length).toBeGreaterThan(0)); it("prices bandwidth", () => { expect(p.pricePerGb(p.networks[0]!)).toBeGreaterThan(0); expect(p.estimateCost(p.networks[0]!, 1_073_741_824)).toBeCloseTo(p.pricePerGb(p.networks[0]!)); expect(p.estimateCost(p.networks[0]!, 0)).toBe(0); }); it("supports worldwide geo", () => expect(p.supportsGeo({ country: "CA", region: null, city: null })).toBe(p.id !== "direct")); }); } contract("Oxylabs", () => new OxylabsProvider(env)); contract("Decodo", () => new DecodoProvider(env)); contract("SOAX", () => new SoaxProvider(env)); contract("Direct", () => new DirectProvider(env)); describe("username grammar (geo + sessions)", () => { it("oxylabs", () => { const p = new OxylabsProvider(env); expect(p.endpoint({ geo: { country: "CA", region: "quebec", city: "montreal" }, sessionKey: "abc-123", sessionMinutes: 10 })).toEqual({ host: "pr.oxylabs.io", port: 7777, username: "customer-acme-cc-CA-st-ca_quebec-city-montreal-sessid-abc123-sesstime-10", password: "pw1", }); expect(p.endpoint({ geo: { country: null, region: null, city: null } }).username).toBe("customer-acme"); }); it("decodo", () => { const p = new DecodoProvider(env); expect(p.endpoint({ geo: { country: "US", region: "new_york", city: null }, sessionKey: "s1", sessionMinutes: 5 })).toEqual({ host: "gate.decodo.com", port: 7000, username: "user-acme-country-us-state-us_new_york-session-s1-sessionduration-5", password: "pw2", }); }); it("soax", () => { const p = new SoaxProvider(env); expect(p.endpoint({ geo: { country: "GB", region: null, city: "london" }, sessionKey: "k", sessionMinutes: 2 }).username).toBe("package-123-country-gb-city-london-sessionid-k-sessionlength-120"); }); }); describe("registry", () => { it("excludes unconfigured and disabled providers", () => { const r = new ProviderRegistry({ env: { OXYLABS_USERNAME: "a", OXYLABS_PASSWORD: "b" } as NodeJS.ProcessEnv }); expect(r.available().map((p) => p.id)).toEqual(["oxylabs"]); expect(r.available("mobile")).toHaveLength(0); r.setDisabled("oxylabs", true); expect(r.available()).toHaveLength(0); }); }); describe("direct HTTP execution (local server)", () => { let server: Server; let base = ""; beforeAll(async () => { server = createServer((req, res) => { if (req.url === "/redirect") { res.writeHead(302, { location: "/final" }); return res.end(); } if (req.url === "/final") { res.writeHead(200, { "content-type": "text/html", "set-cookie": "a=1; Path=/" }); return res.end("final"); } if (req.url === "/big") { res.writeHead(200, { "content-type": "application/octet-stream" }); return res.end(Buffer.alloc(50_000, 1)); } if (req.url === "/slow") { return setTimeout(() => { res.writeHead(200); res.end("late"); }, 2000); } if (req.url === "/echo") { let body = ""; req.on("data", (c) => (body += c)); return req.on("end", () => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ method: req.method, body, ua: req.headers["user-agent"], x: req.headers["x-test"] })); }); } res.writeHead(404); res.end("nope"); }); await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); const addr = server.address(); base = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; }); afterAll(() => new Promise((r) => server.close(() => r()))); const p = new DirectProvider(env); it("follows redirects and validates each hop via onRedirect", async () => { const hops: string[] = []; const res = await p.fetch(baseReq(`${base}/redirect`, { onRedirect: async (u) => void hops.push(u) })); expect(res.status).toBe(200); expect(res.redirects).toBe(1); expect(hops[0]).toBe(`${base}/final`); expect(res.finalUrl).toBe(`${base}/final`); expect(res.headers["set-cookie"]).toContain("a=1"); expect(res.bytesIn).toBeGreaterThan(30); }); it("aborts redirect chains when the callback throws", async () => { await expect(p.fetch(baseReq(`${base}/redirect`, { onRedirect: async () => { throw new Error("blocked"); } }))).rejects.toThrow("blocked"); }); it("enforces the response size cap", async () => { await expect(p.fetch(baseReq(`${base}/big`, { maxResponseBytes: 10_000 }))).rejects.toMatchObject({ kind: "too_large" }); }); it("times out", async () => { await expect(p.fetch(baseReq(`${base}/slow`, { timeoutMs: 300 }))).rejects.toBeInstanceOf(ProviderError); }); it("performs POST with headers and body", async () => { const res = await p.fetch(baseReq(`${base}/echo`, { method: "POST", body: '{"a":1}', headers: { "x-test": "yes", host: "evil" } })); const j = JSON.parse(res.body.toString()) as { method: string; body: string; x: string }; expect(j.method).toBe("POST"); expect(j.body).toBe('{"a":1}'); expect(j.x).toBe("yes"); }); it("records timing", async () => { const res = await p.fetch(baseReq(`${base}/final`)); expect(res.timing.total_ms).toBeGreaterThanOrEqual(0); expect(res.timing.origin_ms).toBeGreaterThanOrEqual(0); }); });