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 @@
|
||||
export * from "./mock-camofox.ts";
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { MockCall, MockFault, MockHttp, MockReply } from "../tmdb/mock-http.ts";
|
||||
|
||||
/**
|
||||
* mock camofox-browser(形状按 jo-inc/camofox-browser openapi.json 1.17.0,取值虚构,未对真实实例核验)。
|
||||
* 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、POST /tabs/{id}/navigate、
|
||||
* GET /tabs/{id}/snapshot(固定可访问性快照 + 表单 structure,含密码值/Cookie/passkey 供脱敏测试)、
|
||||
* GET /tabs/{id}/screenshot(1x1 PNG,base64)、DELETE /sessions/{userId}。
|
||||
* accessKey 设置时模拟 CAMOFOX_ACCESS_KEY:除 /health 外要求 `Authorization: Bearer <key>`,否则 401。
|
||||
* 故障注入(401/超时/断连)用 MockHttp.fault。
|
||||
*/
|
||||
export interface MockCamofoxOptions {
|
||||
accessKey?: string;
|
||||
/** 覆盖快照文本;{url} 替换为当前地址 */
|
||||
snapshotText?: string;
|
||||
/** 让 openapi.json 缺少某些端点(模拟旧版本/fork) */
|
||||
omitPaths?: string[];
|
||||
version?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface MockCamofoxTab {
|
||||
tabId: string;
|
||||
userId: string;
|
||||
sessionKey: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type MockCamofox = MockHttp & { tabs: Map<string, MockCamofoxTab> };
|
||||
|
||||
/** 1x1 透明 PNG */
|
||||
export const PNG_1X1_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
||||
|
||||
export const CAMOFOX_FIXTURE_PASSWORD = "hunter2-fixture-password";
|
||||
export const CAMOFOX_FIXTURE_COOKIE = "c0ffee-fixture-cookie";
|
||||
export const CAMOFOX_FIXTURE_PASSKEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
export const CAMOFOX_FIXTURE_SNAPSHOT = [
|
||||
"[heading e1] 观众 · 种子列表",
|
||||
`[link e2] 下载 https://audiences.example/download.php?id=42&passkey=${CAMOFOX_FIXTURE_PASSKEY}`,
|
||||
"[textbox e3] 用户名: fixture-user",
|
||||
`[textbox e4 password] 密码: ${CAMOFOX_FIXTURE_PASSWORD}`,
|
||||
`[text e5] Cookie: c_secure_pass=${CAMOFOX_FIXTURE_COOKIE}; c_secure_uid=1001`,
|
||||
"[button e6] 登录",
|
||||
"[row e7] 奥本海默 Oppenheimer 2023 2160p UHD BluRay 做种 128",
|
||||
].join("\n");
|
||||
|
||||
const OPENAPI_PATHS: Record<string, string[]> = {
|
||||
"/health": ["get"],
|
||||
"/tabs": ["post", "get"],
|
||||
"/tabs/{tabId}": ["delete"],
|
||||
"/tabs/{tabId}/navigate": ["post"],
|
||||
"/tabs/{tabId}/snapshot": ["get"],
|
||||
"/tabs/{tabId}/screenshot": ["get"],
|
||||
"/tabs/{tabId}/click": ["post"],
|
||||
"/tabs/{tabId}/type": ["post"],
|
||||
"/tabs/{tabId}/select": ["post"],
|
||||
"/tabs/{tabId}/extract": ["post"],
|
||||
"/tabs/{tabId}/downloads": ["get"],
|
||||
"/sessions/{userId}": ["delete"],
|
||||
"/sessions/{userId}/cookies": ["post"],
|
||||
};
|
||||
|
||||
type Body = Record<string, unknown>;
|
||||
type Route = (m: RegExpMatchArray, q: Record<string, string>, req: IncomingMessage, body: Body) => MockReply;
|
||||
|
||||
async function readJson(req: IncomingMessage): Promise<Body> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
if (chunks.length === 0) return {};
|
||||
try {
|
||||
const v = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
|
||||
return v && typeof v === "object" ? (v as Body) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? path.startsWith(p) : p.test(path));
|
||||
|
||||
/** 与 tmdb/mock-http 同形的 MockHttp,但路由能拿到 JSON 请求体(camofox 动作都是 POST JSON) */
|
||||
async function startJsonMock(
|
||||
routes: [RegExp, Route][],
|
||||
opts: { port?: number | undefined; guard?: (req: IncomingMessage) => MockReply | null },
|
||||
): Promise<MockHttp> {
|
||||
const calls: MockCall[] = [];
|
||||
const faults: { pattern: string | RegExp; f: MockFault }[] = [];
|
||||
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const u = new URL(req.url ?? "/", "http://mock");
|
||||
const q = Object.fromEntries(u.searchParams);
|
||||
const call: MockCall = { method: req.method ?? "GET", path: u.pathname, query: q, headers: req.headers, status: 0 };
|
||||
calls.push(call);
|
||||
const body = await readJson(req);
|
||||
const reply = (r: MockReply) => {
|
||||
call.status = r.status ?? 200;
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(call.status, { "content-type": "application/json", ...r.headers });
|
||||
res.end(r.json === undefined ? "" : JSON.stringify(r.json));
|
||||
};
|
||||
const fi = faults.findIndex((x) => matches(x.pattern, u.pathname));
|
||||
const fault = fi >= 0 ? faults[fi]!.f : undefined;
|
||||
if (fault) {
|
||||
fault.times = (fault.times ?? 1) - 1;
|
||||
if (fault.times <= 0) faults.splice(fi, 1);
|
||||
if (fault.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs));
|
||||
if (fault.drop) {
|
||||
call.status = -1;
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (fault.status)
|
||||
return reply({ status: fault.status, json: fault.body, ...(fault.headers ? { headers: fault.headers } : {}) });
|
||||
}
|
||||
const denied = opts.guard?.(req);
|
||||
if (denied) return reply(denied);
|
||||
for (const [re, h] of routes) {
|
||||
const m = re.exec(u.pathname);
|
||||
if (m) {
|
||||
try {
|
||||
return reply(h(m, q, req, body));
|
||||
} catch (e) {
|
||||
return reply({ status: 500, json: { error: String(e) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
reply({ status: 404, json: { error: "no route" } });
|
||||
});
|
||||
await new Promise<void>((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
calls,
|
||||
close: () =>
|
||||
new Promise<void>((ok) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => ok());
|
||||
}),
|
||||
fault: (pattern, f) => faults.push({ pattern, f: { ...f } }),
|
||||
clearFaults: () => {
|
||||
faults.length = 0;
|
||||
},
|
||||
callsTo: (p) => calls.filter((c) => matches(p, c.path)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<MockCamofox> {
|
||||
const tabs = new Map<string, MockCamofoxTab>();
|
||||
let seq = 0;
|
||||
const err = (status: number, error: string): MockReply => ({ status, json: { error } });
|
||||
const tabFor = (id: string, userId: string | undefined) => {
|
||||
const t = tabs.get(decodeURIComponent(id));
|
||||
return t && t.userId === userId ? t : undefined;
|
||||
};
|
||||
|
||||
const mock = await startJsonMock(
|
||||
[
|
||||
[
|
||||
/^\/health$/,
|
||||
() => ({
|
||||
json: {
|
||||
ok: true,
|
||||
engine: "camoufox",
|
||||
browserConnected: true,
|
||||
browserRunning: true,
|
||||
activeTabs: tabs.size,
|
||||
activeSessions: new Set([...tabs.values()].map((t) => t.userId)).size,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^\/openapi\.json$/,
|
||||
() => {
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
for (const [p, ms] of Object.entries(OPENAPI_PATHS))
|
||||
for (const m of ms)
|
||||
if (!opts.omitPaths?.includes(`${m} ${p}`)) {
|
||||
const ops = paths[p] ?? {};
|
||||
ops[m] = { summary: `${m} ${p}` };
|
||||
paths[p] = ops;
|
||||
}
|
||||
return {
|
||||
json: { openapi: "3.0.3", info: { title: "camofox-browser", version: opts.version ?? "1.17.0" }, paths },
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/tabs$/,
|
||||
(_m, q, req, b) => {
|
||||
if (req.method === "GET")
|
||||
return {
|
||||
json: {
|
||||
running: true,
|
||||
tabs: [...tabs.values()]
|
||||
.filter((t) => !q.userId || t.userId === q.userId)
|
||||
.map((t) => ({ tabId: t.tabId, targetId: t.tabId, url: t.url, title: "", listItemId: t.sessionKey })),
|
||||
},
|
||||
};
|
||||
if (typeof b.userId !== "string" || typeof b.sessionKey !== "string")
|
||||
return err(400, "userId and sessionKey required");
|
||||
const tabId = `tab-${++seq}`;
|
||||
const url = typeof b.url === "string" ? b.url : "about:blank";
|
||||
tabs.set(tabId, { tabId, userId: b.userId, sessionKey: b.sessionKey, url });
|
||||
return { json: { tabId, url, httpStatus: b.url ? 200 : null, navigationOk: true } };
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/tabs\/([^/]+)\/navigate$/,
|
||||
(m, _q, _req, b) => {
|
||||
const t = tabFor(m[1]!, b.userId as string | undefined);
|
||||
if (!t) return err(404, "Tab not found");
|
||||
if (typeof b.url !== "string") return err(400, "url required");
|
||||
// 模拟站点重定向:?redirect=<url> 落到另一地址
|
||||
const target = new URL(b.url).searchParams.get("redirect") ?? b.url;
|
||||
t.url = target;
|
||||
const httpStatus = target.includes("/missing") ? 404 : 200;
|
||||
return { json: { ok: true, tabId: t.tabId, url: target, httpStatus, navigationOk: httpStatus < 400 } };
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/tabs\/([^/]+)\/snapshot$/,
|
||||
(m, q) => {
|
||||
const t = tabFor(m[1]!, q.userId);
|
||||
if (!t) return err(404, "Tab not found");
|
||||
const snapshot = (opts.snapshotText ?? CAMOFOX_FIXTURE_SNAPSHOT).replaceAll("{url}", t.url);
|
||||
const offset = Number(q.offset ?? 0);
|
||||
return {
|
||||
json: {
|
||||
url: t.url,
|
||||
snapshot: snapshot.slice(offset),
|
||||
structure: {
|
||||
forms: [
|
||||
{
|
||||
action: "takelogin.php",
|
||||
fields: [
|
||||
{ name: "username", type: "text", value: "fixture-user" },
|
||||
{ name: "password", type: "password", value: CAMOFOX_FIXTURE_PASSWORD },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
refsCount: new Set(snapshot.match(/\[[a-z]+ (e\d+)/g) ?? []).size,
|
||||
truncated: false,
|
||||
totalChars: snapshot.length,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/tabs\/([^/]+)\/screenshot$/,
|
||||
(m, q) => {
|
||||
const t = tabFor(m[1]!, q.userId);
|
||||
if (!t) return err(404, "Tab not found");
|
||||
return { json: { screenshot: { data: PNG_1X1_BASE64, mimeType: "image/png" } } };
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/sessions\/([^/]+)$/,
|
||||
(m) => {
|
||||
const userId = decodeURIComponent(m[1]!);
|
||||
let closed = 0;
|
||||
for (const [id, t] of tabs)
|
||||
if (t.userId === userId) {
|
||||
tabs.delete(id);
|
||||
closed++;
|
||||
}
|
||||
return { json: { ok: true, closed } };
|
||||
},
|
||||
],
|
||||
],
|
||||
{
|
||||
port: opts.port,
|
||||
guard: (req) => {
|
||||
const path = new URL(req.url ?? "/", "http://mock").pathname;
|
||||
if (!opts.accessKey || path === "/health") return null;
|
||||
return req.headers.authorization === `Bearer ${opts.accessKey}` ? null : err(401, "Unauthorized");
|
||||
},
|
||||
},
|
||||
);
|
||||
return Object.assign(mock, { tabs });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// @umt/testkit 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/testkit";
|
||||
export * from "./browser/index.ts";
|
||||
export * from "./emby/index.ts";
|
||||
export * from "./fs/index.ts";
|
||||
export * from "./qbittorrent/index.ts";
|
||||
|
||||
Reference in new issue
Block a user