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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user