188 lines
8.2 KiB
TypeScript
188 lines
8.2 KiB
TypeScript
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.
|
|
});
|
|
});
|