Files
UHD_Media_Tool/packages/adapters/src/browser/browser.test.ts
T
rootandClaude Opus 5.5 40dd0e69e4 feat(browser): ship camofox v1.17.0 in compose, verified on a real instance
- compose: camofox service on by default (internal only, volume umt-camofox,
  healthcheck, CAMOFOX_REPLICAS=0 to skip); app depends on it with
  service_started/required=false and auto-registers camofox-env from
  BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED
- docker/camofox/Dockerfile: builds upstream tag v1.17.0 (389c996); fixes the
  upstream Dockerfile (missing postinstall.js before npm ci, sh -c not
  forwarding SIGTERM so logins were never persisted on restart)
- adapter fixes from the real container: coordinate click via evaluate +
  selector, focus typing with mode=keyboard, password values redacted from
  ARIA snapshots/extract, downloads field names, 410 after browser restart
  treated as lost tab; navigate reopens a tab in the same session
- verified-capability table keyed by upstream version (1.17.0 all ten);
  other versions stay unverified; probe reports auth failures
- settings: browser providers block (create/edit/delete/probe, capability
  table); API POST/PATCH/DELETE /browser/providers, POST .../probe
- scripts/browser-verify: umtbr stack + mock NexusPHP run of REQ 4.7.4 items
- docs: runbook browser §1/§7 (real-instance record), deploy §2.1

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 00:05:21 +08:00

363 lines
16 KiB
TypeScript

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> = {}): 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<CamofoxConfig> = {},
) {
const mock = await createMockCamofox({
...(opts.accessKey ? { accessKey: opts.accessKey } : {}),
...(opts.omitPaths ? { omitPaths: opts.omitPaths } : {}),
});
mocks.push(mock);
const logs: Parameters<BrowserLogger>[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<ReturnType<typeof setup>>["provider"], url?: string) =>
p.openSession({
providerId: PROVIDER,
siteId: SITE,
accountId: ACCOUNT,
budget: budget(),
...(url ? { initialUrl: url } : {}),
});
async function rejectKind(p: Promise<unknown>): Promise<string> {
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<string, Record<string, unknown>> = {};
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 });
const ok = camofoxCapabilitiesFromOpenApi(doc("1.17.0"));
expect(Object.keys(CAMOFOX_VERIFIED_RELEASES)).toContain("1.17.0");
for (const k of CAMOFOX_VERIFIED_RELEASES["1.17.0"]!.keys)
expect(ok[k]).toMatchObject({ supported: true, verified: true });
expect(camofoxCapabilitiesFromOpenApi(doc("1.17.0"), { takeover: false }).takeover.verified).toBe(false);
const other = camofoxCapabilitiesFromOpenApi(doc("1.18.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: [
'<input name="pw" type="password" value="s3cret-html">',
"https://user:[email protected]/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<unknown>[] = [
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");
});
});