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]>
294 lines
13 KiB
TypeScript
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("登录");
|
|
});
|
|
});
|