import type { NavigationBudget } from "@umt/contracts"; import { CAMOFOX_FIXTURE_COOKIE, CAMOFOX_FIXTURE_PASSKEY, CAMOFOX_FIXTURE_PASSWORD, createMockCamofox, type MockCamofox, } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { BrowserError, type BrowserLogger, CAMOFOX_ENDPOINTS, CAMOFOX_VERIFIED_RELEASES, type CamofoxConfig, camofoxCapabilitiesFromOpenApi, camofoxStaticCapabilities, classifyHttpStatus, createCamofoxBrowserProvider, createNoneBrowserProvider, hostAllowed, InMemoryProfileLock, profileKeyFor, redactBrowserUrl, redactSnapshot, staticBrowserSecrets, } from "./index.ts"; const ACCESS_KEY = "camofox-access-key-fixture"; const PROVIDER = "11111111-1111-4111-8111-111111111111"; const SITE = "22222222-2222-4222-8222-222222222222"; const ACCOUNT = "33333333-3333-4333-8333-333333333333"; const budget = (over: Partial = {}): NavigationBudget => ({ domainAllowlist: ["audiences.example"], timeoutMs: 500, maxRetries: 1, maxBytes: 1_000_000, ...over, }); let mocks: MockCamofox[] = []; afterEach(async () => { await Promise.all(mocks.map((m) => m.close())); mocks = []; }); async function setup( opts: { accessKey?: string; key?: string | null; omitPaths?: string[] } = {}, extra: Partial = {}, ) { const mock = await createMockCamofox({ ...(opts.accessKey ? { accessKey: opts.accessKey } : {}), ...(opts.omitPaths ? { omitPaths: opts.omitPaths } : {}), }); mocks.push(mock); const logs: Parameters[0][] = []; const key = opts.key === undefined ? opts.accessKey : opts.key; const provider = createCamofoxBrowserProvider({ providerId: PROVIDER, baseUrl: `${mock.url}/`, secrets: staticBrowserSecrets(key ? { [`provider:${PROVIDER}`]: key } : {}), timeoutMs: 500, logger: (e) => logs.push(e), ...extra, }); return { mock, provider, logs }; } const open = (p: Awaited>["provider"], url?: string) => p.openSession({ providerId: PROVIDER, siteId: SITE, accountId: ACCOUNT, budget: budget(), ...(url ? { initialUrl: url } : {}), }); async function rejectKind(p: Promise): Promise { try { await p; } catch (e) { expect(e).toBeInstanceOf(BrowserError); return (e as BrowserError).kind; } throw new Error("预期抛错"); } describe("能力声明", () => { it("静态声明:全部能力 supported,verified 只来自配置;待核验项有注明", () => { const c = camofoxStaticCapabilities({ snapshot: true, interact: true }); expect(c.navigate).toMatchObject({ supported: true, verified: false, source: "static" }); expect(c.snapshot).toMatchObject({ supported: true, verified: true }); expect(c.interact).toMatchObject({ supported: true, verified: true }); expect(c.interact.note).toContain("evaluate"); expect(c.takeover).toMatchObject({ supported: true, verified: false }); expect(c.upstreamVersion).toBeNull(); }); it("探测 /openapi.json:读取上游版本,缺端点的能力降级", async () => { const { provider } = await setup({ omitPaths: ["get /tabs/{tabId}/snapshot"] }); const c = await provider.capabilities({ probe: true }); expect(c.upstreamVersion).toBe("1.17.0-mock"); expect(c.probedAt).not.toBeNull(); expect(c.navigate).toMatchObject({ supported: true, source: "probed", verified: false }); expect(c.snapshot).toMatchObject({ supported: false, source: "probed" }); expect(c.snapshot.note).toContain("/tabs/{tabId}/snapshot"); }); it("真机核验表:探测到已核验的上游版本才置 verified;配置可逐项撤销;其它版本不继承", () => { const paths: Record> = {}; for (const e of Object.values(CAMOFOX_ENDPOINTS).flat()) { const [m, p] = e.split(" "); paths[p!] = { ...paths[p!], [m!]: {} }; } const doc = (version: string) => ({ info: { version }, paths }); for (const version of ["1.17.0", "1.18.0"]) { const ok = camofoxCapabilitiesFromOpenApi(doc(version)); expect(Object.keys(CAMOFOX_VERIFIED_RELEASES)).toContain(version); expect(CAMOFOX_VERIFIED_RELEASES[version]!.keys).toHaveLength(10); for (const k of CAMOFOX_VERIFIED_RELEASES[version]!.keys) expect(ok[k]).toMatchObject({ supported: true, verified: true }); expect(camofoxCapabilitiesFromOpenApi(doc(version), { takeover: false }).takeover.verified).toBe(false); } const other = camofoxCapabilitiesFromOpenApi(doc("1.19.0")); expect(other.snapshot.verified).toBe(false); // 静态声明不知道上游版本:一律未核验 expect(camofoxStaticCapabilities().snapshot.verified).toBe(false); }); it("none 实现:能力全关,动作报 unavailable", async () => { const none = createNoneBrowserProvider(); const c = await none.capabilities(); expect(Object.values(c).filter((v) => v && typeof v === "object" && "supported" in v && v.supported)).toEqual([]); expect((await none.health()).status).toBe("disabled"); expect(await rejectKind(none.navigate({ sessionId: "x" }, "https://a.example", budget()))).toBe("unavailable"); }); }); describe("认证与健康", () => { it("每个请求带 Bearer 访问凭据;凭据不进日志与错误信息", async () => { const { mock, provider, logs } = await setup({ accessKey: ACCESS_KEY }); await open(provider, "https://audiences.example/torrents.php"); const call = mock.callsTo("/tabs")[0]!; expect(call.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`); expect(JSON.stringify(logs)).not.toContain(ACCESS_KEY); expect(logs.every((l) => !l.path?.includes("?"))).toBe(true); }); it("凭据缺失/错误 → auth,不重试", async () => { const { mock, provider } = await setup({ accessKey: ACCESS_KEY, key: "wrong-key" }); try { await open(provider); throw new Error("预期抛错"); } catch (e) { expect(e).toMatchObject({ kind: "auth", httpStatus: 401 }); expect(String((e as Error).message)).not.toContain("wrong-key"); } expect(mock.callsTo("/tabs")).toHaveLength(1); }); it("health:/health 免认证;503 → degraded;连不上 → down", async () => { const { mock, provider } = await setup({ accessKey: ACCESS_KEY, key: null }); const ok = await provider.health(); expect(ok.status).toBe("ok"); expect(ok.detail).toMatchObject({ engine: "camoufox", browserConnected: true }); mock.fault("/health", { status: 503, body: { ok: false, recovering: true } }); expect((await provider.health()).status).toBe("degraded"); await mock.close(); mocks = []; expect((await provider.health()).status).toBe("down"); }); }); describe("会话、导航与快照", () => { it("openSession 以 profileKey 作 camofox userId,会话 id 作 sessionKey", async () => { const { mock, provider } = await setup(); const r = await open(provider, "https://audiences.example/torrents.php"); expect(r.session.profileKey).toBe(profileKeyFor(SITE, ACCOUNT)); expect(r.session.controller).toBe("automation"); expect(r.tab.upstreamTabId).toBe("tab-1"); expect(r.navigation).toMatchObject({ httpStatus: 200, ok: true }); expect([...mock.tabs.values()][0]).toMatchObject({ userId: r.session.profileKey, sessionKey: r.session.id }); // 同一 profile 不能并存两个未关闭会话 expect(await rejectKind(open(provider))).toBe("control_conflict"); }); it("快照进入模型前脱敏:密码值、Cookie 值、passkey URL 均被遮蔽", async () => { const { provider } = await setup(); const { session } = await open(provider, "https://audiences.example/torrents.php"); await provider.navigate( { sessionId: session.id }, `https://audiences.example/details.php?id=42&passkey=${CAMOFOX_FIXTURE_PASSKEY}`, budget(), ); const snap = await provider.snapshot({ sessionId: session.id }); const dump = JSON.stringify(snap); for (const secret of [CAMOFOX_FIXTURE_PASSWORD, CAMOFOX_FIXTURE_COOKIE, CAMOFOX_FIXTURE_PASSKEY]) expect(dump).not.toContain(secret); expect(snap.text).toContain("奥本海默"); expect(snap.text).toContain("[button e6] 登录"); expect(snap.url).toBe("https://audiences.example/details.php?id=42&passkey=[REDACTED]"); const kinds = Object.fromEntries(snap.redactions.map((r) => [r.kind, r.count])); expect(kinds.password_field).toBeGreaterThanOrEqual(2); expect(kinds.cookie).toBeGreaterThanOrEqual(1); expect(kinds.credential_url).toBeGreaterThanOrEqual(2); expect(snap.refsCount).toBe(7); }); it("redactSnapshot:HTML 密码框、URL userinfo、路径 passkey、YAML 形式;普通文本不动", () => { const r = redactSnapshot({ text: [ '', "https://user:pw123@tracker.example/announce/passkey/abcdef0123456789", '- textbox "Password": yaml-secret', "做种 128 · 大小 58.2 GB", ].join("\n"), url: "https://audiences.example/rss.php?rsskey=deadbeefdeadbeef&cat=401", }); expect(r.text).not.toMatch(/s3cret-html|pw123|abcdef0123456789|yaml-secret/); expect(r.text).toContain("做种 128 · 大小 58.2 GB"); expect(r.url).toBe("https://audiences.example/rss.php?rsskey=[REDACTED]&cat=401"); expect(redactBrowserUrl("https://a.example/x?id=1")).toBe("https://a.example/x?id=1"); // 幂等:已脱敏的文本再过一遍不变 expect(redactSnapshot({ text: r.text, url: r.url }).text).toBe(r.text); }); it("导航预算:白名单外直接拒绝不发请求;重定向出白名单报 domain_blocked", async () => { const { mock, provider } = await setup(); const { session } = await open(provider); expect(await rejectKind(provider.navigate({ sessionId: session.id }, "https://evil.example/", budget()))).toBe( "domain_blocked", ); expect(await rejectKind(provider.navigate({ sessionId: session.id }, "file:///etc/passwd", budget()))).toBe( "domain_blocked", ); expect(mock.callsTo(/navigate$/)).toHaveLength(0); const redirect = `https://audiences.example/jump?redirect=${encodeURIComponent("https://other.example/login")}`; expect(await rejectKind(provider.navigate({ sessionId: session.id }, redirect, budget()))).toBe("domain_blocked"); expect(hostAllowed("cdn.audiences.example", ["audiences.example"])).toBe(true); expect(hostAllowed("audiences.example.evil", ["audiences.example"])).toBe(false); expect(hostAllowed("a.example", [])).toBe(false); }); it("截图:解码 1x1 PNG;登录页不可进模型;超字节预算报 budget_exceeded", async () => { const { provider } = await setup(); const { session } = await open(provider, "https://audiences.example/torrents.php"); const shot = await provider.screenshot({ sessionId: session.id }); expect(shot.mimeType).toBe("image/png"); expect([...shot.data.slice(1, 4)].map((c) => String.fromCharCode(c)).join("")).toBe("PNG"); expect(shot.modelSafe).toBe(true); await provider.navigate({ sessionId: session.id }, "https://audiences.example/login.php", budget()); expect((await provider.screenshot({ sessionId: session.id })).modelSafe).toBe(false); expect(await rejectKind(provider.screenshot({ sessionId: session.id }, { maxBytes: 10 }))).toBe("budget_exceeded"); }); it("人工接管期间拒绝自动化导航", async () => { const { provider } = await setup(); const { session } = await open(provider); provider.setController(session.id, "human"); expect(await rejectKind(provider.navigate({ sessionId: session.id }, "https://audiences.example/", budget()))).toBe( "control_conflict", ); }); }); describe("串行锁", () => { it("同一 profile 严格串行、不同 profile 并行;等待超时报 lock_timeout", async () => { const lock = new InMemoryProfileLock(); const order: string[] = []; const task = (key: string, name: string, ms: number) => lock.run(key, async () => { order.push(`${name}:start`); await new Promise((r) => setTimeout(r, ms)); order.push(`${name}:end`); }); await Promise.all([task("p1", "a", 20), task("p1", "b", 1), task("p2", "c", 1)]); expect(order.indexOf("a:end")).toBeLessThan(order.indexOf("b:start")); expect(order.indexOf("c:start")).toBeLessThan(order.indexOf("a:end")); expect(lock.size()).toBe(0); const slow = lock.run("p1", () => new Promise((r) => setTimeout(r, 60))); try { await lock.run("p1", async () => "never", { waitMs: 10 }); throw new Error("预期超时"); } catch (e) { expect(e).toMatchObject({ kind: "lock_timeout" }); } await slow; expect(lock.size()).toBe(0); }); it("适配器对同一会话的并发请求逐个发往上游", async () => { const { mock, provider } = await setup(); const { session } = await open(provider, "https://audiences.example/torrents.php"); mock.fault(/snapshot$/, { delayMs: 40 }); const started = Date.now(); await Promise.all([provider.snapshot({ sessionId: session.id }), provider.screenshot({ sessionId: session.id })]); const shotCall = mock.callsTo(/screenshot$/)[0]!; expect(shotCall.status).toBe(200); expect(Date.now() - started).toBeGreaterThanOrEqual(40); }); }); describe("未知会话", () => { it("交互/提取/下载/关闭对不存在的会话报 not_found;listSessions 只列内存镜像", async () => { const { provider } = await setup(); const ref = { sessionId: "x" }; const calls: Promise[] = [ provider.click(ref, { ref: "e1" }), provider.type(ref, { ref: "e1" }, { kind: "text", text: "x" }), provider.extract(ref, {}), provider.waitForDownload(ref, { timeoutMs: 10, maxBytes: 10 }), provider.closeSession("x"), ]; for (const c of calls) expect(await rejectKind(c)).toBe("not_found"); expect(await rejectKind(provider.releaseControl("lease"))).toBe("not_found"); expect(await provider.listSessions()).toEqual([]); }); }); describe("错误分类", () => { it("HTTP 状态映射", () => { expect(classifyHttpStatus(401)).toBe("auth"); expect(classifyHttpStatus(403)).toBe("auth"); expect(classifyHttpStatus(404)).toBe("not_found"); expect(classifyHttpStatus(410)).toBe("not_found"); expect(classifyHttpStatus(409)).toBe("bad_request"); expect(classifyHttpStatus(429)).toBe("rate_limited"); expect(classifyHttpStatus(500)).toBe("upstream"); expect(classifyHttpStatus(503)).toBe("unavailable"); expect(new BrowserError("upstream").retryable).toBe(true); expect(new BrowserError("auth").retryable).toBe(false); }); it("上游 5xx 在 maxRetries 内重试;超时分类为 timeout;429 不重试", async () => { const { mock, provider } = await setup(); const { session } = await open(provider); const ref = { sessionId: session.id }; mock.fault(/navigate$/, { status: 502, times: 1 }); const r = await provider.navigate(ref, "https://audiences.example/torrents.php", budget({ maxRetries: 1 })); expect(r.attempts).toBe(2); mock.fault(/navigate$/, { delayMs: 200, times: 5 }); expect( await rejectKind(provider.navigate(ref, "https://audiences.example/", budget({ timeoutMs: 50, maxRetries: 0 }))), ).toBe("timeout"); mock.clearFaults(); mock.fault(/navigate$/, { status: 429, times: 5 }); const before = mock.callsTo(/navigate$/).length; expect(await rejectKind(provider.navigate(ref, "https://audiences.example/", budget({ maxRetries: 3 })))).toBe( "rate_limited", ); expect(mock.callsTo(/navigate$/).length - before).toBe(1); }); it("未知会话 → not_found;上游返回非 JSON → protocol", async () => { const { mock, provider } = await setup(); expect(await rejectKind(provider.snapshot({ sessionId: "nope" }))).toBe("not_found"); const { session } = await open(provider); mock.fault(/snapshot$/, { status: 200, body: undefined }); expect(await rejectKind(provider.snapshot({ sessionId: session.id }))).toBe("protocol"); }); });