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]>
This commit is contained in:
1 parent
b4c9b1ead2
commit
40dd0e69e4
29 files changed
+1661
-73
No files matched your search
@@ -0,0 +1,195 @@
|
||||
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) ??
|
||||
(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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user