import { createHash } from "node:crypto"; import type { NavigationBudget } from "@umt/contracts"; import { buildFakeTorrent, createMockCamofox, MOCK_ROW_H, MOCK_ROW_Y0, type MockCamofox } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { BrowserError, createCamofoxBrowserProvider, memoryDownloadSink, staticBrowserSecrets } from "./index.ts"; // C1-E:camofox 适配器的交互、标签页、下载捕获与控制权(对 testkit 交互式 mock 站点验证,未连真实实例)。 const PROVIDER = "11111111-1111-4111-8111-111111111111"; const SITE = "22222222-2222-4222-8222-222222222222"; const ACCOUNT = "33333333-3333-4333-8333-333333333333"; const ORIGIN = "https://audiences.example"; const PASSWORD = "s3cret-login-pw"; const PASSKEY = "fedcba9876543210fedcba9876543210"; const TORRENT = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay" }); 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: { captcha?: boolean; accessKey?: string } = {}) { const mock = await createMockCamofox({ ...(opts.accessKey ? { accessKey: opts.accessKey } : {}), site: { origin: ORIGIN, username: "fixture-user", password: PASSWORD, passkey: PASSKEY, ...(opts.captcha ? { captcha: true, captchaAnswer: "4321" } : {}), releases: [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }], torrent: () => TORRENT.bytes, }, }); mocks.push(mock); const sink = memoryDownloadSink(); const provider = createCamofoxBrowserProvider({ providerId: PROVIDER, baseUrl: mock.url, secrets: staticBrowserSecrets({ ...(opts.accessKey ? { [`provider:${PROVIDER}`]: opts.accessKey } : {}), [`site:${SITE}:${ACCOUNT}:username`]: "fixture-user", [`site:${SITE}:${ACCOUNT}:password`]: PASSWORD, }), timeoutMs: 500, downloadSink: sink, downloadPollMs: 20, }); const { session, tab } = await provider.openSession({ providerId: PROVIDER, siteId: SITE, accountId: ACCOUNT, budget: budget(), initialUrl: `${ORIGIN}/torrents.php`, }); return { mock, provider, sink, session, tab, ref: { sessionId: session.id } }; } async function kind(p: Promise): Promise { try { await p; } catch (e) { expect(e).toBeInstanceOf(BrowserError); return (e as BrowserError).kind; } throw new Error("预期抛错"); } type Setup = Awaited>; async function login(x: Setup) { await x.provider.snapshot(x.ref); await x.provider.type(x.ref, { ref: "e2" }, { kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "username" }); await x.provider.type(x.ref, { ref: "e3" }, { kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "password" }); await x.provider.click(x.ref, { ref: "e6" }); } describe("登录与凭证注入", () => { it("登录失效落到登录页;凭证由服务端解析后下发,快照里的密码值被遮蔽", async () => { const x = await setup(); const info = x.provider.sessionInfo(x.session.id)!; expect(info.loginPage).toBe(true); expect(info.session.currentUrl).toContain("/login.php"); await x.provider.snapshot(x.ref); await x.provider.type( x.ref, { ref: "e2" }, { kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "username" }, ); await x.provider.type( x.ref, { ref: "e3" }, { kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "password" }, ); // 输入后页面上的密码框有值:快照进入模型前必须遮蔽 const snap = await x.provider.snapshot(x.ref); expect(snap.text).not.toContain(PASSWORD); expect(snap.redactions.some((r) => r.kind === "password_field")).toBe(true); // 凭证确实到了浏览器 expect(x.mock.actions.find((a) => a.action === "type" && a.body.ref === "e3")?.body.text).toBe(PASSWORD); await x.provider.click(x.ref, { ref: "e6" }); expect(x.provider.sessionInfo(x.session.id)!.loginPage).toBe(false); expect(x.mock.loggedIn.has(x.session.profileKey)).toBe(true); }); it("账号没有该凭证 → auth,错误信息不含任何凭证", async () => { const x = await setup(); await x.provider.snapshot(x.ref); const e = await x.provider .type(x.ref, { ref: "e3" }, { kind: "secret", siteId: SITE, accountId: null, field: "password" }) .catch((err) => err as BrowserError); expect(e).toMatchObject({ kind: "auth" }); expect(String((e as Error).message)).not.toContain(PASSWORD); }); }); describe("元素引用与控制权", () => { it("导航/点击后旧引用失效(stale_ref),重新快照后可用;选择器与坐标不受限", async () => { const x = await setup(); expect(await kind(x.provider.click(x.ref, { ref: "e6" }))).toBe("stale_ref"); await x.provider.snapshot(x.ref); expect(x.provider.sessionInfo(x.session.id)!.refsValid).toBe(true); await x.provider.navigate(x.ref, `${ORIGIN}/`, budget()); expect(await kind(x.provider.click(x.ref, { ref: "e2" }))).toBe("stale_ref"); await x.provider.click(x.ref, { selector: "button[type=submit]" }); expect(x.mock.actions.at(-1)).toMatchObject({ action: "click", body: { selector: "button[type=submit]" } }); }); it("接管:人工期间自动化被拒、人工输入可用;交还后旧引用作废,需重新读取", async () => { const x = await setup(); await x.provider.snapshot(x.ref); const lease = await x.provider.acquireControl({ sessionId: x.session.id, holderKind: "human", holder: "admin", reason: "验证码", ttlMs: 60_000, }); expect(lease.status).toBe("active"); expect(await kind(x.provider.click(x.ref, { ref: "e6" }))).toBe("control_conflict"); expect( await kind( x.provider.acquireControl({ sessionId: x.session.id, holderKind: "human", holder: "other", reason: "x", ttlMs: 60_000, }), ), ).toBe("control_conflict"); // 人工按坐标点用户名框(第 2 行),输入落到焦点元素 const human = { ...x.ref, as: "human" as const }; await x.provider.click(human, { x: 100, y: MOCK_ROW_Y0 + MOCK_ROW_H * 1 + 5 }); await x.provider.type(human, null, { kind: "text", text: "fixture-user" }); await x.provider.click(human, { x: 100, y: MOCK_ROW_Y0 + MOCK_ROW_H * 2 + 5 }); await x.provider.type(human, null, { kind: "text", text: PASSWORD }); await x.provider.press(human, "Enter"); expect(x.mock.loggedIn.has(x.session.profileKey)).toBe(true); await x.provider.releaseControl(lease.id, "done"); const info = x.provider.sessionInfo(x.session.id)!; expect(info.session.controller).toBe("automation"); expect(info.refsValid).toBe(false); expect(await kind(x.provider.click(x.ref, { ref: "e11" }))).toBe("stale_ref"); const snap = await x.provider.snapshot(x.ref); expect(snap.text).toContain("种子列表"); expect(snap.text).not.toContain(PASSKEY); }); it("人工身份在自动化控制期间操作 → control_conflict(先接管)", async () => { const x = await setup(); expect(await kind(x.provider.scroll({ ...x.ref, as: "human" }, { direction: "down", amount: 300 }))).toBe( "control_conflict", ); await x.provider.scroll(x.ref, { direction: "down", amount: 300 }); await x.provider.snapshot(x.ref); await x.provider.select(x.ref, { ref: "e2" }, "401"); expect(x.mock.actions.map((a) => a.action)).toEqual(expect.arrayContaining(["scroll", "select"])); }); }); describe("标签页与历史", () => { it("新建/关闭标签页;上游回收的标签页对账标 lost", async () => { const x = await setup(); const t2 = await x.provider.openTab(x.session.id, { url: `${ORIGIN}/`, budget: budget() }); expect(x.provider.sessionInfo(x.session.id)!.currentTabId).toBe(t2.upstreamTabId); expect(await kind(x.provider.openTab(x.session.id, { url: "https://evil.example/", budget: budget() }))).toBe( "domain_blocked", ); await x.provider.closeTab({ ...x.ref, tabId: t2.upstreamTabId }); expect(x.mock.tabs.has(t2.upstreamTabId)).toBe(false); expect(x.provider.sessionInfo(x.session.id)!.currentTabId).toBe(x.tab.upstreamTabId); x.mock.dropTab(x.tab.upstreamTabId); const r = await x.provider.reconcileTabs(x.session.id); expect(r.lost).toEqual([x.tab.upstreamTabId]); expect( x.provider.sessionInfo(x.session.id)!.tabs.find((t) => t.upstreamTabId === x.tab.upstreamTabId)?.status, ).toBe("lost"); expect(await kind(x.provider.snapshot({ ...x.ref, tabId: x.tab.upstreamTabId }))).toBe("not_found"); }); it("后退/前进/刷新", async () => { const x = await setup(); await login(x); await x.provider.navigate(x.ref, `${ORIGIN}/details.php?id=42`, budget()); const back = await x.provider.history(x.ref, "back", budget()); expect(back.url).toContain("/torrents.php"); const fwd = await x.provider.history(x.ref, "forward", budget()); expect(fwd.url).toContain("/details.php"); await x.provider.history(x.ref, "reload", budget()); expect(x.mock.callsTo(/\/refresh$/)).toHaveLength(1); }); }); describe("提取与下载捕获", () => { it("extract 按 x-ref 取字段,需先快照", async () => { const x = await setup(); await login(x); const schema = { type: "object", properties: { title: { type: "string", "x-ref": "e10" } } }; expect(await kind(x.provider.extract(x.ref, schema))).toBe("stale_ref"); await x.provider.snapshot(x.ref); expect(await x.provider.extract(x.ref, schema)).toEqual({ title: "Oppenheimer 2023 2160p UHD BluRay" }); }); it("点击下载 → 捕获 .torrent:字节进暂存、sha256 一致、来源地址去掉 passkey;只取一次", async () => { const x = await setup(); await login(x); await x.provider.snapshot(x.ref); await x.provider.click(x.ref, { ref: "e11" }); const a = await x.provider.waitForDownload(x.ref, { timeoutMs: 1_000, maxBytes: 5_000_000 }); expect(a.filename).toMatch(/\.torrent$/); expect(a.sizeBytes).toBe(TORRENT.bytes.byteLength); expect(a.sha256).toBe(createHash("sha256").update(TORRENT.bytes).digest("hex")); expect(x.sink.blobs.get(a.bytes.ref)).toEqual(TORRENT.bytes); expect(a.sourceUrl).toContain("id=42"); expect(a.sourceUrl).not.toContain(PASSKEY); expect(JSON.stringify(a)).not.toContain(PASSKEY); // 已消费:再等只会超时 expect(await kind(x.provider.waitForDownload(x.ref, { timeoutMs: 100, maxBytes: 5_000_000 }))).toBe("timeout"); }); it("下载超字节预算 → budget_exceeded", async () => { const x = await setup(); await login(x); await x.provider.snapshot(x.ref); await x.provider.click(x.ref, { ref: "e11" }); expect(await kind(x.provider.waitForDownload(x.ref, { timeoutMs: 500, maxBytes: 10 }))).toBe("budget_exceeded"); }); }); describe("异常路径", () => { it("交互时上游 401 → auth;超时 → timeout;不重试", async () => { const x = await setup({ accessKey: "k-1" }); await x.provider.snapshot(x.ref); x.mock.fault(/\/click$/, { status: 401 }); expect(await kind(x.provider.click(x.ref, { selector: "a" }))).toBe("auth"); x.mock.fault(/\/type$/, { delayMs: 800 }); expect(await kind(x.provider.type(x.ref, { selector: "input" }, { kind: "text", text: "x" }))).toBe("timeout"); expect(x.mock.callsTo(/\/click$/)).toHaveLength(1); }); it("关闭会话:DELETE /sessions/{userId},上游标签页清空,之后动作报 not_found", async () => { const x = await setup(); await x.provider.closeSession(x.session.id, "done"); expect(x.mock.callsTo(/^\/sessions\//)[0]?.method).toBe("DELETE"); expect(x.mock.tabs.size).toBe(0); expect(await x.provider.listSessions()).toEqual([]); expect(await kind(x.provider.snapshot(x.ref))).toBe("not_found"); }); it("恢复会话(服务重启):引用失效,可继续读取", async () => { const x = await setup(); const info = x.provider.sessionInfo(x.session.id)!; const fresh = createCamofoxBrowserProvider({ providerId: PROVIDER, baseUrl: x.mock.url, secrets: staticBrowserSecrets({}), timeoutMs: 500, }); fresh.restoreSession({ session: info.session, tabs: info.tabs, currentTabId: info.currentTabId, budget: budget() }); expect(fresh.sessionInfo(x.session.id)!.refsValid).toBe(false); expect(await kind(fresh.click(x.ref, { ref: "e6" }))).toBe("stale_ref"); expect((await fresh.snapshot(x.ref)).text).toContain("登录"); }); });