Files
UHD_Media_Tool/packages/adapters/src/browser/interact.test.ts
T
rootandClaude Opus 5.5 c17d4b90bd feat(browser): complete camofox BrowserProvider with tabs, interaction, download capture and control lease
Contracts for browser API and browser.* agent capabilities; adapter implements
close/list/restore sessions, tabs + reconcile, history, click/type/select/scroll/press,
extract, waitForDownload (staged via DownloadSink, sha256), stale-ref guard and
automation/human actor check. testkit mock camofox gains an interactive site
(login redirect, captcha, click-driven page changes, downloads, PNG screenshots).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:17:46 +08:00

294 lines
13 KiB
TypeScript

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> = {}): 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<unknown>): Promise<string> {
try {
await p;
} catch (e) {
expect(e).toBeInstanceOf(BrowserError);
return (e as BrowserError).kind;
}
throw new Error("预期抛错");
}
type Setup = Awaited<ReturnType<typeof setup>>;
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("登录");
});
});