test(frontend): e2e main flow (login→shelf→cbz→bookmark→logout) + axe scans
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { adminApi, creds, ensureSampleBook } from "./helpers/api";
|
||||
import { scanA11y } from "./helpers/axe";
|
||||
|
||||
let admin: APIRequestContext;
|
||||
let bookId: number;
|
||||
let title: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
admin = await adminApi();
|
||||
bookId = await ensureSampleBook(admin, "e2e");
|
||||
// 标题以 API 实况为准:后端 titleOf 可能改写文件名(下划线→空格等),书架卡按真实标题找。
|
||||
title = ((await (await admin.get(`/api/books/${bookId}`)).json()) as { title: string }).title;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await admin?.dispose();
|
||||
});
|
||||
|
||||
test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ page }) => {
|
||||
const { user, pass } = creds();
|
||||
|
||||
// 登录页 + axe
|
||||
await page.goto("/login");
|
||||
await scanA11y(page, "login");
|
||||
await page.fill("#login-user", user);
|
||||
await page.fill("#login-pass", pass);
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
|
||||
// 书架 + axe;样本卡可见并点进阅读器。
|
||||
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
|
||||
const card = page.locator(".group > button", { hasText: title });
|
||||
await expect(card).toBeVisible();
|
||||
await scanA11y(page, "shelf");
|
||||
await card.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
|
||||
|
||||
// 阅读器(chrome 默认展开)+ axe
|
||||
const reader = page.getByLabel("漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏");
|
||||
await expect(reader).toBeVisible();
|
||||
await expect(page.getByText("1/3")).toBeVisible();
|
||||
await scanA11y(page, "reader-chrome");
|
||||
|
||||
// 右缘点击翻屏 → 页码前进。
|
||||
// 单次 scrollBy 一屏未必把视口中心推过第 1 页底(页高 > 一屏),故按实况重试点击直到 2/3。
|
||||
const box = await reader.boundingBox();
|
||||
if (!box) throw new Error("阅读器容器无 boundingBox");
|
||||
await expect(async () => {
|
||||
await page.mouse.click(box.x + box.width * 0.9, box.y + box.height / 2);
|
||||
await expect(page.getByText("2/3")).toBeVisible();
|
||||
}).toPass({ timeout: 20_000 });
|
||||
|
||||
// 导航抽屉(本 fixture 无内嵌目录,抽屉直接落在书签面板)→ 加书签 → toast
|
||||
await page.getByRole("button", { name: "导航", exact: true }).click();
|
||||
const nav = page.locator("#reader-nav");
|
||||
await expect(nav).toBeVisible();
|
||||
await nav.getByRole("button", { name: "加书签" }).click();
|
||||
await expect(page.getByText("已加书签")).toBeVisible();
|
||||
await page.getByRole("button", { name: "关闭导航" }).click();
|
||||
|
||||
// 回书架 → 退出登录
|
||||
await page.getByRole("link", { name: "书架" }).click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click();
|
||||
await page.getByText("退出登录").click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
@@ -12,16 +12,34 @@ interface Waiver {
|
||||
* 仅允许「属于将被 ② 阅读器改版重写的旧 chrome」的问题进这里,reason 必须写明归属。
|
||||
* 例:{ page: "reader-chrome", rule: "color-contrast", reason: "旧 rd-* chrome 配色,② 迁移 shadcn token 后移除本豁免" }
|
||||
*/
|
||||
const WAIVERS: Waiver[] = [];
|
||||
const WAIVERS: Waiver[] = [
|
||||
{
|
||||
page: "reader-chrome",
|
||||
rule: "color-contrast",
|
||||
reason:
|
||||
"旧 chrome:Reader.tsx 头栏(书架返回钮/书名/库名/进度)用 stone-* 配色,对比不足;② 阅读器改版迁移 shadcn token 后移除本豁免",
|
||||
},
|
||||
];
|
||||
|
||||
export async function scanA11y(page: Page, name: string): Promise<void> {
|
||||
// 等 CSS 动画/过渡收尾(如 fx-rise 入场淡入)再扫:动画途中采样会因不透明度混色造成 contrast 误报;2s 上限兜底长动画
|
||||
await page.evaluate(() =>
|
||||
Promise.race([
|
||||
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
|
||||
new Promise((r) => setTimeout(r, 2000)),
|
||||
]),
|
||||
);
|
||||
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 })),
|
||||
results.violations.map((v) => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
nodes: v.nodes.map((n) => n.target.join(" ")),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
--secondary: oklch(0.95 0.003 80);
|
||||
--secondary-foreground: oklch(0.32 0.01 70);
|
||||
--muted: oklch(0.95 0.003 80);
|
||||
--muted-foreground: oklch(0.55 0.008 70);
|
||||
--muted-foreground: oklch(0.52 0.008 70);
|
||||
--accent: oklch(0.68 0.16 46);
|
||||
--accent-strong: oklch(0.55 0.17 46);
|
||||
--accent-foreground: oklch(0.985 0.002 80);
|
||||
|
||||
@@ -312,6 +312,8 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
>
|
||||
<div
|
||||
ref={attachBox}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);div 无交互语义属滚动区可聚焦的通行做法
|
||||
tabIndex={0}
|
||||
onScroll={onScroll}
|
||||
onClick={onZoneClick}
|
||||
onPointerDown={(e) => {
|
||||
|
||||
Reference in New Issue
Block a user