- 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]>
198 lines
8.5 KiB
TypeScript
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 });
|
|
});
|
|
});
|