Files
UHD_Media_Tool/packages/adapters/src/browser/camofox-real.test.ts
T
rootandClaude Opus 5.5 6b14b0da45 feat(browser): 插件真实坐标点击与页面内容读取(CF-2)
- umt 插件 POST /umt/tabs/{id}/mouse-click:page.mouse.click,跨域 iframe 的 Turnstile 也点得到;click 坐标优先走它,旧镜像退回 elementFromPoint
- 适配器 pageContent(地址 / 标题 / outerHTML / 正文 / navigator.userAgent);setEgress 返回插件出口模式
- mock camofox 增加 Cloudflare 模式(auto / interactive / captcha)与插件鼠标点击

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

198 lines
8.5 KiB
TypeScript

import type { NavigationBudget } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { BrowserError, createCamofoxBrowserProvider, memoryDownloadSink, staticBrowserSecrets } from "./index.ts";
// BR-1:对照 camofox-browser v1.17.0 真实容器(2026-09-28)记下的请求/响应形状,锁住适配器的实测差异修正。
// 响应体取自真机核验输出(docs/runbook/browser.md §7),只把值换成测试常量。
const PROVIDER = "11111111-1111-4111-8111-111111111111";
const SITE = "22222222-2222-4222-8222-222222222222";
const ORIGIN = "http://mocknexus:8080";
const PASSWORD = "mock-password";
const budget: NavigationBudget = { domainAllowlist: ["mocknexus"], timeoutMs: 500, maxRetries: 0, maxBytes: 1_000_000 };
type Call = { method: string; path: string; query: Record<string, string>; body: Record<string, unknown> | null };
type Reply = { status?: number; json?: unknown; bytes?: Uint8Array; type?: string };
function realUpstream(handler: (c: Call) => Reply | undefined) {
const calls: Call[] = [];
const fetchImpl = (async (input: URL | string, init?: RequestInit) => {
const u = new URL(String(input));
const call: Call = {
method: init?.method ?? "GET",
path: u.pathname,
query: Object.fromEntries(u.searchParams),
body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : null,
};
calls.push(call);
const r =
handler(call) ??
// 真实上游(不带 umt 插件)没有 /umt/* 路由
(call.path.startsWith("/umt/") ? { status: 404, json: { error: "Not found" } } : undefined) ??
(call.path === "/tabs" && call.method === "POST"
? { json: { tabId: "t-1", url: `${ORIGIN}/login.php`, httpStatus: 200, navigationOk: true } }
: { json: { ok: true } });
if (r.bytes)
return new Response(r.bytes as Uint8Array<ArrayBuffer>, {
status: r.status ?? 200,
headers: { "content-type": r.type ?? "image/png" },
});
return new Response(JSON.stringify(r.json ?? {}), {
status: r.status ?? 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return { calls, fetchImpl };
}
async function open(handler: (c: Call) => Reply | undefined) {
const up = realUpstream(handler);
const sink = memoryDownloadSink();
const provider = createCamofoxBrowserProvider({
providerId: PROVIDER,
baseUrl: "http://camofox:9377",
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: "k" }),
fetch: up.fetchImpl,
downloadSink: sink,
downloadPollMs: 5,
});
const { session } = await provider.openSession({
providerId: PROVIDER,
siteId: SITE,
accountId: null,
budget,
initialUrl: `${ORIGIN}/login.php`,
});
return { ...up, provider, sink, ref: { sessionId: session.id } };
}
const ARIA_LOGIN = `- paragraph: 你需要启用cookies才能登录\n- textbox [e1]: mockuser\n- textbox [e2]: ${PASSWORD}\n- button "登录" [e3]`;
describe("camofox 1.17.0 实测形状", () => {
it("ARIA 快照里密码框与普通文本框同形:经 evaluate 取密码框当前值后按字面量遮蔽", async () => {
const x = await open((c) => {
if (c.path.endsWith("/snapshot"))
return { json: { url: `${ORIGIN}/login.php`, snapshot: ARIA_LOGIN, refsCount: 3 } };
if (c.path.endsWith("/evaluate")) return { json: { ok: true, result: [PASSWORD] } };
});
const s = await x.provider.snapshot(x.ref);
expect(s.text).not.toContain(PASSWORD);
expect(s.text).toContain("- textbox [e1]: mockuser");
expect(s.redactions).toEqual([{ kind: "secret_value", count: 1 }]);
});
it("evaluate 不可用时遮蔽全部输入框的值(宁可多遮)", async () => {
const x = await open((c) => {
if (c.path.endsWith("/snapshot")) return { json: { url: `${ORIGIN}/login.php`, snapshot: ARIA_LOGIN } };
if (c.path.endsWith("/evaluate")) return { status: 500, json: { error: "x" } };
});
const s = await x.provider.snapshot(x.ref);
expect(s.text).not.toContain(PASSWORD);
expect(s.text).not.toContain("mockuser");
expect(s.text).toContain('- button "登录" [e3]');
});
it("截图直接返回 image/png 二进制", async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
const x = await open((c) => (c.path.endsWith("/screenshot") ? { bytes: png } : undefined));
const shot = await x.provider.screenshot(x.ref);
expect(shot.mimeType).toBe("image/png");
expect([...shot.data]).toEqual([...png]);
});
it("坐标点击:click 不接受 {x, y},先 evaluate 标记该点元素,再按选择器点击", async () => {
const x = await open((c) => (c.path.endsWith("/evaluate") ? { json: { ok: true, result: true } } : undefined));
await x.provider.acquireControl({
sessionId: x.ref.sessionId,
holderKind: "human",
holder: "u",
reason: "t",
ttlMs: 60_000,
});
await x.provider.click({ ...x.ref, as: "human" }, { x: 98.4, y: 71 });
const ev = x.calls.find((c) => c.path.endsWith("/evaluate"))!;
expect(String(ev.body?.expression)).toContain("elementFromPoint(98, 71)");
const click = x.calls.find((c) => c.path.endsWith("/click"))!;
expect(click.body).toMatchObject({
userId: expect.any(String),
selector: expect.stringMatching(/^\[data-umt-pt="/),
});
expect(click.body).not.toHaveProperty("x");
});
it("坐标处没有元素 → bad_request,不发 click", async () => {
const x = await open((c) => (c.path.endsWith("/evaluate") ? { json: { ok: true, result: false } } : undefined));
await x.provider.acquireControl({
sessionId: x.ref.sessionId,
holderKind: "human",
holder: "u",
reason: "t",
ttlMs: 60_000,
});
await expect(x.provider.click({ ...x.ref, as: "human" }, { x: 5000, y: 5000 })).rejects.toMatchObject({
kind: "bad_request",
});
expect(x.calls.some((c) => c.path.endsWith("/click"))).toBe(false);
});
it("无目标输入 = 当前焦点:mode=keyboard(fill 模式必须带 ref/selector)", async () => {
const x = await open(() => undefined);
await x.provider.type(x.ref, null, { kind: "text", text: "abc" });
await x.provider.type(x.ref, { selector: "#u" }, { kind: "text", text: "abc" });
const types = x.calls.filter((c) => c.path.endsWith("/type"));
expect(types[0]!.body).toMatchObject({ mode: "keyboard", text: "abc" });
expect(types[1]!.body).not.toHaveProperty("mode");
});
it("downloads 字段:suggestedFilename / bytes / dataBase64;failure 与 dataSkipped 分别报错", async () => {
const bytes = new TextEncoder().encode("d8:announce0:e");
const b64 = btoa(String.fromCharCode(...bytes));
const list = [
{
id: "d1",
url: `${ORIGIN}/download.php?id=1&passkey=abcdef0123456789abcdef0123456789`,
suggestedFilename: "[Mock].x.torrent",
mimeType: "application/x-bittorrent",
bytes: bytes.byteLength,
createdAt: 1,
failure: null,
dataBase64: b64,
},
];
const x = await open((c) =>
c.path.endsWith("/downloads") ? { json: { tabId: "t-1", downloads: list } } : undefined,
);
const a = await x.provider.waitForDownload(x.ref, { timeoutMs: 200, maxBytes: 1024 });
expect(a).toMatchObject({ filename: "[Mock].x.torrent", sizeBytes: bytes.byteLength });
expect(a.sourceUrl).not.toContain("abcdef0123456789");
const y = await open((c) =>
c.path.endsWith("/downloads")
? { json: { downloads: [{ ...list[0], id: "d2", dataBase64: undefined, dataSkipped: "max_bytes_exceeded" }] } }
: undefined,
);
await expect(y.provider.waitForDownload(y.ref, { timeoutMs: 200, maxBytes: 4 })).rejects.toMatchObject({
kind: "budget_exceeded",
});
const z = await open((c) =>
c.path.endsWith("/downloads")
? { json: { downloads: [{ ...list[0], id: "d3", dataBase64: undefined, failure: "canceled" }] } }
: undefined,
);
await expect(z.provider.waitForDownload(z.ref, { timeoutMs: 200, maxBytes: 1024 })).rejects.toMatchObject({
kind: "upstream",
});
});
it("浏览器重启后旧标签页返回 410 browser_restarted:按 not_found 处理", async () => {
const x = await open((c) =>
c.path.endsWith("/snapshot")
? { status: 410, json: { error: "Tab no longer exists (browser was restarted).", code: "browser_restarted" } }
: undefined,
);
const err = await x.provider.snapshot(x.ref).catch((e) => e);
expect(err).toBeInstanceOf(BrowserError);
expect(err).toMatchObject({ kind: "not_found", httpStatus: 410 });
});
});