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,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: "站点已停用",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user