- 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]>
144 lines
7.1 KiB
TypeScript
144 lines
7.1 KiB
TypeScript
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();
|
|
});
|
|
});
|