- 新增 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]>
327 lines
11 KiB
TypeScript
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 };
|