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); });