import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { request, type APIRequestContext } from "@playwright/test"; export const BASE = process.env.E2E_BASE_URL ?? "http://localhost:5173"; export const FIXTURE_CBZ = fileURLToPath(new URL("../fixtures/e2e-sample.cbz", import.meta.url)); export const FIXTURE_TXT = fileURLToPath(new URL("../fixtures/e2e-sample-txt.txt", import.meta.url)); /** UI 登录与 API fixture 共用同一管理员凭据:E2E_ADMIN_* 优先,回落 deploy/.env 注入的 ADMIN_*。 */ export function creds(): { user: string; pass: string } { const user = process.env.E2E_ADMIN_USER ?? process.env.ADMIN_USER; const pass = process.env.E2E_ADMIN_PASSWORD ?? process.env.ADMIN_PASSWORD; if (!user || !pass) { throw new Error( "缺少管理员凭据:export E2E_ADMIN_USER/E2E_ADMIN_PASSWORD," + "或 `set -a; source ../deploy/.env; set +a` 后重跑", ); } return { user, pass }; } export async function adminApi(): Promise { const { user, pass } = creds(); const res = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: user, password: pass }), }); if (!res.ok) throw new Error(`admin 登录失败:HTTP ${res.status}(dev 栈起了吗?凭据对吗?)`); const { token } = (await res.json()) as { token: string }; return request.newContext({ baseURL: BASE, extraHTTPHeaders: { Authorization: `Bearer ${token}` } }); } /** adminApi 登录带 429 退避重试:三 spec 并发 worker 的登录总数可能顶到后端 1min/5 次限流门,等窗口即可;仅消耗等待预算,不改用例语义 */ export async function adminApiRetry(): Promise { for (let i = 0; ; i++) { try { return await adminApi(); } catch (e) { if (i > 20 || !(e instanceof Error) || !e.message.includes("HTTP 429")) throw e; await new Promise((r) => setTimeout(r, 3000)); } } } export async function ensureLibrary(ctx: APIRequestContext, name: string): Promise { const libs = (await (await ctx.get("/api/libraries")).json()) as { id: number; name: string }[]; const hit = libs.find((l) => l.name === name); if (hit) return hit.id; const created = (await (await ctx.post("/api/libraries", { data: { name } })).json()) as { id: number }; return created.id; } async function findBook(ctx: APIRequestContext, q: string): Promise<{ id: number; title: string } | null> { const res = await ctx.get(`/api/books?q=${encodeURIComponent(q)}`); const books = (await res.json()) as { id: number; title: string }[]; return books.find((b) => b.title.includes(q)) ?? null; } /** 幂等:书已存在直接返回 id;否则上传 fixture → 触发扫描 → 轮询等书出现(扫描是 fire-and-forget)。 */ export async function ensureBook( ctx: APIRequestContext, libName: string, fileName: string, absPath: string, ): Promise { const title = fileName.replace(/\.[a-z]+$/i, ""); const existing = await findBook(ctx, title); if (existing) return existing.id; const libId = await ensureLibrary(ctx, libName); const up = await ctx.post(`/api/libraries/${libId}/upload`, { multipart: { file: { name: fileName, mimeType: "application/octet-stream", buffer: readFileSync(absPath) }, }, }); if (!up.ok()) throw new Error(`上传失败:HTTP ${up.status()} ${await up.text()}`); const scan = await ctx.post(`/api/libraries/${libId}/scan`); if (!scan.ok()) throw new Error(`触发扫描失败:HTTP ${scan.status()}`); const deadline = Date.now() + 30_000; for (;;) { const hit = await findBook(ctx, title); if (hit) return hit.id; if (Date.now() > deadline) throw new Error(`扫描后 30s 内未找到书「${title}」`); await new Promise((r) => setTimeout(r, 1000)); } } /** CBZ 样本书便捷封装(auth-shelf 沿用)。 */ export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise { return ensureBook(ctx, libName, "e2e-sample.cbz", FIXTURE_CBZ); }