- 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]>
241 lines
8.6 KiB
TypeScript
241 lines
8.6 KiB
TypeScript
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 });
|
|
},
|
|
};
|
|
}
|