生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。
- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
(metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)
同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距
依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
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,
|
|
CAMOFOX_ENDPOINTS,
|
|
CAMOFOX_VERIFIED_RELEASES,
|
|
type CamofoxConfig,
|
|
camofoxCapabilitiesFromOpenApi,
|
|
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,verified 只来自配置;待核验项有注明", () => {
|
|
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 });
|
|
expect(c.interact).toMatchObject({ supported: true, verified: true });
|
|
expect(c.interact.note).toContain("evaluate");
|
|
expect(c.takeover).toMatchObject({ supported: true, verified: 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-mock");
|
|
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("真机核验表:探测到已核验的上游版本才置 verified;配置可逐项撤销;其它版本不继承", () => {
|
|
const paths: Record<string, Record<string, unknown>> = {};
|
|
for (const e of Object.values(CAMOFOX_ENDPOINTS).flat()) {
|
|
const [m, p] = e.split(" ");
|
|
paths[p!] = { ...paths[p!], [m!]: {} };
|
|
}
|
|
const doc = (version: string) => ({ info: { version }, paths });
|
|
for (const version of ["1.17.0", "1.18.0"]) {
|
|
const ok = camofoxCapabilitiesFromOpenApi(doc(version));
|
|
expect(Object.keys(CAMOFOX_VERIFIED_RELEASES)).toContain(version);
|
|
expect(CAMOFOX_VERIFIED_RELEASES[version]!.keys).toHaveLength(10);
|
|
for (const k of CAMOFOX_VERIFIED_RELEASES[version]!.keys)
|
|
expect(ok[k]).toMatchObject({ supported: true, verified: true });
|
|
expect(camofoxCapabilitiesFromOpenApi(doc(version), { takeover: false }).takeover.verified).toBe(false);
|
|
}
|
|
const other = camofoxCapabilitiesFromOpenApi(doc("1.19.0"));
|
|
expect(other.snapshot.verified).toBe(false);
|
|
// 静态声明不知道上游版本:一律未核验
|
|
expect(camofoxStaticCapabilities().snapshot.verified).toBe(false);
|
|
});
|
|
|
|
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("未知会话", () => {
|
|
it("交互/提取/下载/关闭对不存在的会话报 not_found;listSessions 只列内存镜像", 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.closeSession("x"),
|
|
];
|
|
for (const c of calls) expect(await rejectKind(c)).toBe("not_found");
|
|
expect(await rejectKind(provider.releaseControl("lease"))).toBe("not_found");
|
|
expect(await provider.listSessions()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("错误分类", () => {
|
|
it("HTTP 状态映射", () => {
|
|
expect(classifyHttpStatus(401)).toBe("auth");
|
|
expect(classifyHttpStatus(403)).toBe("auth");
|
|
expect(classifyHttpStatus(404)).toBe("not_found");
|
|
expect(classifyHttpStatus(410)).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");
|
|
});
|
|
});
|