From 217adf5914f07b118b2db4290fa60e553dd4894e Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 15 Sep 2026 14:13:58 +0800 Subject: [PATCH] test(frontend): e2e api/axe helpers --- frontend/e2e/helpers/api.ts | 68 +++++++++++++++++++++++++++++++++++++ frontend/e2e/helpers/axe.ts | 39 +++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 frontend/e2e/helpers/api.ts create mode 100644 frontend/e2e/helpers/axe.ts diff --git a/frontend/e2e/helpers/api.ts b/frontend/e2e/helpers/api.ts new file mode 100644 index 0000000..884d40e --- /dev/null +++ b/frontend/e2e/helpers/api.ts @@ -0,0 +1,68 @@ +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)); + +/** 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}` } }); +} + +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 ensureSampleBook(ctx: APIRequestContext, libName: string): Promise { + const title = "e2e-sample"; + 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: "e2e-sample.cbz", mimeType: "application/zip", buffer: readFileSync(FIXTURE_CBZ) }, + }, + }); + 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)); + } +} diff --git a/frontend/e2e/helpers/axe.ts b/frontend/e2e/helpers/axe.ts new file mode 100644 index 0000000..78c369f --- /dev/null +++ b/frontend/e2e/helpers/axe.ts @@ -0,0 +1,39 @@ +import { expect, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +interface Waiver { + page: string; + rule: string; + reason: string; +} + +/** + * critical/serious 违规的逐条豁免清单(spec S3): + * 仅允许「属于将被 ② 阅读器改版重写的旧 chrome」的问题进这里,reason 必须写明归属。 + * 例:{ page: "reader-chrome", rule: "color-contrast", reason: "旧 rd-* chrome 配色,② 迁移 shadcn token 后移除本豁免" } + */ +const WAIVERS: Waiver[] = []; + +export async function scanA11y(page: Page, name: string): Promise { + const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze(); + + if (results.violations.length > 0) { + console.log( + `[axe:${name}]`, + JSON.stringify( + results.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length })), + null, + 2, + ), + ); + } + + const blocking = results.violations.filter( + (v) => + (v.impact === "critical" || v.impact === "serious") && !WAIVERS.some((w) => w.page === name && w.rule === v.id), + ); + expect( + blocking.map((v) => ({ id: v.id, impact: v.impact })), + `${name} 存在未豁免的 critical/serious a11y 违规`, + ).toEqual([]); +}