feat(browser): W4-C BrowserProvider contract and camofox-browser adapter skeleton
- contracts/browser.ts: provider kind, per-capability {supported, verified} flags,
session/tab/control-lease rows, NavigationBudget, redacted BrowserSnapshot,
DownloadArtifact with blob/secret refs, BrowserErrorKind
- adapters/browser: BrowserProvider interface, camofox client (health, capabilities
static + /openapi.json probe, openSession/navigate/snapshot/screenshot; rest
not_implemented for M2), none provider, redactSnapshot, profile serial lock,
domain allowlist budget; Bearer access key via BrowserSecretResolver
- testkit/browser: mock camofox server (auth, fixtures with secrets, faults)
- db 0023: browser_provider/session/tab/control_lease with one-active-lease and
one-live-session-per-profile partial unique indexes; types regenerated
- docs/design/browser-provider.md: contract, camofox OpenAPI 1.17.0 mapping,
unverified items and M2 prototype checklist; Playwright is not a product browser
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
269a2269a1
commit
effaa32b70
23 files changed
+2290
No files matched your search
@@ -0,0 +1,348 @@
|
||||
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,
|
||||
type CamofoxConfig,
|
||||
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、M2 能力 false 并注明,默认全部未验证", () => {
|
||||
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 });
|
||||
// 未实现的能力即使配置了 verified 也不会被标为已验证
|
||||
expect(c.interact).toMatchObject({ supported: false, verified: false });
|
||||
expect(c.interact.note).toContain("M2");
|
||||
expect(c.takeover.supported).toBe(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");
|
||||
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("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("M2 未实现分支", () => {
|
||||
it("click/type/extract/waitForDownload/接管/关闭会话均抛 not_implemented", 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.acquireControl({
|
||||
sessionId: "x",
|
||||
holderKind: "human",
|
||||
holder: "admin",
|
||||
reason: "登录验证",
|
||||
ttlMs: 1000,
|
||||
}),
|
||||
provider.releaseControl("lease"),
|
||||
provider.closeSession("x"),
|
||||
provider.listSessions(),
|
||||
];
|
||||
for (const c of calls) expect(await rejectKind(c)).toBe("not_implemented");
|
||||
});
|
||||
});
|
||||
|
||||
describe("错误分类", () => {
|
||||
it("HTTP 状态映射", () => {
|
||||
expect(classifyHttpStatus(401)).toBe("auth");
|
||||
expect(classifyHttpStatus(403)).toBe("auth");
|
||||
expect(classifyHttpStatus(404)).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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user