test: 测试按行为命名、server 测试统一到 test/、PG 测试分组并行

- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀,
  w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名
- vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行);
  pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s
- CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争)
- ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 16:54:04 +08:00
1 parent b343dddbf6
commit b61335eaf6
75 files changed
+159 -115

No files matched your search

@@ -0,0 +1,276 @@
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { MOCK_ROW_H, MOCK_ROW_Y0, MOCK_VIEWPORT } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { registerBrowserCapabilities } from "../../src/modules/browser/capabilities.ts";
import { createBrowserModule } from "../../src/modules/browser/index.ts";
import {
ACCESS_KEY,
type BrowserHarness,
createBrowserHarness,
type Loose,
ORIGIN,
OTHER_KEY,
PASSKEY,
PASSWORD,
pgAvailable,
READ_KEY,
} from "./harness.ts";
// C1-E 浏览器 HTTP 端点(对 testkit 交互式 mock camofox 验证,未连真实实例):
// 浏览器服务列表与探测、会话开关(数据库为准)、接管租约、坐标点击与凭证注入、截图、下载暂存核验与提交、异常路径。
const up = await pgAvailable();
const row = (i: number) => ({ x: 200, y: MOCK_ROW_Y0 + MOCK_ROW_H * i + MOCK_ROW_H / 2 });
describe.skipIf(!up)("浏览器 HTTP 端点", () => {
let h: BrowserHarness;
let sid: string;
beforeAll(async () => {
h = await createBrowserHarness("api");
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("GET /browser/providers:凭证只给引用;probe 写回能力与健康,verified 只来自人工核验配置", async () => {
const r = await h.req("/browser/providers");
expect(r.status).toBe(200);
expect(r.body.data.items).toHaveLength(1);
expect(r.body.data.items[0]).toMatchObject({ kind: "camofox", enabled: true, capabilities: null, health: null });
expect(JSON.stringify(r.body)).not.toContain(ACCESS_KEY);
const p = await h.req("/browser/providers?probe=true");
const item = p.body.data.items[0];
expect(item.health.status).toBe("ok");
expect(item.healthStatus).toBe("healthy");
// mock 的版本号不在真机核验表里:verified 只来自配置
expect(item.capabilities.upstreamVersion).toBe("1.17.0-mock");
expect(item.capabilities.snapshot).toMatchObject({ supported: true, verified: true, source: "probed" });
expect(item.capabilities.interact).toMatchObject({ supported: true, verified: false });
expect(item.capabilities.takeover).toMatchObject({ supported: true, verified: false });
// 探测带 Bearer 访问凭据
expect(h.mock.callsTo("/openapi.json")[0]?.headers.authorization).toBe(`Bearer ${ACCESS_KEY}`);
expect((await h.req("/browser/providers", { key: READ_KEY })).status).toBe(403);
});
it("打开会话:profile 作 camofox userId,会话/标签页落库;同站点账号再开返回同一会话", async () => {
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/torrents.php` } });
expect(r.status).toBe(200);
const s = r.body.data;
sid = s.id;
expect(s).toMatchObject({ siteId: h.siteId, accountId: h.accountId, controller: "automation", lease: null });
expect(s.profileKey).toBe(`umt-site-${h.siteId}-acct-${h.accountId}`);
// 登录失效:落到登录页
expect(s.currentUrl).toContain("/login.php");
expect(s.tabs).toHaveLength(1);
expect([...h.mock.tabs.values()][0]?.userId).toBe(s.profileKey);
const dbRow = await h.db.selectFrom("browser_session").selectAll().where("id", "=", sid).executeTakeFirstOrThrow();
expect(dbRow.controller).toBe("automation");
const again = await h.req("/browser/sessions", { body: { siteId: h.siteId } });
expect(again.body.data.id).toBe(sid);
const list = await h.req(`/browser/sessions?siteId=${h.siteId}`);
expect(list.body.data.items.map((x: Loose) => x.id)).toEqual([sid]);
});
it("输入类动作要先接管;他人接管中 409;读(快照/截图)随时可用", async () => {
const nav = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: `${ORIGIN}/` } });
expect(nav.status).toBe(409);
expect(nav.body.error.message).toContain("先接管");
const shot = await h.req(`/browser/sessions/${sid}/screenshot`);
expect(shot.status).toBe(200);
expect(shot.body.data).toMatchObject({
mimeType: "image/png",
width: MOCK_VIEWPORT.width,
height: MOCK_VIEWPORT.height,
modelSafe: false,
controller: "automation",
});
const png = Buffer.from(shot.body.data.dataBase64, "base64");
expect(png.subarray(1, 4).toString()).toBe("PNG");
const a = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: { reason: "登录" } });
expect(a.status).toBe(200);
expect(a.body.data.lease).toMatchObject({ holderKind: "human", status: "active" });
expect(a.body.data.session.controller).toBe("human");
const other = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {}, key: OTHER_KEY });
expect(other.status).toBe(409);
const otherClick = await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(1) }, key: OTHER_KEY });
expect(otherClick.status).toBe(409);
});
it("人工:导航限站点域名;坐标点击 + 凭证注入登录;快照遮蔽密码与 passkey", async () => {
const evil = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: "https://evil.example/" } });
expect(evil.status).toBe(400);
expect(evil.body.error.message).toContain("白名单");
expect((await h.req(`/browser/sessions/${sid}/navigate`, { body: {} })).status).toBe(400);
const nav = await h.req(`/browser/sessions/${sid}/navigate`, { body: { url: `${ORIGIN}/torrents.php` } });
expect(nav.body.data).toMatchObject({ loginRequired: true, ok: true });
expect((await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(1) } })).status).toBe(200);
await h.req(`/browser/sessions/${sid}/type`, { body: { credential: "username" } });
await h.req(`/browser/sessions/${sid}/click`, { body: { target: row(2) } });
const typed = await h.req(`/browser/sessions/${sid}/type`, { body: { credential: "password" } });
expect(typed.status).toBe(200);
expect(JSON.stringify(typed.body)).not.toContain(PASSWORD);
// 登录前快照:密码框有值,返回前已遮蔽
const snap = await h.req(`/browser/sessions/${sid}/snapshot`);
expect(snap.body.data.text).toContain("登录");
expect(snap.body.data.text).not.toContain(PASSWORD);
// 凭证确实下发到了浏览器(mock 记录),点击按钮完成登录
expect(h.mock.actions.some((x) => x.action === "type" && x.body.text === PASSWORD)).toBe(true);
await h.req(`/browser/sessions/${sid}/type`, { body: { key: "Enter" } });
const after = await h.req(`/browser/sessions/${sid}/snapshot`);
expect(after.body.data.text).toContain("种子列表");
expect(after.body.data.text).not.toContain(PASSKEY);
expect(after.body.data.url).toContain("/torrents.php");
// 后退 / 滚动
expect((await h.req(`/browser/sessions/${sid}/scroll`, { body: { direction: "down" } })).status).toBe(200);
const back = await h.req(`/browser/sessions/${sid}/navigate`, { body: { action: "reload" } });
expect(back.status).toBe(200);
});
it("下载:点击捕获 → 暂存 0600 文件、sha256/infohash 核验、按 infohash 匹配资源,经 /acquisition-plans 提交;同一文件不重复提交", async () => {
await h.req(`/browser/sessions/${sid}/snapshot`);
const r = await h.req(`/browser/sessions/${sid}/download`, {
body: { target: { ref: "e11" }, submit: { note: "browser" } },
});
expect(r.status, JSON.stringify(r.body)).toBe(200);
const d = r.body.data;
expect(d.artifact.sha256).toBe(createHash("sha256").update(h.torrent.bytes).digest("hex"));
expect(d.artifact.sourceUrl).not.toContain(PASSKEY);
expect(d.verification).toMatchObject({ valid: true, infohashV1: h.torrent.infoHash, private: true });
expect(d.sourceReleaseId).toBe(h.releaseId);
// 资源未识别:与 POST /acquisition-plans 相同的确认结果,产物照常返回
expect(d.submission).toMatchObject({ submitted: false, requiresConfirmation: true });
const files = await readdir(h.stagingDir);
expect(files).toHaveLength(1);
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
// 识别到作品后再下载:同一文件第二次捕获标 duplicate、不提交
const entity = await h.db
.insertInto("media_entity")
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("source_release")
.set({ media_entity_id: entity.id })
.where("id", "=", h.releaseId)
.execute();
await h.req(`/browser/sessions/${sid}/snapshot`);
const dup = await h.req(`/browser/sessions/${sid}/download`, { body: { target: { ref: "e11" }, submit: {} } });
expect(dup.body.data).toMatchObject({ duplicate: true, submission: null });
expect(dup.body.data.note).toContain("未重复提交");
// 未接管时不能点击触发下载
await h.req(`/browser/sessions/${sid}/control/release`, { body: {} });
const denied = await h.req(`/browser/sessions/${sid}/download`, { body: { target: { ref: "e11" } } });
expect(denied.status).toBe(409);
});
it("交还后需重新读取页面;租约到期 → 等待处理 + 待处理事项,自动化被拒", async () => {
const v = await h.req(`/browser/sessions/${sid}`);
expect(v.body.data).toMatchObject({ controller: "automation", needsSnapshot: true, lease: null });
await h.grant("browser.interact");
await expect(
h.registry.invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e11" } }, h.agentCtx()),
).rejects.toMatchObject({ code: "conflict" });
await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {} });
await h.db
.updateTable("browser_control_lease")
.set({ acquired_at: new Date(Date.now() - 120_000), expires_at: new Date(Date.now() - 1_000) })
.where("session_id", "=", sid)
.where("status", "=", "active")
.execute();
const w = await h.req(`/browser/sessions/${sid}`);
expect(w.body.data).toMatchObject({ controller: "waiting", waitingReason: "lease_expired", lease: null });
const dec = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", sid).execute();
expect(dec).toHaveLength(1);
expect(dec[0]).toMatchObject({ status: "awaiting_user", subject_type: "browser_session" });
await expect(
h.registry.invoke("browser.interact", { sessionId: sid, action: "scroll" }, h.agentCtx()),
).rejects.toMatchObject({ code: "conflict" });
// 人工重新接管并交还 → 自动化恢复,待处理事项关闭
await h.req(`/browser/sessions/${sid}/control/acquire`, { body: {} });
await h.req(`/browser/sessions/${sid}/control/release`, { body: { reason: "处理完" } });
const d2 = await h.db
.selectFrom("decision")
.select("status")
.where("subject_id", "=", sid)
.executeTakeFirstOrThrow();
expect(d2.status).toBe("resolved");
});
it("服务重启:按数据库恢复会话,旧引用失效;上游回收的标签页标 lost 并自动重开", async () => {
// 新模块实例 = 进程重启(适配器内存为空)
const fresh = createBrowserModule({ stagingDir: h.stagingDir });
const reg = (await import("../../src/modules/agent/capability-registry.ts")).createCapabilityRegistry({
logger: h.ctx.logger,
db: h.db,
settings: h.ctx.settings,
});
registerBrowserCapabilities(reg, () => fresh.service(h.ctx));
await expect(
reg.invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e11" } }, h.agentCtx()),
).rejects.toMatchObject({ code: "conflict" });
const snap = (await reg.invoke("browser.snapshot", { siteId: h.siteId }, h.agentCtx())) as Loose;
expect(snap.snapshot.text).toContain("种子列表");
expect(snap).toMatchObject({ controller: "automation", loginRequired: false });
expect((await fresh.service(h.ctx).view(sid)).needsSnapshot).toBe(false);
const tab = (
await h.db.selectFrom("browser_tab").selectAll().where("session_id", "=", sid).executeTakeFirstOrThrow()
).upstream_tab_id;
h.mock.dropTab(tab);
// BR-3:上游回收后自愈 —— 同一会话新开标签页回到原地址,重试一次
const healed = (await reg.invoke("browser.snapshot", { sessionId: sid }, h.agentCtx())) as Loose;
expect(healed.snapshot.text).toContain("种子列表");
expect(healed.snapshot.tabId).not.toBe(tab);
const t = await h.db
.selectFrom("browser_tab")
.select("status")
.where("upstream_tab_id", "=", tab)
.executeTakeFirstOrThrow();
expect(t.status).toBe("lost");
});
it("站点停用 → 会话关闭、自动访问停止;关闭会话撤销租约并清上游标签页", async () => {
const s2 = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null } });
expect(s2.status).toBe(200);
const id2 = s2.body.data.id;
expect(id2).not.toBe(sid);
await h.req(`/browser/sessions/${id2}/control/acquire`, { body: {} });
const closed = await h.req(`/browser/sessions/${id2}`, { method: "DELETE" });
expect(closed.body.data).toMatchObject({ controller: "closed", closeReason: "closed_by_user", lease: null });
const lease = await h.db.selectFrom("browser_control_lease").selectAll().where("session_id", "=", id2).execute();
expect(lease.every((l) => l.status === "revoked")).toBe(true);
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", h.siteId).execute();
const r = await h.req(`/browser/sessions/${sid}/snapshot`);
expect(r.status).toBe(412);
const s = await h.db.selectFrom("browser_session").selectAll().where("id", "=", sid).executeTakeFirstOrThrow();
expect(s).toMatchObject({ controller: "closed", close_reason: "site_disabled" });
expect((await h.req("/browser/sessions", { body: { siteId: h.siteId } })).status).toBe(412);
await h.db.updateTable("site").set({ enabled: true }).where("id", "=", h.siteId).execute();
});
it("浏览器服务认证失败(401)与超时:报上游不可用,不无限重试", async () => {
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId } });
const id = r.body.data.id;
await h.req(`/browser/sessions/${id}/control/acquire`, { body: {} });
h.mock.fault(/\/navigate$/, { status: 401, times: 5 });
const a = await h.req(`/browser/sessions/${id}/navigate`, { body: { url: `${ORIGIN}/` } });
expect(a.status).toBe(412);
expect(JSON.stringify(a.body)).not.toContain(ACCESS_KEY);
h.mock.clearFaults();
h.mock.fault(/\/navigate$/, { delayMs: 2_500, times: 5 });
const before = h.mock.callsTo(/\/navigate$/).length;
const t = await h.req(`/browser/sessions/${id}/navigate`, { body: { url: `${ORIGIN}/` } });
expect(t.status).toBe(502);
// maxRetries = 1:共 2 次
expect(h.mock.callsTo(/\/navigate$/).length - before).toBe(2);
h.mock.clearFaults();
}, 30_000);
});
@@ -0,0 +1,228 @@
import { siteCloudflareState } from "@umt/contracts";
import { MOCK_CAMOFOX_IDENTITY, type MockCamofoxSite } from "@umt/testkit";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { browserServiceFor } from "../../src/modules/browser/index.ts";
import { type BrowserService, registerChallengeVision } from "../../src/modules/browser/service.ts";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// CF-2 浏览器一侧(对 testkit mock camofox 的 Cloudflare 模式):
// ① 自动放行(轮询页面)② 复选框 / Turnstile 派视觉子流程,用插件真实鼠标点击 ③ 选图验证码 / 点不过 → 会话转等待 + 待处理事项;
// 人在原站视图里过完交还 → 读回 cf_clearance / UA 通知站点请求继续;浏览器代抓页面受 profile 锁与站点限流约束。
const up = await pgAvailable();
const GOOD = "cf2-session-value";
const CLEARANCE = "clr-from-browser";
const LIST_HTML =
'<html><head><title>种子</title></head><body><table class="torrents"><tr><td>Oppenheimer</td></tr></table></body></html>';
describe.skipIf(!up)("CF-2 浏览器过挑战 / 代抓", () => {
let h: BrowserHarness;
let svc: BrowserService;
const cfCfg: NonNullable<MockCamofoxSite["cloudflare"]> = { mode: "auto", autoPolls: 2, clearance: CLEARANCE };
const visionCalls: string[] = [];
let visionClick = true;
const url = `${ORIGIN}/torrents.php`;
const sessions = async () => (await h.req("/browser/sessions")).body.data.items as Loose[];
const closeAll = async () => {
for (const s of await sessions()) await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
};
const profileKey = () => `umt-site-${h.siteId}-acct-${h.accountId}`;
beforeAll(async () => {
h = await createBrowserHarness("cf2", {
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: GOOD },
cloudflare: cfCfg,
html: (u) =>
u.includes("torrents.php") ? LIST_HTML : "<html><head><title>首页</title></head><body></body></html>",
browserOptions: { challengePollMs: 5, challengeAutoWaitMs: 200, settleMs: 0 },
});
await sessions(); // 第一次请求时浏览器模块才为这个上下文建服务
svc = browserServiceFor(h.ctx)!;
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_uid=1; c_secure_pass=${GOOD}` });
await h.db
.updateTable("site_account")
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active" })
.where("id", "=", h.accountId)
.execute();
// 视觉子流程(假):点一下复选框所在的位置(第 2 行)
registerChallengeVision(h.ctx, async (req) => {
visionCalls.push(req.sessionId);
if (visionClick)
await svc.visionAct(
req.sessionId,
{ kind: "automation", holder: "system:cloudflare" },
(p, ref) => p.click(ref, { x: 100, y: 70 }),
{ wantImage: false, sensitiveAllowed: false },
);
return { status: visionClick ? "solved" : "needs_human", reason: "", childRunId: null };
});
}, 120_000);
afterAll(async () => {
await h?.close();
});
beforeEach(async () => {
await closeAll();
h.mock.cfPassed.clear();
h.mock.contentReads.clear();
h.mock.actions.length = 0;
for (const jar of h.mock.cookieJars.values())
jar.splice(0, jar.length, ...jar.filter((c) => c.name !== "cf_clearance"));
cfCfg.mode = "auto";
visionCalls.length = 0;
visionClick = true;
await h.db
.updateTable("site")
.set({ config: JSON.stringify({}), enabled: true })
.where("id", "=", h.siteId)
.execute();
await h.db
.updateTable("settings")
.set({ value: JSON.stringify({ enabled: true }) })
.where("key", "=", "automation")
.execute();
});
it("① 自动放行:轮询到挑战页消失,读回 cf_clearance + navigator.userAgent + 出口", async () => {
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(r).toEqual({
status: "passed",
by: "auto",
clearance: CLEARANCE,
userAgent: MOCK_CAMOFOX_IDENTITY.userAgent,
browserEgress: "direct",
});
expect(visionCalls).toEqual([]);
// 会话仍归自动化,没有待处理事项
const [s] = await sessions();
expect(s.controller).toBe("automation");
});
it("② 复选框 / Turnstile:等不过 → 视觉子流程用插件真实鼠标点击 → 通过(by vision)", async () => {
cfCfg.mode = "interactive";
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(r).toMatchObject({ status: "passed", by: "vision", clearance: CLEARANCE });
expect(visionCalls).toHaveLength(1);
expect(h.mock.actions.some((a) => a.action === "mouse-click")).toBe(true);
});
it("③ 视觉子流程点不过(最多 2 次)→ 会话转等待,待处理事项「观众需要你在原站视图里过一次验证」", async () => {
cfCfg.mode = "interactive";
visionClick = false;
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(r).toMatchObject({ status: "needs_human" });
expect(visionCalls).toHaveLength(2);
const s = (await h.req(`/browser/sessions/${(r as Loose).sessionId}`)).body.data as Loose;
expect(s.controller).toBe("waiting");
expect(s.waitingReason).toBe("cf_challenge");
const d = await h.db
.selectFrom("decision")
.selectAll()
.where("id", "=", (r as Loose).decisionId)
.executeTakeFirstOrThrow();
expect(d.title).toBe("观众需要你在原站视图里过一次验证");
expect(d.status).toBe("awaiting_user");
expect((d.evidence as Loose).cloudflare).toMatchObject({ siteId: h.siteId });
// 已在等人:再来一次不打扰,直接返回同一个等待
const again = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(again).toMatchObject({ status: "needs_human", sessionId: (r as Loose).sessionId });
expect(visionCalls).toHaveLength(2);
});
it("自动化总开关关闭:不派视觉子流程,直接转人工", async () => {
cfCfg.mode = "interactive";
await h.db
.insertInto("settings")
.values({ key: "automation", value: JSON.stringify({ enabled: false }) })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ enabled: false }) }))
.execute();
const r = await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url });
expect(r).toMatchObject({ status: "needs_human" });
expect(visionCalls).toEqual([]);
});
it("③ 选图 / 输码类:不点、不等,直接转人工;人过完交还 → 读回 cf_clearance,站点请求继续", async () => {
cfCfg.mode = "captcha";
const r = (await svc.solveSiteChallenge({ siteId: h.siteId, accountId: h.accountId, url })) as Loose;
expect(r.status).toBe("needs_human");
expect(r.reason).toContain("选图");
expect(visionCalls).toEqual([]);
// 站点请求一侧记下"等人处理"(级联在 sources-identity 里做,这里直接写状态)
await h.db
.updateTable("site")
.set({ config: JSON.stringify({ cloudflare: { pending: "human", sessionId: r.sessionId } }) })
.where("id", "=", h.siteId)
.execute();
// 人接管,在桌面里过了验证(桌面操作不经 API:直接改 mock 状态),交还
expect(
(await h.req(`/browser/sessions/${r.sessionId}/control/acquire`, { body: { reason: "过验证" } })).status,
).toBe(200);
h.mock.cfPassed.add(profileKey());
h.mock.cookieJars.get(profileKey())!.push({
name: "cf_clearance",
value: "clr-human",
domain: ".audiences.example",
path: "/",
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: true,
secure: true,
});
expect((await h.req(`/browser/sessions/${r.sessionId}/control/release`, { body: {} })).status).toBe(200);
const site = await h.db.selectFrom("site").select("config").where("id", "=", h.siteId).executeTakeFirstOrThrow();
expect(siteCloudflareState(site.config)).toMatchObject({ pending: null, lastSolvedBy: "human" });
const acc = await h.db
.selectFrom("site_account")
.select("user_info")
.where("id", "=", h.accountId)
.executeTakeFirstOrThrow();
const info = (acc.user_info as Loose).cloudflare;
expect(info).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, egress: "direct" });
expect(h.secrets.plain.get(info.secretRef)).toBe("clr-human");
// 待处理事项已关闭
const d = await h.db
.selectFrom("decision")
.select("status")
.where("id", "=", r.decisionId)
.executeTakeFirstOrThrow();
expect(d.status).toBe("resolved");
});
it("浏览器代抓:过了挑战的 profile 导航取 outerHTML;同一 profile 并发串行(只开一个会话);每次导航取站点令牌", async () => {
h.mock.cfPassed.add(profileKey());
const [a, b] = await Promise.all([
svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url }),
svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url }),
]);
expect(a).toMatchObject({ status: "ok", html: LIST_HTML });
expect(b).toMatchObject({ status: "ok", html: LIST_HTML });
expect(await sessions()).toHaveLength(1);
const navs = h.mock.calls.filter(
(c) => c.path.endsWith("/navigate") || (c.path === "/tabs" && c.method === "POST"),
);
expect(navs.length).toBeGreaterThanOrEqual(2);
const count = await h.db
.selectFrom("site_request_count")
.select((eb) => eb.fn.sum<number>("count").as("n"))
.where("site_id", "=", h.siteId)
.executeTakeFirst();
expect(Number(count?.n ?? 0)).toBeGreaterThanOrEqual(2);
});
it("浏览器代抓:仍是挑战页 → challenge;会话在人工接管中 → 不打扰;站点停用 → 不可用", async () => {
cfCfg.mode = "captcha";
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
status: "challenge",
});
const [s] = await sessions();
await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "看看" } });
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
status: "unavailable",
});
await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} });
await h.db.updateTable("site").set({ enabled: false }).where("id", "=", h.siteId).execute();
expect(await svc.fetchSiteHtml({ siteId: h.siteId, accountId: h.accountId, url })).toMatchObject({
status: "unavailable",
reason: "站点已停用",
});
});
});
@@ -0,0 +1,203 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readAccountCookie } from "../../src/modules/sources-identity/service/credentials.ts";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// BR-2 凭据统一(对 testkit mock camofox + mock NexusPHP 登录态判定):
// 打开会话注入站点账号 Cookie → 首页 / 受保护页是登录态;Cookie 过期 → 需要人工登录;
// 人工在画面里登录后交还 → 读回站点域名 Cookie 写进账号(来源 browser);关闭时也回写;没有插件时降级说明。
const up = await pgAvailable();
const GOOD = "good-session-value-7f3a";
const STALE = "stale-session-value-11";
const FRESH = "fresh-browser-login-9c2";
describe.skipIf(!up)("BR-2 站点账号 Cookie ⇄ 浏览器 profile", () => {
let h: BrowserHarness;
let cookieAcc: string;
const setCookieAccount = async (cookie: string) => {
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie });
await h.db
.updateTable("site_account")
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" })
.where("id", "=", cookieAcc)
.execute();
};
const open = async (path = "/torrents.php") =>
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: cookieAcc, url: `${ORIGIN}${path}` } }))
.body.data as Loose;
const closeAll = async () => {
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
};
beforeAll(async () => {
h = await createBrowserHarness("cred", {
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
});
cookieAcc = h.accountId;
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("注入:Cookie 账号打开会话前写进该 profile,受保护页直接是登录态;响应里没有 Cookie 值", async () => {
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
const s = await open();
expect(s.controller).toBe("automation");
expect(s.currentUrl).toBe(`${ORIGIN}/torrents.php`);
expect(s.credential).toMatchObject({ status: "injected", source: "manual", count: 2 });
expect(JSON.stringify(s)).not.toContain(GOOD);
// 注入走 umt-cookies 插件,早于打开标签页;Cookie 按站点主机 host-only、https → secure
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
const inj = calls.indexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`);
expect(inj).toBeGreaterThanOrEqual(0);
expect(inj).toBeLessThan(calls.lastIndexOf("POST /tabs"));
const jar = h.mock.cookieJars.get(s.profileKey)!;
expect(jar.find((c) => c.name === "c_secure_pass")).toMatchObject({
value: GOOD,
domain: "audiences.example",
secure: true,
});
await closeAll();
});
it("过期:注入后仍落在登录页 → 需要人工登录;人工登录后交还 → 回写账号 Cookie(来源浏览器),下次抓取用新值", async () => {
h.mock.loggedIn.clear();
h.mock.cookieJars.clear();
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${STALE}`);
const s = await open();
expect(s.controller).toBe("waiting");
expect(s.waitingReason).toBe("login_required");
expect(s.credential).toMatchObject({ status: "expired" });
expect(s.credential.message).toContain("登录一次");
const d = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", s.id).executeTakeFirstOrThrow();
expect(d.summary).toContain("交还后全站生效");
// 人工接管,在画面里登录(桌面模式里的导航不经 API:直接改 mock 的登录态与当前地址)
expect((await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "登录" } })).status).toBe(200);
const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!;
h.mock.loggedIn.add(s.profileKey);
h.mock.cookieJars.get(s.profileKey)!.push(
{
name: "c_secure_pass",
value: FRESH,
domain: "audiences.example",
path: "/",
expires: -1,
httpOnly: true,
secure: true,
},
// 别的域名的 Cookie 不回写
{
name: "_ga",
value: "tracker",
domain: ".analytics.example",
path: "/",
expires: -1,
httpOnly: false,
secure: true,
},
);
tab.url = `${ORIGIN}/torrents.php`;
const r = await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} });
expect(r.status).toBe(200);
const acc = await readAccountCookie(h.ctx, cookieAcc);
expect(acc).toMatchObject({ source: "browser", applicable: true });
expect(acc!.header).toContain(`c_secure_pass=${FRESH}`);
expect(acc!.header).toContain("c_secure_uid=1001");
expect(acc!.header).not.toContain("_ga");
const row = await h.db.selectFrom("site_account").selectAll().where("id", "=", cookieAcc).executeTakeFirstOrThrow();
expect((row.user_info as Loose).cookieOrigin).toMatchObject({ source: "browser", by: "browser" });
const view = (await h.req(`/browser/sessions/${s.id}`)).body.data;
expect(view.credential).toMatchObject({ status: "injected", source: "browser" });
expect(view.credential.syncedAt).toBeTruthy();
expect(JSON.stringify(view)).not.toContain(FRESH);
await closeAll();
});
it("关闭会话时回写;停在登录页不回写(未登录的 Cookie 不覆盖存储)", async () => {
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
h.mock.cookieJars.clear();
const s = await open();
expect(s.credential.status).toBe("injected");
// 站点在浏览器里轮换了会话 Cookie
const jar = h.mock.cookieJars.get(s.profileKey)!;
jar.find((c) => c.name === "c_secure_pass")!.value = "rotated-by-site-44";
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain("c_secure_pass=rotated-by-site-44");
// 登录页:不回写
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
const s2 = await open("/login.php");
h.mock.cookieJars.get(s2.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "anonymous-junk";
await h.req(`/browser/sessions/${s2.id}`, { method: "DELETE" });
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
});
it("有活动时每 10 分钟读回一次", async () => {
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
h.mock.cookieJars.clear();
const s = await open();
h.mock.cookieJars.get(s.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "periodic-rotated-5";
// 刚打开:不读回
await h.req(`/browser/sessions/${s.id}/snapshot`);
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
// 会话创建时间拨到 11 分钟前:下一次活动触发后台读回
await h.db
.updateTable("browser_session")
.set({ created_at: new Date(Date.now() - 11 * 60_000) })
.where("id", "=", s.id)
.execute();
await h.req(`/browser/sessions/${s.id}/snapshot`);
await expect
.poll(async () => (await readAccountCookie(h.ctx, cookieAcc))!.header, { timeout: 3000 })
.toContain("c_secure_pass=periodic-rotated-5");
await closeAll();
});
it("匿名会话与 API Key 账号不注入", async () => {
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/` } });
expect(r.body.data.credential).toMatchObject({ status: "not_applicable" });
await closeAll();
const ref = await h.secrets.create({ purpose: "site_api_key", plaintext: "api-key-xyz" });
await h.db
.updateTable("site_account")
.set({ auth_kind: "api_key", secret_ref: ref })
.where("id", "=", cookieAcc)
.execute();
const before = h.mock.callsTo(/\/cookies$/).length;
const s = await open("/");
expect(s.credential).toMatchObject({ status: "not_applicable" });
expect(h.mock.callsTo(/\/cookies$/).length).toBe(before);
await closeAll();
});
});
describe.skipIf(!up)("BR-2 浏览器服务没有 umt-cookies 插件", () => {
let h: BrowserHarness;
beforeAll(async () => {
h = await createBrowserHarness("cred_noplugin", {
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
cookieApi: "none",
});
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("注入报不支持,会话照常打开并说明原因", async () => {
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_pass=${GOOD}` });
await h.db
.updateTable("site_account")
.set({ auth_kind: "cookie", secret_ref: ref })
.where("id", "=", h.accountId)
.execute();
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/` } });
expect(r.status).toBe(200);
expect(r.body.data.credential).toMatchObject({ status: "unsupported", count: 0 });
expect(r.body.data.credential.message).toContain("umt-cookies");
});
});
@@ -0,0 +1,117 @@
import { MOCK_ROW_H, MOCK_ROW_Y0 } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
type BrowserHarness,
createBrowserHarness,
type Loose,
ORIGIN,
PASSKEY,
PASSWORD,
pgAvailable,
} from "./harness.ts";
// C1-E e2e(mock 站点,未连真实 camofox):
// Agent 打开站点 → 登录失效 + 验证码 → 会话转等待处理(待处理事项)→ 自动化被拒 →
// 人在原站视图接管(截图坐标点击 + 输入,密码由服务端注入)完成登录 → 交还 →
// Agent 的旧引用被拒,重新读取页面后继续:捕获种子下载、核验并匹配到本地资源。
const up = await pgAvailable();
const at = (i: number) => ({ x: 320, y: MOCK_ROW_Y0 + MOCK_ROW_H * i + 10 });
describe.skipIf(!up)("e2e:登录失效 → 人工接管 → 交还 → Agent 重新读取并继续", () => {
let h: BrowserHarness;
beforeAll(async () => {
h = await createBrowserHarness("e2e", { captcha: true });
await h.grant("browser.navigate", "browser.interact", "browser.download");
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("全流程", async () => {
const agent = h.agentCtx();
const invoke = (id: string, input: unknown) => h.registry.invoke(id, input, agent) as Promise<Loose>;
// 1. Agent 打开种子列表:登录已失效,落到登录页
const nav = await invoke("browser.navigate", { siteId: h.siteId, url: `${ORIGIN}/torrents.php` });
expect(nav).toMatchObject({ loginRequired: true, controller: "automation" });
const sid = nav.sessionId as string;
// 2. Agent 读页面:有验证码 → 会话转等待处理,生成待处理事项
const snap = await invoke("browser.snapshot", { sessionId: sid });
expect(snap).toMatchObject({ controller: "waiting", waitingReason: "captcha", loginRequired: true });
expect(snap.snapshot.text).toContain("验证码");
const decision = await h.db
.selectFrom("decision")
.selectAll()
.where("subject_id", "=", sid)
.executeTakeFirstOrThrow();
expect(decision).toMatchObject({ status: "awaiting_user", kind: "other" });
expect(decision.title).toContain("观众");
// 3. 等待处理期间 Agent 不能输入(不无限重试)
await expect(
invoke("browser.interact", { sessionId: sid, action: "type", target: { ref: "e3" }, credential: "password" }),
).rejects.toMatchObject({ code: "conflict" });
// 4. 人在原站视图接管:截图 → 坐标点击 → 输入(密码走凭证注入)→ 验证码 → 登录
const view = await h.req(`/browser/sessions/${sid}`);
expect(view.body.data).toMatchObject({ controller: "waiting", waitingReason: "captcha" });
const acq = await h.req(`/browser/sessions/${sid}/control/acquire`, { body: { reason: "验证码" } });
expect(acq.body.data.session.controller).toBe("human");
const shot = await h.req(`/browser/sessions/${sid}/screenshot`);
expect(shot.body.data).toMatchObject({ controller: "human", modelSafe: false });
const human = async (path: string, body: unknown) => {
const r = await h.req(`/browser/sessions/${sid}/${path}`, { body });
expect(r.status, JSON.stringify(r.body)).toBe(200);
};
await human("click", { target: at(1) });
await human("type", { text: "fixture-user" });
await human("click", { target: at(2) });
await human("type", { credential: "password" });
await human("click", { target: at(4) });
await human("type", { text: "4321" });
await human("click", { target: at(5) });
const loggedIn = await h.req(`/browser/sessions/${sid}/snapshot`);
expect(loggedIn.body.data.text).toContain("种子列表");
expect(JSON.stringify(loggedIn.body)).not.toContain(PASSWORD);
expect(JSON.stringify(loggedIn.body)).not.toContain(PASSKEY);
// 5. 交还:Agent 恢复前必须重新读页面;待处理事项关闭
const rel = await h.req(`/browser/sessions/${sid}/control/release`, { body: { reason: "已登录" } });
expect(rel.body.data.session).toMatchObject({ controller: "automation", needsSnapshot: true, waitingReason: null });
const d2 = await h.db
.selectFrom("decision")
.select("status")
.where("id", "=", decision.id)
.executeTakeFirstOrThrow();
expect(d2.status).toBe("resolved");
// 6. 旧引用(交接前拿到的 eN)被拒,不重放
await expect(
invoke("browser.interact", { sessionId: sid, action: "click", target: { ref: "e6" } }),
).rejects.toMatchObject({ code: "conflict", message: expect.stringContaining("重新读取页面") });
// 7. 重新读取:人工登录后的状态;继续捕获下载 → 暂存核验 → 匹配本地资源(提交仍走 download.plan)
const fresh = await invoke("browser.snapshot", { siteId: h.siteId });
expect(fresh).toMatchObject({ controller: "automation", loginRequired: false, waitingReason: null });
expect(fresh.snapshot.text).toContain("种子列表");
const afterAgent = (await h.req(`/browser/sessions/${sid}`)).body.data;
expect(afterAgent.needsSnapshot).toBe(false);
// 原站视图据此显示「Agent 正在操作」;人工动作不刷新它
expect(Date.now() - Date.parse(afterAgent.lastAutomationAt)).toBeLessThan(60_000);
const dl = await invoke("browser.download", { sessionId: sid, target: { ref: "e11" }, timeoutMs: 2_000 });
expect(dl).toMatchObject({
sourceReleaseId: h.releaseId,
duplicate: false,
verification: { valid: true, infohashV1: h.torrent.infoHash },
});
expect(dl.note).toContain("download.plan");
expect(JSON.stringify(dl)).not.toContain(PASSKEY);
// 审计:能力调用留痕,输入里没有凭证
const audits = await h.db.selectFrom("audit_event").select(["action", "target_id", "payload"]).execute();
expect(audits.some((a) => a.target_id === "browser.download" && a.action === "capability.invoke")).toBe(true);
expect(JSON.stringify(audits)).not.toContain(PASSWORD);
}, 60_000);
});
@@ -0,0 +1,79 @@
import { MOCK_CAMOFOX_IDENTITY } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { sourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import { readBrowserUserAgent } from "../../src/modules/sources-identity/service/user-agent.ts";
import { egressProxy } from "../../src/modules/browser/service.ts";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// BR-4:打开会话前按站点下发出口(站点开代理 = 与 HTTP 同一代理,否则直连);浏览器固定身份写进 browser_provider.identity,
// 站点请求 UA 经钩子跟随它。
const up = await pgAvailable();
describe("egressProxy", () => {
it("URL → 上下文代理(口令拆出);非法为 null", () => {
expect(egressProxy("http://u:p%[email protected]:7891")).toEqual({
server: "http://172.17.0.1:7891",
username: "u",
password: "p@ss",
});
expect(egressProxy("socks5://127.0.0.1:1080")).toEqual({ server: "socks5://127.0.0.1:1080" });
expect(egressProxy("not a url")).toBeNull();
});
});
describe.skipIf(!up)("BR-4 浏览器身份与出口", () => {
let h: BrowserHarness;
const open = async () =>
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/torrents.php` } }))
.body.data as Loose;
const closeAll = async () => {
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
};
beforeAll(async () => {
h = await createBrowserHarness("br4id");
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("站点不开代理:该 profile 直连;打开时读浏览器身份存库,站点请求 UA 跟随", async () => {
const s = await open();
expect(h.mock.egress.get(s.profileKey)).toBeNull();
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
expect(calls.indexOf(`PUT /umt/sessions/${encodeURIComponent(s.profileKey)}/proxy`)).toBeLessThan(
calls.lastIndexOf("POST /tabs"),
);
const row = await h.db
.selectFrom("browser_provider")
.select("identity")
.where("id", "=", h.providerId)
.executeTakeFirstOrThrow();
expect(row.identity).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, os: "windows" });
expect(await readBrowserUserAgent(h.ctx)).toBe(MOCK_CAMOFOX_IDENTITY.userAgent);
expect(await sourcesIdentityModule.runtime(h.ctx).defaultUserAgent()).toBe(MOCK_CAMOFOX_IDENTITY.userAgent);
await closeAll();
});
it("站点开代理且设了代理地址:该 profile 走同一代理(口令单独传)", async () => {
await h.db.updateTable("site").set({ proxy: true }).where("id", "=", h.siteId).execute();
await h.db
.insertInto("settings")
.values({ key: "sites", value: JSON.stringify({ proxyUrl: "http://user:[email protected]:7891" }) })
.onConflict((oc) =>
oc.column("key").doUpdateSet({ value: JSON.stringify({ proxyUrl: "http://user:[email protected]:7891" }) }),
)
.execute();
const s = await open();
expect(h.mock.egress.get(s.profileKey)).toEqual({
server: "http://172.17.0.1:7891",
username: "user",
password: "pw",
});
await closeAll();
await h.db.updateTable("site").set({ proxy: false }).where("id", "=", h.siteId).execute();
});
});
@@ -0,0 +1,183 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { browserServiceFor } from "../../src/modules/browser/index.ts";
import { BrowserKeepalive } from "../../src/modules/browser/keepalive.ts";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// AG-4 浏览器会话保活(对 testkit mock camofox):桌面连接 / 人工接管 / 智能体会话正在用的会话定期做一次
// GET /tabs/{id}/downloads(不带 includeData / consume);用完不再保活;保活时标签页已被回收 → BR-3 自愈重连。
describe("BrowserKeepalive(登记与复查)", () => {
const fake = () => {
const calls: string[] = [];
const alive = new Set<string>(["s1", "s2"]);
return {
calls,
alive,
target: {
keepAlive: async (id: string) => {
calls.push(id);
return alive.has(id);
},
},
};
};
it("引用计数:同一会话多个桌面连接,全部断开才停;撤销可重复调用", async () => {
const f = fake();
const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 });
const a = k.hold("s1", "desktop", "desktop");
const b = k.hold("s1", "desktop", "desktop");
await k.tick();
expect(f.calls).toEqual(["s1"]);
a();
a();
expect(k.sessions()).toEqual([{ sessionId: "s1", reasons: ["desktop"] }]);
b();
expect(k.sessions()).toEqual([]);
await k.tick();
expect(f.calls).toEqual(["s1"]);
});
it("复查不通过的登记撤销;会话已关闭(keepAlive 返回 false)整条撤销;ensure 不加引用计数", async () => {
const f = fake();
const k = new BrowserKeepalive(f.target, { intervalMs: 60_000 });
let running = true;
k.ensure("s1", "agent:r1", "agent", async () => running);
k.ensure("s1", "agent:r1", "agent", async () => running);
k.hold("s2", "desktop", "desktop");
await k.tick();
expect(f.calls.sort()).toEqual(["s1", "s2"]);
running = false;
f.alive.delete("s2");
await k.tick();
expect(f.calls).toHaveLength(3); // s1 复查不通过不再保活;s2 保活一次后发现已关闭
expect(k.sessions()).toEqual([]);
});
it("保活失败只记日志,下一轮继续;intervalMs = 0 时不登记", async () => {
const logs: string[] = [];
let fail = true;
const k = new BrowserKeepalive(
{
keepAlive: async () => {
if (fail) throw new Error("浏览器服务不可用");
return true;
},
},
{ intervalMs: 60_000, log: (_l, msg, extra) => logs.push(`${msg} ${String(extra?.err)}`) },
);
k.hold("s1", "takeover", "takeover");
await k.tick();
expect(logs).toEqual(["浏览器会话保活失败 浏览器服务不可用"]);
fail = false;
await k.tick();
expect(k.sessions()).toHaveLength(1);
k.stop();
const off = new BrowserKeepalive(fake().target, { intervalMs: 0 });
off.hold("s1", "desktop", "desktop");
expect(off.sessions()).toEqual([]);
});
});
const up = await pgAvailable();
describe.skipIf(!up)("AG-4 浏览器会话保活(mock camofox)", () => {
let h: BrowserHarness;
const svc = () => browserServiceFor(h.ctx)!;
const open = async (path: string) =>
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: h.accountId, url: `${ORIGIN}${path}` } }))
.body.data as Loose;
const pings = (tabId: string) =>
h.mock.callsTo(`/tabs/${encodeURIComponent(tabId)}/downloads`).filter((c) => c.method === "GET");
const view = async (id: string) => (await h.req(`/browser/sessions/${id}`)).body.data as Loose;
beforeAll(async () => {
h = await createBrowserHarness("ag4ka");
await h.grant("browser.navigate");
}, 120_000);
afterAll(async () => {
svc()?.keepalive.stop();
await h?.close();
});
it("人工接管期间保活:只读调用、不记自动化活动;交还后停止", async () => {
const s = await open("/torrents.php");
const before = (await view(s.id)).lastAutomationAt;
const acq = await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "核对" } });
expect(acq.status).toBe(200);
expect(svc().keepalive.sessions()).toEqual([{ sessionId: s.id, reasons: ["takeover"] }]);
await svc().keepalive.tick();
const p = pings(s.currentTabId);
expect(p).toHaveLength(1);
// 不带 includeData / consume:不读下载内容、不清下载记录
expect(p[0]!.query).toEqual({ userId: s.profileKey });
const v = await view(s.id);
expect(v.controller).toBe("human");
expect(v.lastAutomationAt).toBe(before);
await h.req(`/browser/sessions/${s.id}/control/release`, { body: { to: "automation", reason: "done" } });
expect(svc().keepalive.sessions()).toEqual([]);
await svc().keepalive.tick();
expect(pings(s.currentTabId)).toHaveLength(1);
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
});
it("桌面连接期间标签页被空闲回收 → 保活时按 BR-3 自愈重连回到原地址;断开后停止", async () => {
const s = await open("/details.php?id=42");
const release = svc().keepalive.hold(s.id, "desktop", "desktop");
// 上游空闲回收了标签页
h.mock.tabs.delete(s.currentTabId);
await svc().keepalive.tick();
const v = await view(s.id);
expect(v.currentTabId).not.toBe(s.currentTabId);
// 回到会话记录的地址(测试账号没有登录态,mock 站点跳登录页并带 returnto)
expect(decodeURIComponent(v.currentUrl)).toContain("/details.php?id=42");
expect(v.reconnect).toMatchObject({ status: "ok", trigger: "tab_gone" });
// 新标签页上重试了保活
expect(pings(v.currentTabId)).toHaveLength(1);
release();
expect(svc().keepalive.sessions()).toEqual([]);
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
});
it("智能体会话在用:运行中保活;会话结束后停止;浏览器会话关闭也停止", async () => {
const run = await h.db
.insertInto("agent_run")
.values({
trigger: "user",
goal: "看看种子列表",
status: "running",
mode: "execute",
scope: JSON.stringify({}),
budget: JSON.stringify({}),
created_by: "user:admin",
})
.returning("id")
.executeTakeFirstOrThrow();
const nav = (await h.registry.invoke(
"browser.navigate",
{ siteId: h.siteId, url: `${ORIGIN}/torrents.php` },
{ ...h.agentCtx(), agentRunId: run.id },
)) as Loose;
const sid = nav.sessionId as string;
expect(svc().keepalive.sessions()).toEqual([{ sessionId: sid, reasons: ["agent"] }]);
const tab = (await view(sid)).currentTabId as string;
await svc().keepalive.tick();
expect(pings(tab)).toHaveLength(1);
await h.db.updateTable("agent_run").set({ status: "succeeded" }).where("id", "=", run.id).execute();
await svc().keepalive.tick();
expect(pings(tab)).toHaveLength(1);
expect(svc().keepalive.sessions()).toEqual([]);
// 再用一次(新会话在跑)→ 登记;关闭浏览器会话 → 撤销
await h.db.updateTable("agent_run").set({ status: "running" }).where("id", "=", run.id).execute();
await h.registry.invoke("browser.snapshot", { sessionId: sid }, { ...h.agentCtx(), agentRunId: run.id });
expect(svc().keepalive.sessions()).toHaveLength(1);
await h.req(`/browser/sessions/${sid}`, { method: "DELETE" });
expect(svc().keepalive.sessions()).toEqual([]);
});
});
@@ -0,0 +1,92 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { ServerContext } from "../../src/modules/types.ts";
import { BrowserService, BrowserServiceError, ENV_PROVIDER_NAME } from "../../src/modules/browser/service.ts";
import { ACCESS_KEY, type BrowserHarness, createBrowserHarness, type Loose, pgAvailable } from "./harness.ts";
// BR-1:浏览器服务登记 —— 环境变量(BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → camofox-env)
// 与设置页端点(POST / PATCH /browser/providers、POST /browser/providers/{id}/probe)。
const up = await pgAvailable();
describe.skipIf(!up)("浏览器服务登记", () => {
let h: BrowserHarness;
const withEnv = (env: Record<string, unknown>) =>
new BrowserService({ ...h.ctx, config: { ...(h.ctx.config as object), ...env } } as ServerContext, {});
beforeAll(async () => {
h = await createBrowserHarness("provcfg");
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("BROWSER_PROVIDER_URL:登记 camofox-env,凭据只在进程内;探测带 Bearer;地址/凭据不能在设置页改", async () => {
const svc = withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ACCESS_KEY: ACCESS_KEY, BROWSER_ENABLED: true });
const list = await svc.listProviders(true);
const env = list.find((p) => p.name === ENV_PROVIDER_NAME)!;
expect(env).toMatchObject({ managedBy: "env", hasAccessKey: true, secretRef: null, enabled: true });
expect(env.health?.status).toBe("ok");
const row = await h.db
.selectFrom("browser_provider")
.selectAll()
.where("name", "=", ENV_PROVIDER_NAME)
.executeTakeFirstOrThrow();
expect(JSON.stringify(row)).not.toContain(ACCESS_KEY);
expect(h.mock.calls.filter((c) => c.path === "/openapi.json").at(-1)?.headers.authorization).toBe(
`Bearer ${ACCESS_KEY}`,
);
await expect(svc.updateProvider(env.id, { baseUrl: "http://x:1" })).rejects.toBeInstanceOf(BrowserServiceError);
// 启用可以改
expect((await svc.updateProvider(env.id, { enabled: false })).enabled).toBe(false);
});
it("BROWSER_ENABLED=false / 去掉地址:camofox-env 停用,其它服务不受影响", async () => {
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: false }).syncEnvProvider();
const off = await h.db
.selectFrom("browser_provider")
.selectAll()
.where("name", "=", ENV_PROVIDER_NAME)
.executeTakeFirstOrThrow();
expect(off.enabled).toBe(false);
await withEnv({ BROWSER_PROVIDER_URL: h.mock.url, BROWSER_ENABLED: true }).syncEnvProvider();
await withEnv({}).syncEnvProvider();
const rows = await h.db.selectFrom("browser_provider").select(["name", "enabled"]).orderBy("name").execute();
expect(rows).toEqual([
{ name: "camofox", enabled: true },
{ name: ENV_PROVIDER_NAME, enabled: false },
]);
});
it("设置页:登记 / 改凭据 / 测试连通;凭据明文不出 API,名称 camofox-env 保留", async () => {
const bad = await h.req("/browser/providers", { body: { name: ENV_PROVIDER_NAME, baseUrl: h.mock.url } });
expect(bad.status).toBe(400);
const c = await h.req("/browser/providers", {
body: { name: "my-camofox", baseUrl: h.mock.url, accessKey: "wrong-key", enabled: true },
});
expect(c.status).toBe(200);
const id = c.body.data.id as string;
expect(c.body.data).toMatchObject({ managedBy: null, hasAccessKey: true, enabled: true });
expect(JSON.stringify(c.body)).not.toContain("wrong-key");
const dup = await h.req("/browser/providers", { body: { name: "my-camofox", baseUrl: h.mock.url } });
expect(dup.status).toBe(409);
// 错误凭据:/health 免认证是好的,但 /openapi.json 401 → 标离线并注明 auth
const p1 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
expect(p1.body.data).toMatchObject({
healthStatus: "offline",
health: { status: "down", detail: { error: "auth" } },
});
const u = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: ACCESS_KEY } });
expect(u.status).toBe(200);
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
expect(p2.status).toBe(200);
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
// 空串 = 保持原凭据(FIX-8 只写凭证统一语义)
const kept = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: "", enabled: true } });
expect(kept.body.data).toMatchObject({ hasAccessKey: true, secretRef: u.body.data.secretRef });
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
const env = (await h.req("/browser/providers")).body.data.items.find((p: Loose) => p.name === ENV_PROVIDER_NAME);
expect((await h.req(`/browser/providers/${env.id}`, { method: "DELETE" })).status).toBe(409);
});
});
@@ -0,0 +1,143 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
// BR-3 浏览器服务重启后自愈(对 testkit mock camofox):容器重启 → 旧标签页 410、GET /tabs 为空、实例标识换新、
// profile 登录态丢失(SIGKILL 未落盘)。任一会话操作 → 注入账号 Cookie → 同一会话新开标签页回到原地址 → 重试原操作;
// 失败 503 browser_reconnect_failed;POST .../reconnect 幂等;人工接管中重连控制权不变并提示核对。
const up = await pgAvailable();
const GOOD = "good-session-value-br3";
describe.skipIf(!up)("BR-3 浏览器服务重启后自动重连", () => {
let h: BrowserHarness;
const setCookieAccount = async (cookie: string) => {
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie });
await h.db
.updateTable("site_account")
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" })
.where("id", "=", h.accountId)
.execute();
};
const open = async (path: string) =>
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: h.accountId, url: `${ORIGIN}${path}` } }))
.body.data as Loose;
const closeAll = async () => {
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
};
const tabRow = (upstream: string) =>
h.db.selectFrom("browser_tab").selectAll().where("upstream_tab_id", "=", upstream).executeTakeFirstOrThrow();
const cookiePosts = (profileKey: string) =>
h.mock.callsTo(`/umt/sessions/${encodeURIComponent(profileKey)}/cookies`).filter((c) => c.method === "POST").length;
beforeAll(async () => {
h = await createBrowserHarness("br3", {
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: "fresh-br3" },
});
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("重启后截图:标签页 410 → 注入 Cookie、新开标签页回到原地址、重试成功;旧标签页标 lost", async () => {
const s = await open("/details.php?id=42");
expect(s.currentUrl).toBe(`${ORIGIN}/details.php?id=42`);
const old = s.currentTabId as string;
const injected = cookiePosts(s.profileKey);
h.mock.restart({ loseProfiles: true });
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
expect(r.status).toBe(200);
expect(r.body.data.url).toBe(`${ORIGIN}/details.php?id=42`);
// Cookie 注入先于新标签页;profile 丢了登录态也能回到受保护页(不是登录页)
expect(cookiePosts(s.profileKey)).toBe(injected + 1);
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
expect(calls.lastIndexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`)).toBeLessThan(
calls.lastIndexOf("POST /tabs"),
);
const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!;
expect(tab.sessionKey).toBe(s.id);
expect(tab.url).toBe(`${ORIGIN}/details.php?id=42`);
expect(h.mock.loggedIn.has(s.profileKey)).toBe(true);
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
expect(v.currentTabId).toBe(tab.tabId);
expect(v.currentTabId).not.toBe(old);
expect(v).toMatchObject({ controller: "automation", needsReconnect: false, needsSnapshot: true });
expect(v.reconnect).toMatchObject({ status: "ok", humanNotice: false, reason: null });
expect((await tabRow(old)).status).toBe("lost");
expect(JSON.stringify(v)).not.toContain(GOOD);
await closeAll();
});
it("重启检测:实例标识变了 → 会话标需要重连;POST .../reconnect 幂等", async () => {
const s = await open("/torrents.php");
h.mock.restart();
// 会话详情轮询发现重启(实例标识缓存 5 秒:用显式 reconnect 强制刷新)
const r1 = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
expect(r1.status).toBe(200);
expect(r1.body.data).toMatchObject({
needsReconnect: false,
reconnect: { status: "ok", trigger: "provider_restarted" },
});
expect(r1.body.data.currentUrl).toBe(`${ORIGIN}/torrents.php`);
const opened = h.mock.callsTo(/^\/tabs$/).filter((c) => c.method === "POST").length;
const r2 = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
expect(r2.status).toBe(200);
expect(r2.body.data.currentTabId).toBe(r1.body.data.currentTabId);
expect(h.mock.callsTo(/^\/tabs$/).filter((c) => c.method === "POST").length).toBe(opened);
// Agent 快照也走同一条自愈
h.mock.restart();
const snap = await h.req(`/browser/sessions/${s.id}/snapshot`);
expect(snap.status).toBe(200);
expect(snap.body.data.url).toBe(`${ORIGIN}/torrents.php`);
await closeAll();
});
it("人工接管中重连:控制者与租约不变,提示页面已重新加载", async () => {
const s = await open("/torrents.php");
const acq = await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "核对" } });
const leaseId = acq.body.data.lease.id;
h.mock.restart();
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
expect(r.status).toBe(200);
expect(r.body.data.controller).toBe("human");
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
expect(v.lease).toMatchObject({ id: leaseId, status: "active" });
expect(v.reconnect).toMatchObject({ status: "ok", humanNotice: true });
// 接管者的输入照常可用
const nav = await h.req(`/browser/sessions/${s.id}/navigate`, { body: { url: `${ORIGIN}/index.php` } });
expect(nav.status).toBe(200);
await closeAll();
});
it("重连失败:503 browser_reconnect_failed 带原因;短时间内不反复重连;恢复后手动重连成功", async () => {
const s = await open("/torrents.php");
h.mock.restart();
h.mock.fault(/^\/tabs$/, { status: 500, times: 10 });
const r = await h.req(`/browser/sessions/${s.id}/screenshot`);
expect(r.status).toBe(503);
expect(r.body.error.code).toBe("browser_reconnect_failed");
expect(r.body.error.message).toContain("HTTP 500");
const v = (await h.req(`/browser/sessions/${s.id}`)).body.data as Loose;
expect(v).toMatchObject({ needsReconnect: true, reconnect: { status: "failed", trigger: "tab_gone" } });
// 退避期内的截图直接报上次原因,不再打上游
const before = h.mock.calls.length;
const again = await h.req(`/browser/sessions/${s.id}/screenshot`);
expect(again.status).toBe(503);
expect(h.mock.calls.slice(before).some((c) => c.path === "/tabs")).toBe(false);
h.mock.clearFaults();
const ok = await h.req(`/browser/sessions/${s.id}/reconnect`, { body: {} });
expect(ok.status).toBe(200);
expect(ok.body.data).toMatchObject({ needsReconnect: false, reconnect: { status: "ok" } });
expect((await h.req(`/browser/sessions/${s.id}/screenshot`)).status).toBe(200);
await closeAll();
});
});
@@ -0,0 +1,74 @@
import { getSiteDefinition } from "@umt/adapters";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resourceIdFromUrl } from "../../src/modules/browser/resource.ts";
import { type BrowserHarness, createBrowserHarness, ORIGIN, pgAvailable, READ_KEY } from "./harness.ts";
const def = getSiteDefinition("audiences")!;
describe("原站资源详情定位", () => {
it("仅按本站详情路径定位,不把列表、登录或别站同 id 当作资源", () => {
expect(resourceIdFromUrl(`${ORIGIN}/details.php?id=73`, `${ORIGIN}/`, def)).toBe("73");
expect(resourceIdFromUrl(`${ORIGIN}/torrents.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
expect(resourceIdFromUrl(`${ORIGIN}/login.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
expect(resourceIdFromUrl("https://else.example/details.php?id=73", `${ORIGIN}/`, def)).toBeNull();
});
});
const up = await pgAvailable();
describe.skipIf(!up)("原站详情页交接(隔离 PG 与浏览器 fixture)", () => {
let h: BrowserHarness;
let sid: string;
beforeAll(async () => {
h = await createBrowserHarness("resource", {
html: () => `<!doctype html><html><head><title>资源详情</title></head><body>
<h1 id="top">Example.Movie.2024.2160p.WEB-DL.DV</h1>
<table><tr><td class="rowhead">副标题</td><td class="rowfollow">测试译名与简介</td></tr>
<tr><td class="rowhead">基本信息</td><td class="rowfollow">大小: 2 GB 类型: 电影</td></tr></table>
<div id="kdescr">详情页提供的影片简介 <a href="https://www.imdb.com/title/tt1234567/">IMDb</a></div>
</body></html>`,
});
h.mock.loggedIn.add(`umt-site-${h.siteId}-acct-${h.accountId}`);
const session = await h.req("/browser/sessions", {
body: { siteId: h.siteId, url: `${ORIGIN}/details.php?id=73` },
});
expect(session.status).toBe(200);
sid = session.body.data.id;
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("未在快照/搜索中的详情可直接登记,缓存PT内容供元数据兜底,不点击原站下载按钮", async () => {
const before = h.mock.actions.length;
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.release).toMatchObject({
siteId: h.siteId,
siteReleaseId: "73",
title: "Example.Movie.2024.2160p.WEB-DL.DV",
});
expect(r.body.data.detailUrl).toBe(`${ORIGIN}/details.php?id=73`);
const row = await h.db
.selectFrom("source_release")
.selectAll()
.where("id", "=", r.body.data.release.id)
.executeTakeFirstOrThrow();
expect(row.detail_fetched_at).not.toBeNull();
expect(row.raw).toMatchObject({ _detail: { imdbId: "tt1234567", sourceUrl: `${ORIGIN}/details.php?id=73` } });
expect(h.mock.actions.slice(before).some((a) => a.action === "click")).toBe(false);
const again = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(again.body.data.release.id).toBe(row.id);
});
it("使用当前页面而非旧会话地址;列表页明确提示进入详情", async () => {
const tab = [...h.mock.tabs.values()][0]!;
tab.url = `${ORIGIN}/torrents.php`;
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("详情页");
});
it("只读账号不能通过该入口登记下载资源", async () => {
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {}, key: READ_KEY });
expect(r.status).toBe(403);
});
});
+240
View File
@@ -0,0 +1,240 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CapabilityRegistry, InvokeContext } from "@umt/agent";
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
import {
buildFakeTorrent,
createMockCamofox,
type FakeTorrent,
type MockCamofox,
type MockCamofoxSite,
} from "@umt/testkit";
import { Kysely as K, PostgresDialect } from "kysely";
import pg from "pg";
import { withSchemaLock } from "../auth-settings/harness.ts";
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
import { fakeEngine, fakeSecrets, PG_URL, plainSettings } from "../sources-identity/harness.ts";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { registerBrowserCapabilities } from "../../src/modules/browser/capabilities.ts";
import { createBrowserModule } from "../../src/modules/browser/index.ts";
import type { BrowserModuleOptions } from "../../src/modules/browser/service.ts";
// C1-E 浏览器模块测试装配:独立 PG schema + 真实迁移 + 交互式 mock camofox(观众风格站点)+ 真实能力注册中心。
export { pgAvailable } from "../sources-identity/harness.ts";
export const ORIGIN = "https://audiences.example";
export const PASSWORD = "brw-s3cret-login-pw";
export const PASSKEY = "0f0e0d0c0b0a09080706050403020100";
export const ACCESS_KEY = "camofox-access-key-c1e";
/** 第二位管理员(接管冲突) */
export const OTHER_KEY = "brw-other-admin-key";
export const READ_KEY = "brw-read-key";
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
export type Loose = any;
export interface BrowserHarness {
db: Kysely<DB>;
ctx: ServerContext;
mock: MockCamofox;
torrent: FakeTorrent;
registry: CapabilityRegistry;
siteId: string;
accountId: string;
providerId: string;
releaseId: string;
stagingDir: string;
secrets: ReturnType<typeof fakeSecrets>;
req(path: string, init?: { method?: string; body?: unknown; key?: string }): Promise<{ status: number; body: Loose }>;
agentCtx(): InvokeContext;
grant(...actions: string[]): Promise<void>;
close(): Promise<void>;
}
export async function createBrowserHarness(
name: string,
opts: {
captcha?: boolean;
/** BR-2:mock 站点按这个 Cookie 判定登录态 */
sessionCookie?: { name: string; valid: string[]; fresh: string };
cookieApi?: "plugin" | "upstream" | "none";
cookieImportKey?: string;
/** CF-2:mock 站点的 Cloudflare 挑战与页面 HTML */
cloudflare?: MockCamofoxSite["cloudflare"];
html?: MockCamofoxSite["html"];
/** 浏览器模块的额外选项(挑战轮询间隔等) */
browserOptions?: Partial<BrowserModuleOptions>;
} = {},
): Promise<BrowserHarness> {
const torrent = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay.x265-GRP" });
const mock = await createMockCamofox({
accessKey: ACCESS_KEY,
...(opts.cookieApi ? { cookieApi: opts.cookieApi } : {}),
...(opts.cookieImportKey ? { cookieImportKey: opts.cookieImportKey } : {}),
site: {
...(opts.sessionCookie ? { sessionCookie: opts.sessionCookie } : {}),
...(opts.cloudflare ? { cloudflare: opts.cloudflare } : {}),
...(opts.html ? { html: opts.html } : {}),
origin: ORIGIN,
username: "fixture-user",
password: PASSWORD,
passkey: PASSKEY,
...(opts.captcha ? { captcha: true, captchaAnswer: "4321" } : {}),
releases: [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }],
torrent: () => torrent.bytes,
},
});
const stagingDir = await mkdtemp(join(tmpdir(), "umt-browser-"));
const schema = `c1e_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new K<unknown>({
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
});
await sql.raw(`create schema ${schema}`).execute(admin);
await import("@umt/db");
const db = new K<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
}),
}) as unknown as Kysely<DB>;
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
const { logger } = memoryLogger();
const secrets = fakeSecrets(() => db);
const settings = plainSettings(() => db);
const registry = createCapabilityRegistry({ logger, db, settings });
const ctx = {
role: "all",
db,
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never,
logger,
events: new EventBus(5),
engine: fakeEngine(),
outbox: createOutboxPort(),
secrets,
settings,
capabilities: registry,
} as ServerContext;
const sources = createSourcesIdentityModule({ tmdb: () => null });
const browser = createBrowserModule({
runtime: (c) => sources.runtime(c),
stagingDir,
downloadPollMs: 20,
...opts.browserOptions,
});
registerBrowserCapabilities(registry, () => browser.service(ctx));
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus: new EventBus(5),
role: "all",
version: "0.0.0-test",
// POST /acquisition-plans 在 agent 模块
modules: [sources, createAcquisitionOrganizeModule(), createAgentModule(), browser],
ctx,
apiKeyStore: memoryStore({
[OTHER_KEY]: { id: "other-admin", scopes: ["admin"] },
[READ_KEY]: { id: "reader", scopes: ["read"] },
}),
});
const siteId = (
await db
.insertInto("site")
.values({
key: "audiences",
name: "观众",
adapter: "nexusphp",
base_url: `${ORIGIN}/`,
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const pwRef = await secrets.create({ purpose: "site_password", plaintext: PASSWORD });
const accountId = (
await db
.insertInto("site_account")
.values({ site_id: siteId, label: "main", auth_kind: "password", secret_ref: pwRef, login_name: "fixture-user" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
const keyRef = await secrets.create({ purpose: "other", plaintext: ACCESS_KEY });
const providerId = (
await db
.insertInto("browser_provider")
.values({
name: "camofox",
kind: "camofox",
base_url: mock.url,
secret_ref: keyRef,
enabled: true,
config: JSON.stringify({ verified: { snapshot: true }, navigationTimeoutMs: 2_000, maxRetries: 1 }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const releaseId = (
await db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: "42",
title: "Oppenheimer 2023 2160p UHD BluRay",
infohash_v1: torrent.infoHash,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
return {
db,
ctx,
mock,
torrent,
registry,
siteId,
accountId,
providerId,
releaseId,
stagingDir,
secrets,
async req(path, init = {}) {
const res = await app.request(`/api/v1${path}`, {
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
headers: {
authorization: `Bearer ${init.key ?? DEV_KEY}`,
...(init.body !== undefined ? { "content-type": "application/json" } : {}),
},
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
});
return { status: res.status, body: (await res.json()) as Loose };
},
agentCtx: () => ({
actorId: "agent:c1e",
taskId: crypto.randomUUID(),
scopeIds: [],
signal: new AbortController().signal,
source: "agent",
}),
async grant(...actions) {
for (const action of actions)
await db.insertInto("automation_grant").values({ action, granted_by: "test" }).execute();
},
async close() {
await db.destroy();
await withSchemaLock(() => sql.raw(`drop schema if exists ${schema} cascade`).execute(admin));
await admin.destroy();
await mock.close();
await rm(stagingDir, { recursive: true, force: true });
},
};
}