test(frontend): e2e api/axe helpers
This commit is contained in:
@@ -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<APIRequestContext> {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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([]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user