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; body: Record | 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) : 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, { 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 }); }); });