- 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]>
277 lines
15 KiB
TypeScript
277 lines
15 KiB
TypeScript
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);
|
|
});
|