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