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; ctx: ServerContext; mock: MockCamofox; torrent: FakeTorrent; registry: CapabilityRegistry; siteId: string; accountId: string; providerId: string; releaseId: string; stagingDir: string; secrets: ReturnType; req(path: string, init?: { method?: string; body?: unknown; key?: string }): Promise<{ status: number; body: Loose }>; agentCtx(): InvokeContext; grant(...actions: string[]): Promise; close(): Promise; } 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; } = {}, ): Promise { 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({ 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({ dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }), }), }) as unknown as Kysely; 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 }); }, }; }