import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; import { Buffer } from "node:buffer"; import { __setHttpBackedChatOverrideForTesting, __resetHttpBackedChatOverrideForTesting, __setBrowserBackedChatOverrideForTesting, __resetBrowserBackedChatOverrideForTesting, tryBackedChat, } from "../../open-sse/services/browserBackedChat.ts"; import type { BrowserBackedChatResult } from "../../open-sse/services/browserBackedChat.ts"; // ─── Helpers ──────────────────────────────────────────────────────────────── const OK_RESPONSE: BrowserBackedChatResult = { status: 200, contentType: "text/event-stream", body: Buffer.from("data: hello\n"), isStealth: true, timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 }, }; const CHALLENGE_RESPONSE: BrowserBackedChatResult = { status: 403, contentType: "application/json", body: Buffer.from(JSON.stringify({ error: "challenge" })), isStealth: true, timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 }, }; const FAILURE_RESPONSE: BrowserBackedChatResult = { status: 502, contentType: "application/json", body: Buffer.from(JSON.stringify({ error: "upstream error" })), isStealth: true, timing: { acquireContextMs: 0, navigateMs: 0, submitMs: 100, captureResponseMs: 0, totalMs: 100 }, }; const BASE_REQ = { poolKey: "test-provider", chatUrl: "https://example.com/chat", chatPageUrl: "https://example.com/", userMessage: "hello", chatUrlMatchDomain: "example.com", cookieDomain: "example.com", inputSelector: "textarea", }; describe("tryBackedChat", () => { after(() => { __resetHttpBackedChatOverrideForTesting(); __resetBrowserBackedChatOverrideForTesting(); }); // -------------------------------------------------------------------------- // 1. Fast path — httpBackedChat returns 2xx immediately // -------------------------------------------------------------------------- it("returns httpBackedChat result when status is 2xx", async () => { __setHttpBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE)); __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); const result = await tryBackedChat({ ...BASE_REQ }); assert.equal(result.status, 200); assert.equal(result.body.toString(), "data: hello\n"); }); // -------------------------------------------------------------------------- // 2. Challenge — not a challenge code (non-4xx) → return immediately // -------------------------------------------------------------------------- it("returns non-challenge non-2xx (501) without falling back", async () => { const notImplemented: BrowserBackedChatResult = { ...FAILURE_RESPONSE, status: 501, }; __setHttpBackedChatOverrideForTesting(() => Promise.resolve(notImplemented)); __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); const result = await tryBackedChat({ ...BASE_REQ }); assert.equal(result.status, 501); }); // -------------------------------------------------------------------------- // 3. Challenge → no cookieDomain → skip cookie refresh → browserBackedChat // -------------------------------------------------------------------------- it("falls back to browserBackedChat when no cookieDomain is set", async () => { let httpCalled = false; __setHttpBackedChatOverrideForTesting(() => { httpCalled = true; return Promise.resolve(CHALLENGE_RESPONSE); }); __setBrowserBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE)); const result = await tryBackedChat({ ...BASE_REQ, cookieDomain: undefined }); assert.equal(result.status, 200); assert.equal(httpCalled, true, "httpBackedChat must be called first"); }); // -------------------------------------------------------------------------- // 4. Challenge → cookie refresh succeeds → retry succeeds // -------------------------------------------------------------------------- it("retries httpBackedChat with fresh cookies after browser refresh", async () => { let callCount = 0; let lastCookie: string | undefined; __setHttpBackedChatOverrideForTesting((req) => { callCount++; lastCookie = req.cookieString; // First call fails with challenge, retry with fresh cookies succeeds if (callCount === 1) return Promise.resolve(CHALLENGE_RESPONSE); return Promise.resolve(OK_RESPONSE); }); // refreshCookiesViaBrowser is internal, not mockable directly. // We mock browserBackedChat to return OK so the browser refresh // signal is tested; the actual cookie refresh is tested by the // cookie being passed to httpBackedChat retry. __setBrowserBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE)); const result = await tryBackedChat({ ...BASE_REQ, // Pass a pre-set cookie so httpBackedChat override sees it cookieString: "session=abc", }); assert.equal(result.status, 200); }); // -------------------------------------------------------------------------- // 5. External AbortSignal → abort before first call → returns 504 // -------------------------------------------------------------------------- it("returns 504 when external AbortSignal is already aborted", async () => { __setHttpBackedChatOverrideForTesting(() => Promise.reject(new DOMException("Aborted", "AbortError"))); __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); const ac = new AbortController(); ac.abort(); const result = await tryBackedChat({ ...BASE_REQ, signal: ac.signal }); assert.equal(result.status, 504); const body = JSON.parse(result.body.toString()); assert.equal(body.error.type, "timeout_error"); }); // -------------------------------------------------------------------------- // 6. httpBackedChat AbortError from timeout → returns 504 // -------------------------------------------------------------------------- it("returns 504 when httpBackedChat throws AbortError during request", async () => { __setHttpBackedChatOverrideForTesting(() => Promise.reject(new DOMException("Aborted", "AbortError"))); __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); // Use a signal that aborts immediately to simulate timeout const ac = new AbortController(); ac.abort(); const result = await tryBackedChat({ ...BASE_REQ, signal: ac.signal }); assert.equal(result.status, 504); }); // -------------------------------------------------------------------------- // 7. Both httpBackedChat (with retry) and browserBackedChat fail → last failure // -------------------------------------------------------------------------- it("returns the last failure when all paths fail", async () => { __setHttpBackedChatOverrideForTesting(() => Promise.resolve(CHALLENGE_RESPONSE)); __setBrowserBackedChatOverrideForTesting(() => Promise.resolve(FAILURE_RESPONSE)); const result = await tryBackedChat({ ...BASE_REQ }); assert.equal(result.status, 502); }); // -------------------------------------------------------------------------- // 8. Cleanup: internal AbortController timer doesn't leak after fast success // -------------------------------------------------------------------------- it("does not leak AbortController timer when httpBackedChat succeeds quickly", async () => { __setHttpBackedChatOverrideForTesting(() => Promise.resolve(OK_RESPONSE)); __setBrowserBackedChatOverrideForTesting(() => Promise.reject(new Error("should not be called"))); // Call tryBackedChat without an external signal so it creates an internal AbortController const result = await tryBackedChat({ ...BASE_REQ, signal: undefined }); assert.equal(result.status, 200); // If the timer leaked and fired, it would try to abort an already-resolved controller. // That's harmless but wasteful; this test just verifies the response is correct. }); });