Files

229 lines
8.0 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, Kysely } from "@umt/db";
import {
buildFakeTorrent,
createMockCamofox,
type FakeTorrent,
type MockCamofox,
type MockCamofoxSite,
} from "@umt/testkit";
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 { registerBrowserCapabilities } from "../../src/modules/browser/capabilities.ts";
import { createBrowserModule } from "../../src/modules/browser/index.ts";
import type { BrowserModuleOptions } from "../../src/modules/browser/service.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
import { fakeEngine, fakeSecrets, plainSettings } from "../sources-identity/harness.ts";
import { createTestSchema, uniqueSchema } from "../support/pg.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"];
pageText?: MockCamofoxSite["pageText"];
/** 浏览器模块的额外选项(挑战轮询间隔等) */
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 } : {}),
...(opts.pageText ? { pageText: opts.pageText } : {}),
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 ts = await createTestSchema(uniqueSchema(`c1e_${name}`), { max: 8 });
const db = ts.db as unknown as Kysely<DB>;
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 ts.drop();
await mock.close();
await rm(stagingDir, { recursive: true, force: true });
},
};
}