- 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]>
706 lines
26 KiB
TypeScript
706 lines
26 KiB
TypeScript
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { deflateSync } from "node:zlib";
|
|
import type { MockCall, MockFault, MockHttp, MockReply } from "../tmdb/mock-http.ts";
|
|
|
|
/**
|
|
* mock camofox-browser(形状按 jo-inc/camofox-browser v1.17.0,2026-09-28 与真实容器对照过请求/响应字段;取值虚构)。
|
|
* 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、DELETE /tabs/{id}、POST /tabs/{id}/navigate、
|
|
* POST /tabs/{id}/{back,forward,refresh}、GET /tabs/{id}/snapshot、GET /tabs/{id}/screenshot(base64 PNG)、
|
|
* POST /tabs/{id}/{click,type,select,scroll,press,extract,evaluate}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。
|
|
* 不给 site:快照为固定文本(含密码值/Cookie/passkey 供脱敏测试)。
|
|
* 给 site:模拟一个 NexusPHP 风格站点——未登录访问受保护页重定向登录页,登录页可带验证码(需人工),
|
|
* 点击/输入改变页面,点"下载"把 .torrent 放进该标签页的下载列表;登录状态按 userId(profile)保存。
|
|
* 坐标:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H);与真实上游一样
|
|
* click 不接受 {x, y},适配器先 evaluate(elementFromPoint)打 data-umt-pt 标记再按选择器点击。
|
|
* accessKey 设置时模拟 CAMOFOX_ACCESS_KEY:除 /health 外要求 `Authorization: Bearer <key>`,否则 401。
|
|
* 故障注入(401/超时/断连)用 MockHttp.fault。
|
|
*/
|
|
/** 交互式 mock 站点 */
|
|
export interface MockCamofoxSite {
|
|
/** 例 https://audiences.example */
|
|
origin: string;
|
|
username: string;
|
|
password: string;
|
|
loginPath?: string;
|
|
/** 未登录访问会被重定向到登录页的路径 */
|
|
protectedPaths?: RegExp;
|
|
/** 登录页带验证码:只有填对 captchaAnswer 才能登录(模拟需本人操作) */
|
|
captcha?: boolean;
|
|
captchaAnswer?: string;
|
|
releases?: { id: string; title: string }[];
|
|
/** 下载内容;缺省返回固定字节 */
|
|
torrent?: (id: string) => Uint8Array;
|
|
passkey?: string;
|
|
}
|
|
|
|
export const MOCK_VIEWPORT = { width: 1280, height: 800 } as const;
|
|
export const MOCK_ROW_Y0 = 20;
|
|
export const MOCK_ROW_H = 40;
|
|
|
|
export interface MockCamofoxOptions {
|
|
accessKey?: string;
|
|
site?: MockCamofoxSite;
|
|
/** 覆盖快照文本;{url} 替换为当前地址 */
|
|
snapshotText?: string;
|
|
/** 让 openapi.json 缺少某些端点(模拟旧版本/fork) */
|
|
omitPaths?: string[];
|
|
version?: string;
|
|
port?: number;
|
|
}
|
|
|
|
export interface MockCamofoxTab {
|
|
tabId: string;
|
|
userId: string;
|
|
sessionKey: string;
|
|
url: string;
|
|
history: string[];
|
|
cursor: number;
|
|
/** 元素引用 → 已输入的值 */
|
|
fields: Map<string, string>;
|
|
focus: string | null;
|
|
/** evaluate(elementFromPoint)打的 data-umt-pt 标记 → 元素引用 */
|
|
marks: Map<string, string>;
|
|
downloads: MockCamofoxDownload[];
|
|
error: string | null;
|
|
}
|
|
|
|
export interface MockCamofoxDownload {
|
|
id: string;
|
|
filename: string;
|
|
url: string;
|
|
mimeType: string;
|
|
bytes: Uint8Array;
|
|
}
|
|
|
|
/** 交互动作记录(断言凭证确实下发到了浏览器、坐标换算结果等) */
|
|
export interface MockCamofoxAction {
|
|
action: string;
|
|
tabId: string;
|
|
body: Record<string, unknown>;
|
|
}
|
|
|
|
export type MockCamofox = MockHttp & {
|
|
tabs: Map<string, MockCamofoxTab>;
|
|
actions: MockCamofoxAction[];
|
|
/** 已登录的 userId(profile) */
|
|
loggedIn: Set<string>;
|
|
/** 模拟站点登录过期(全部 profile 或指定 profile) */
|
|
expireLogin(userId?: string): void;
|
|
/** 模拟上游回收标签页(空闲超时 / 重启) */
|
|
dropTab(tabId: string): void;
|
|
};
|
|
|
|
/** 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[]> = {
|
|
"/tabs/{tabId}/scroll": ["post"],
|
|
"/tabs/{tabId}/press": ["post"],
|
|
"/tabs/{tabId}/back": ["post"],
|
|
"/tabs/{tabId}/forward": ["post"],
|
|
"/tabs/{tabId}/refresh": ["post"],
|
|
"/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}/evaluate": ["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)),
|
|
};
|
|
}
|
|
|
|
// ---------- 截图:按页面种类填色的 PNG(1280x800) ----------
|
|
|
|
const CRC_TABLE = (() => {
|
|
const t = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
t[n] = c >>> 0;
|
|
}
|
|
return t;
|
|
})();
|
|
function crc32(buf: Uint8Array): number {
|
|
let c = 0xffffffff;
|
|
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
|
|
return (c ^ 0xffffffff) >>> 0;
|
|
}
|
|
function pngChunk(type: string, data: Uint8Array): Buffer {
|
|
const len = Buffer.alloc(4);
|
|
len.writeUInt32BE(data.byteLength);
|
|
const td = Buffer.concat([Buffer.from(type, "ascii"), Buffer.from(data)]);
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(td));
|
|
return Buffer.concat([len, td, crc]);
|
|
}
|
|
const pngCache = new Map<string, string>();
|
|
/** 纯色 PNG(base64),可选若干横条表示元素行 */
|
|
export function solidPngBase64(w: number, h: number, rgb: [number, number, number], rows: number[] = []): string {
|
|
const key = `${w}x${h}:${rgb.join(",")}:${rows.join(",")}`;
|
|
const hit = pngCache.get(key);
|
|
if (hit) return hit;
|
|
const raw = Buffer.alloc((w * 3 + 1) * h);
|
|
for (let y = 0; y < h; y++) {
|
|
const off = y * (w * 3 + 1);
|
|
const inRow = rows.some((r) => y >= MOCK_ROW_Y0 + r * MOCK_ROW_H + 8 && y < MOCK_ROW_Y0 + (r + 1) * MOCK_ROW_H - 8);
|
|
for (let x = 0; x < w; x++) {
|
|
const bar = inRow && x >= 40 && x < 640;
|
|
raw[off + 1 + x * 3] = bar ? 90 : rgb[0];
|
|
raw[off + 2 + x * 3] = bar ? 110 : rgb[1];
|
|
raw[off + 3 + x * 3] = bar ? 140 : rgb[2];
|
|
}
|
|
}
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(w, 0);
|
|
ihdr.writeUInt32BE(h, 4);
|
|
ihdr[8] = 8;
|
|
ihdr[9] = 2;
|
|
const png = Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
pngChunk("IHDR", ihdr),
|
|
pngChunk("IDAT", deflateSync(raw)),
|
|
pngChunk("IEND", new Uint8Array()),
|
|
]).toString("base64");
|
|
pngCache.set(key, png);
|
|
return png;
|
|
}
|
|
|
|
// ---------- 交互式站点 ----------
|
|
|
|
interface Line {
|
|
ref: string;
|
|
role: string;
|
|
text: string;
|
|
/** 点击行为 */
|
|
onClick?: () => void;
|
|
}
|
|
|
|
export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<MockCamofox> {
|
|
const tabs = new Map<string, MockCamofoxTab>();
|
|
const actions: MockCamofoxAction[] = [];
|
|
const loggedIn = new Set<string>();
|
|
const site = opts.site;
|
|
const loginPath = site?.loginPath ?? "/login.php";
|
|
const protectedPaths = site?.protectedPaths ?? /^\/(?:torrents|details|download|index)\.php/;
|
|
let seq = 0;
|
|
let dl = 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;
|
|
};
|
|
|
|
/** 站点路由:未登录访问受保护页 → 登录页(带 returnto) */
|
|
const resolve = (t: MockCamofoxTab, url: string): string => {
|
|
if (!site) return new URL(url).searchParams.get("redirect") ?? url;
|
|
const u = new URL(url);
|
|
if (u.origin !== new URL(site.origin).origin) return url;
|
|
if (!loggedIn.has(t.userId) && protectedPaths.test(u.pathname))
|
|
return `${site.origin}${loginPath}?returnto=${encodeURIComponent(u.pathname + u.search)}`;
|
|
return url;
|
|
};
|
|
const go = (t: MockCamofoxTab, url: string, push = true) => {
|
|
const target = resolve(t, url);
|
|
if (push) {
|
|
t.history = t.history.slice(0, t.cursor + 1);
|
|
t.history.push(target);
|
|
t.cursor = t.history.length - 1;
|
|
} else t.history[t.cursor] = target;
|
|
t.url = target;
|
|
t.fields.clear();
|
|
t.focus = null;
|
|
t.error = null;
|
|
return target;
|
|
};
|
|
|
|
const tryLogin = (t: MockCamofoxTab) => {
|
|
if (!site) return;
|
|
const ok =
|
|
t.fields.get("e2") === site.username &&
|
|
t.fields.get("e3") === site.password &&
|
|
(!site.captcha || t.fields.get("e5") === (site.captchaAnswer ?? "4321"));
|
|
if (!ok) {
|
|
t.error =
|
|
site.captcha && t.fields.get("e5") !== (site.captchaAnswer ?? "4321") ? "验证码错误" : "用户名或密码错误";
|
|
return;
|
|
}
|
|
loggedIn.add(t.userId);
|
|
const back = new URL(t.url).searchParams.get("returnto") ?? "/torrents.php";
|
|
go(t, `${site.origin}${back}`);
|
|
};
|
|
|
|
const pageLines = (t: MockCamofoxTab): Line[] => {
|
|
if (!site) return [];
|
|
const u = new URL(t.url);
|
|
const passkey = site.passkey ?? CAMOFOX_FIXTURE_PASSKEY;
|
|
const releases = site.releases ?? [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }];
|
|
const field = (ref: string) => t.fields.get(ref) ?? "";
|
|
if (u.pathname === loginPath) {
|
|
const lines: Line[] = [
|
|
{ ref: "e1", role: "heading", text: "登录" },
|
|
{ ref: "e2", role: "textbox", text: `用户名: ${field("e2")}` },
|
|
{ ref: "e3", role: "textbox password", text: `密码: ${field("e3")}` },
|
|
];
|
|
if (site.captcha)
|
|
lines.push(
|
|
{ ref: "e4", role: "img", text: "验证码图片" },
|
|
{ ref: "e5", role: "textbox", text: `验证码: ${field("e5")}` },
|
|
);
|
|
lines.push({ ref: "e6", role: "button", text: "登录", onClick: () => tryLogin(t) });
|
|
if (t.error) lines.push({ ref: "e7", role: "alert", text: t.error });
|
|
return lines;
|
|
}
|
|
if (u.pathname === "/torrents.php") {
|
|
const lines: Line[] = [{ ref: "e1", role: "heading", text: "种子列表" }];
|
|
releases.forEach((r, i) => {
|
|
const dlUrl = `${site.origin}/download.php?id=${r.id}&passkey=${passkey}`;
|
|
lines.push(
|
|
{
|
|
ref: `e${10 + i * 2}`,
|
|
role: "link",
|
|
text: r.title,
|
|
onClick: () => void go(t, `${site.origin}/details.php?id=${r.id}`),
|
|
},
|
|
{
|
|
ref: `e${11 + i * 2}`,
|
|
role: "link",
|
|
text: `下载 ${dlUrl}`,
|
|
onClick: () => {
|
|
const bytes = site.torrent?.(r.id) ?? new TextEncoder().encode(`fake-torrent-${r.id}`);
|
|
t.downloads.push({
|
|
id: `dl-${++dl}`,
|
|
filename: `[UHD].${r.title.replace(/\s+/g, ".")}.torrent`,
|
|
url: dlUrl,
|
|
mimeType: "application/x-bittorrent",
|
|
bytes,
|
|
});
|
|
},
|
|
},
|
|
);
|
|
});
|
|
return lines;
|
|
}
|
|
return [
|
|
{ ref: "e1", role: "heading", text: "首页" },
|
|
{
|
|
ref: "e2",
|
|
role: "link",
|
|
text: `种子 ${site.origin}/torrents.php`,
|
|
onClick: () => void go(t, `${site.origin}/torrents.php`),
|
|
},
|
|
];
|
|
};
|
|
const snapshotOf = (t: MockCamofoxTab) =>
|
|
site
|
|
? pageLines(t)
|
|
.map(
|
|
(l) =>
|
|
`[${l.role.split(" ")[0]} ${l.ref}${l.role.includes(" ") ? ` ${l.role.split(" ").slice(1).join(" ")}` : ""}] ${l.text}`,
|
|
)
|
|
.join("\n")
|
|
: (opts.snapshotText ?? CAMOFOX_FIXTURE_SNAPSHOT).replaceAll("{url}", t.url);
|
|
|
|
const findLine = (t: MockCamofoxTab, b: Body): Line | undefined => {
|
|
const lines = pageLines(t);
|
|
if (typeof b.ref === "string") return lines.find((l) => l.ref === b.ref);
|
|
if (typeof b.selector === "string") {
|
|
const mark = /^\[data-umt-pt="([^"]+)"\]$/.exec(b.selector)?.[1];
|
|
if (mark) return lines.find((l) => l.ref === t.marks.get(mark));
|
|
return lines.find((l) => l.role.startsWith("button"));
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const action =
|
|
(name: string, fn: (t: MockCamofoxTab, b: Body) => MockReply | undefined) =>
|
|
(m: RegExpMatchArray, _q: Record<string, string>, _req: IncomingMessage, b: Body): MockReply => {
|
|
const t = tabFor(m[1]!, b.userId as string | undefined);
|
|
if (!t) return err(404, "Tab not found");
|
|
actions.push({ action: name, tabId: t.tabId, body: { ...b } });
|
|
return fn(t, b) ?? { json: { ok: true, tabId: t.tabId, url: t.url } };
|
|
};
|
|
|
|
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-mock" },
|
|
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 t: MockCamofoxTab = {
|
|
tabId,
|
|
userId: b.userId,
|
|
sessionKey: b.sessionKey,
|
|
url: "about:blank",
|
|
history: [],
|
|
cursor: -1,
|
|
fields: new Map(),
|
|
focus: null,
|
|
marks: new Map(),
|
|
downloads: [],
|
|
error: null,
|
|
};
|
|
tabs.set(tabId, t);
|
|
const url = typeof b.url === "string" ? go(t, b.url) : go(t, "about:blank");
|
|
return { json: { tabId, url, httpStatus: b.url ? 200 : null, navigationOk: true } };
|
|
},
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)$/,
|
|
(m, q, req, b) => {
|
|
if (req.method !== "DELETE") return err(405, "method");
|
|
const t = tabFor(m[1]!, (q.userId ?? b.userId) as string | undefined);
|
|
if (!t) return err(404, "Tab not found");
|
|
tabs.delete(t.tabId);
|
|
return { json: { ok: true } };
|
|
},
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)\/navigate$/,
|
|
action("navigate", (t, b) => {
|
|
if (typeof b.url !== "string") return err(400, "url required");
|
|
const target = go(t, b.url);
|
|
const httpStatus = target.includes("/missing") ? 404 : 200;
|
|
return { json: { ok: true, tabId: t.tabId, url: target, httpStatus, navigationOk: httpStatus < 400 } };
|
|
}),
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)\/(back|forward|refresh)$/,
|
|
(m, q, req, b) => {
|
|
const dir = m[2]!;
|
|
return action(dir, (t) => {
|
|
if (dir === "back" && t.cursor > 0) t.cursor--;
|
|
if (dir === "forward" && t.cursor < t.history.length - 1) t.cursor++;
|
|
const target = t.history[t.cursor] ?? t.url;
|
|
go(t, target, false);
|
|
return { json: { ok: true, tabId: t.tabId, url: t.url, httpStatus: 200, navigationOk: true } };
|
|
})(m, q, req, b);
|
|
},
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)\/snapshot$/,
|
|
(m, q) => {
|
|
const t = tabFor(m[1]!, q.userId);
|
|
if (!t) return err(404, "Tab not found");
|
|
const snapshot = snapshotOf(t);
|
|
const offset = Number(q.offset ?? 0);
|
|
return {
|
|
json: {
|
|
url: t.url,
|
|
snapshot: snapshot.slice(offset),
|
|
structure: site
|
|
? null
|
|
: {
|
|
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");
|
|
if (!site) return { json: { screenshot: { data: PNG_1X1_BASE64, mimeType: "image/png" } } };
|
|
const login = new URL(t.url).pathname === loginPath;
|
|
const rows = pageLines(t).map((_l, i) => i);
|
|
const data = solidPngBase64(
|
|
MOCK_VIEWPORT.width,
|
|
MOCK_VIEWPORT.height,
|
|
login ? [236, 232, 224] : [248, 248, 250],
|
|
rows,
|
|
);
|
|
return { json: { screenshot: { data, mimeType: "image/png" } } };
|
|
},
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)\/click$/,
|
|
action("click", (t, b) => {
|
|
// 1.17.0 实测:只认 ref / selector,坐标点击由适配器先 evaluate 打标记
|
|
if (typeof b.ref !== "string" && typeof b.selector !== "string") return err(400, "ref or selector required");
|
|
const line = findLine(t, b);
|
|
if (!line) return typeof b.ref === "string" ? err(422, "Unknown ref") : undefined;
|
|
if (line.role.startsWith("textbox")) t.focus = line.ref;
|
|
line.onClick?.();
|
|
return undefined;
|
|
}),
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)\/type$/,
|
|
action("type", (t, b) => {
|
|
if (typeof b.text !== "string") return err(400, "text required");
|
|
if (typeof b.ref !== "string" && typeof b.selector !== "string" && b.mode !== "keyboard")
|
|
return err(400, "ref or selector required for mode=fill");
|
|
const ref = typeof b.ref === "string" ? b.ref : (findLine(t, b)?.ref ?? t.focus);
|
|
if (!ref) return err(422, "No focused element");
|
|
t.fields.set(ref, b.clear === false ? (t.fields.get(ref) ?? "") + b.text : b.text);
|
|
t.focus = ref;
|
|
if (b.submit === true) tryLogin(t);
|
|
return undefined;
|
|
}),
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)\/evaluate$/,
|
|
action("evaluate", (t, b) => {
|
|
// 只模拟适配器的坐标定位表达式:elementFromPoint(x, y) + data-umt-pt 标记
|
|
const expr = typeof b.expression === "string" ? b.expression : "";
|
|
const pt = /elementFromPoint\((\d+), (\d+)\)/.exec(expr);
|
|
const nonce = /setAttribute\("data-umt-pt", "([^"]+)"\)/.exec(expr)?.[1];
|
|
if (!pt || !nonce) return { json: { ok: true, result: null } };
|
|
const line = pageLines(t)[Math.floor((Number(pt[2]) - MOCK_ROW_Y0) / MOCK_ROW_H)];
|
|
if (!line) return { json: { ok: true, result: false } };
|
|
t.marks.clear();
|
|
t.marks.set(nonce, line.ref);
|
|
return { json: { ok: true, result: true } };
|
|
}),
|
|
],
|
|
[/^\/tabs\/([^/]+)\/select$/, action("select", () => undefined)],
|
|
[/^\/tabs\/([^/]+)\/scroll$/, action("scroll", () => undefined)],
|
|
[
|
|
/^\/tabs\/([^/]+)\/press$/,
|
|
action("press", (t, b) => {
|
|
if (b.key === "Enter" && site && new URL(t.url).pathname === loginPath) tryLogin(t);
|
|
if (b.key === "Backspace" && t.focus) t.fields.set(t.focus, (t.fields.get(t.focus) ?? "").slice(0, -1));
|
|
return undefined;
|
|
}),
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)\/extract$/,
|
|
action("extract", (t, b) => {
|
|
const props = ((b.schema as Body | undefined)?.properties ?? {}) as Record<string, Body>;
|
|
const lines = site ? pageLines(t) : [];
|
|
const text = snapshotOf(t).split("\n");
|
|
const data: Record<string, unknown> = {};
|
|
for (const [k, p] of Object.entries(props)) {
|
|
const ref = p["x-ref"];
|
|
const hit =
|
|
lines.find((l) => l.ref === ref)?.text ??
|
|
text.find((l) => l.includes(` ${ref}]`) || l.includes(` ${ref} `))?.replace(/^\[[^\]]*\]\s*/, "");
|
|
data[k] = hit ?? null;
|
|
}
|
|
return { json: { ok: true, data } };
|
|
}),
|
|
],
|
|
[
|
|
/^\/tabs\/([^/]+)\/downloads$/,
|
|
(m, q) => {
|
|
const t = tabFor(m[1]!, q.userId);
|
|
if (!t) return err(404, "Tab not found");
|
|
const max = q.maxBytes ? Number(q.maxBytes) : Number.POSITIVE_INFINITY;
|
|
// 字段形状按 1.17.0 实测(lib/downloads.js getDownloadsList)
|
|
const list = t.downloads.map((d) => ({
|
|
id: d.id,
|
|
url: d.url,
|
|
suggestedFilename: d.filename,
|
|
mimeType: d.mimeType,
|
|
bytes: d.bytes.byteLength,
|
|
createdAt: Date.now(),
|
|
failure: null,
|
|
...(q.includeData === "true"
|
|
? d.bytes.byteLength <= max
|
|
? { dataBase64: Buffer.from(d.bytes).toString("base64") }
|
|
: { dataSkipped: "max_bytes_exceeded" }
|
|
: {}),
|
|
}));
|
|
if (q.consume === "true") t.downloads = [];
|
|
return { json: { downloads: list } };
|
|
},
|
|
],
|
|
[
|
|
/^\/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,
|
|
actions,
|
|
loggedIn,
|
|
expireLogin(userId?: string) {
|
|
if (userId) loggedIn.delete(userId);
|
|
else loggedIn.clear();
|
|
},
|
|
dropTab(tabId: string) {
|
|
tabs.delete(tabId);
|
|
},
|
|
});
|
|
}
|