Files
rootandClaude Opus 5.5 c9e8120fc6 test: server 测试共用建库与假引擎,sse-pg 归入串行组
- 新增 apps/server/test/support/{pg,engine}.ts:测试库地址、可用性探测、schema 咨询锁、
  建 schema + 加锁迁移 + 删除、可选去重的假引擎;6 份 harness 改用它(acquisition-organize 与
  file-manager 原先迁移不加锁,并行下有冲突隐患,一并修正)
- sse-pg 起子进程,并行下子进程启动超时,归入 serial
- 修正 b61335e 挪动 / 改名测试后遗留的 import 排序与格式(当时 lint 检查经管道被吞掉退出码)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 17:40:43 +08:00

327 lines
11 KiB
TypeScript

import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { FakeScript } from "@umt/agent";
import type { DB } from "@umt/db";
import {
createMockQb,
createMockTmdb,
createTempTree,
type MockQb,
type MockTmdb,
type TempTree,
TMDB_FIXTURES,
type TmdbFixture,
} from "@umt/testkit";
import { type Kysely, sql } from "kysely";
import { ConfigSchema } from "../../src/config.ts";
import { type Bootstrapped, bootstrap } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import type { AgentModuleOptions } from "../../src/modules/agent/runner.ts";
import type { BrowserModuleOptions } from "../../src/modules/browser/service.ts";
import { setDefaultUpstream } from "../../src/modules/pi/store.ts";
import type { ServerModule } from "../../src/modules/types.ts";
import type { WebSearchModuleOptions } from "../../src/modules/websearch/index.ts";
import { type RunningWorker, startWorker } from "../../src/worker/index.ts";
import { createTestSchema, dropSchemas, PG_URL, uniqueSchema } from "../support/pg.ts";
import { buildModules } from "./fixtures/modules.ts";
// 端到端夹具:真实 PG(每个文件独立业务 schema + 独立引擎 schema)+ 真实四个模块 + OpenWorkflow 引擎/worker/outbox 中继
// + testkit mock(qB / TMDB / RSS)+ 临时目录(缓存卷、媒体库、Agent 会话)。HTTP 走 createApp(DEV key = admin)。
export { PG_URL, pgAvailable } from "../support/pg.ts";
export const DEV_KEY = "dev-admin-e2e-key";
export const MASTER_KEY = "e2e-master-key-0123456789";
/** mock TMDB 的 v3 key(32 位十六进制,经设置 metadata.tmdbApiKey 录入) */
export const TMDB_KEY = "0123456789abcdef0123456789abcdef";
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
type Loose = any;
export interface E2E {
name: string;
biz: string;
ow: string;
db: Kysely<DB>;
booted: Bootstrapped;
modules: ServerModule[];
bus: EventBus;
worker: RunningWorker | null;
qb: MockQb;
tmdb: MockTmdb;
lib: TempTree;
cache: TempTree;
sessionDir: string;
/** Hono 应用(管理员会话用例用 cookie 调用,见 auth-settings/harness.ts 的 Browser) */
app: ReturnType<typeof createApp>;
api(method: string, path: string, body?: unknown): Promise<{ status: number; body: Loose }>;
/** PATCH /settings 并断言成功 */
settings(patch: Record<string, unknown>): Promise<Loose>;
startWorker(): Promise<void>;
close(): Promise<void>;
}
export interface E2EOptions {
fakeScripts?: FakeScript[];
/** false:只做 API/引擎客户端(崩溃恢复用例由子进程跑 worker) */
worker?: boolean;
tmdbFixtures?: TmdbFixture[];
/** 网页搜索模块注入(E13) */
webSearch?: WebSearchModuleOptions;
/** 浏览器模块注入(E14) */
browser?: BrowserModuleOptions;
/** 智能体模块的额外参数(E16:替换 / 捕获系统提示) */
agent?: Pick<AgentModuleOptions, "systemPrompt">;
}
export async function waitFor<T>(
fn: () => Promise<T | null | undefined | false>,
ms: number,
what: string,
every = 200,
): Promise<T> {
const deadline = Date.now() + ms;
let last: unknown;
while (Date.now() < deadline) {
try {
const v = await fn();
if (v) return v as T;
} catch (err) {
last = err;
}
await new Promise((r) => setTimeout(r, every));
}
throw new Error(`等待超时:${what}${last ? `(${(last as Error).message})` : ""}`);
}
export async function createE2E(name: string, opts: E2EOptions = {}): Promise<E2E> {
const ts = await createTestSchema(uniqueSchema(`e2e_${name}`), { max: 10 });
const { schema: biz } = ts;
const db: Kysely<DB> = ts.db;
const ow = `${biz}_ow`;
const qb = await createMockQb({ addVisibleAfterPolls: 0 });
const tmdb = await createMockTmdb({ apiKey: TMDB_KEY, fixtures: opts.tmdbFixtures ?? TMDB_FIXTURES });
const lib = await createTempTree({}, { sentinel: true, prefix: "umt-e2e-lib-" });
const cache = await createTempTree({}, { prefix: "umt-e2e-cache-" });
const sessionDir = await mkdtemp(join(tmpdir(), "umt-e2e-sessions-"));
const logger = createLogger({ level: process.env.E2E_LOG_LEVEL === "debug" ? "debug" : "silent" });
const config = ConfigSchema.parse({
DATABASE_URL: PG_URL,
SECRET_MASTER_KEY: MASTER_KEY,
DEV_ADMIN_KEY: DEV_KEY,
NODE_ENV: "development",
});
const bus = new EventBus(5000);
const modules = buildModules({
sessionDir,
...(opts.fakeScripts ? { fakeScripts: opts.fakeScripts } : {}),
...(opts.webSearch ? { webSearch: opts.webSearch } : {}),
...(opts.browser ? { browser: opts.browser } : {}),
...(opts.agent ? { agent: opts.agent } : {}),
});
const booted = await bootstrap({
role: "all",
config,
logger,
db,
events: bus,
modules,
engineSchema: ow,
engineMigrations: true,
});
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus,
role: "all",
version: "e2e",
db,
modules,
ctx: booted.ctx,
});
let worker: RunningWorker | null = null;
const e: E2E = {
name,
biz,
ow,
db,
booted,
modules,
bus,
worker,
qb,
tmdb,
lib,
cache,
sessionDir,
app,
async api(method, path, body) {
const res = await app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${DEV_KEY}`,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { status: res.status, body: await res.json() };
},
async settings(patch) {
const r = await e.api("PATCH", "/settings", patch);
if (r.status !== 200) throw new Error(`PATCH /settings 失败:${JSON.stringify(r.body)}`);
return r.body.data;
},
async startWorker() {
worker = await startWorker({
logger,
heartbeatMs: 60_000,
bus,
runtime: {
ctx: booted.ctx,
modules,
setRelay: booted.outbox.setRelay,
outboxIntervalMs: 100,
// 并发显式固定,不随 WORKER_CONCURRENCY 等环境变量漂移
concurrency: 4,
agentConcurrency: 2,
},
});
e.worker = worker;
},
async close() {
await worker?.stop().catch(() => {});
await booted.engine.stop().catch(() => {});
for (const m of [...modules].reverse()) await m.stop?.();
await db.destroy();
await qb.close();
await tmdb.close();
await lib.cleanup();
await cache.cleanup();
await rm(sessionDir, { recursive: true, force: true });
await dropSchemas(biz, ow);
},
};
// 通用设置:fake 上游(脚本化假模型,设为默认)、TMDB 走设置里的 secret、结算快一点
const fakeUpstream = await db
.insertInto("pi_upstream")
.values({
name: "fake",
provider: "fake",
auth_kind: "none",
default_model: "fake-model",
thinking_level: "medium",
extra: JSON.stringify({}),
enabled: true,
status: "ok",
})
.returning("id")
.executeTakeFirstOrThrow();
await setDefaultUpstream(booted.ctx, fakeUpstream.id);
await e.settings({
metadata: { tmdbApiKey: TMDB_KEY, tmdbBaseUrl: `${tmdb.url}/3` },
retention: { seedRetentionDays: 0 },
// 整理最小体积与模块端口的 minVideoBytes 一致(测试文件 ≥ 100 KB)
organize: { minSizeBytes: 100_000 },
});
if (opts.worker !== false) await e.startWorker();
return e;
}
/** 媒体库根 + qB 实例(保存路径 = 缓存临时目录)+ 站点 */
export async function seedBase(e: E2E, site: { key?: string; baseUrl?: string } = {}) {
const root = await e.db
.insertInto("library_root")
.values({ name: `lib-${e.name}`, media_kind: "mixed", local_path: e.lib.root, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const inst = await e.db
.insertInto("downloader_instance")
.values({
name: `qb-${e.name}`,
base_url: e.qb.url,
username: "admin",
settings: JSON.stringify({ savePath: e.cache.root }),
})
.returning("id")
.executeTakeFirstOrThrow();
const s = await e.db
.insertInto("site")
.values({
key: site.key ?? "audiences",
name: "观众",
adapter: "nexusphp",
base_url: site.baseUrl ?? "https://audiences.example/",
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
})
.returning("id")
.executeTakeFirstOrThrow();
return { rootId: root.id, instanceId: inst.id, siteId: s.id };
}
/** 作品 + 订阅 + 1080p Slot + 两个已识别的站点资源(720p / 1080p);不发 release.resolved,只有 Agent 会去规划 */
export async function seedMovieTarget(e: E2E, siteId: string) {
const entity = await e.db
.insertInto("media_entity")
.values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
const sub = await e.db
.insertInto("subscription")
.values({ media_entity_id: entity.id, media_kind: "movie", mode: "once" })
.returning("id")
.executeTakeFirstOrThrow();
const slot = await e.db
.insertInto("acquisition_slot")
.values({
media_entity_id: entity.id,
subscription_id: sub.id,
name: "1080p",
hard_conditions: JSON.stringify({ resolutions: ["1080p"] }),
})
.returning("id")
.executeTakeFirstOrThrow();
const rel = async (title: string, n: number) =>
(
await e.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: String(880000 + n),
title,
size_bytes: 300_000,
media_entity_id: entity.id,
resolution_status: "resolved",
raw: JSON.stringify({ _site: { hr: false } }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const r720 = await rel("Dune.Part.Two.2024.720p.WEB-DL.x264-AAA", 1);
const r1080 = await rel("Dune.Part.Two.2024.1080p.BluRay.x264-GRP", 2);
return { entityId: entity.id, slotId: slot.id, r720, r1080 };
}
export async function runRow(e: E2E, runId: string) {
return e.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow();
}
export async function runEvents(e: E2E, runId: string) {
return e.db.selectFrom("agent_run_event").selectAll().where("run_id", "=", runId).orderBy("seq").execute();
}
/** 等 mock qB 收到某个种子后置为完成并落盘 */
export async function completeDownload(e: E2E, title: string, ms = 60_000) {
const { torrentFor } = await import("./fixtures/modules.ts");
const hash = torrentFor(title).hash;
await waitFor(async () => e.qb.get(hash), ms, `qB 收到 ${title}`);
await e.qb.complete(hash, { writeFiles: true });
return hash;
}
export { sql };