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 必须写明归属。
|
* 仅允许「属于将被 ② 阅读器改版重写的旧 chrome」的问题进这里,reason 必须写明归属。
|
||||||
* 例:{ page: "reader-chrome", rule: "color-contrast", reason: "旧 rd-* chrome 配色,② 迁移 shadcn token 后移除本豁免" }
|
* 例:{ 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> {
|
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();
|
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
|
||||||
|
|
||||||
if (results.violations.length > 0) {
|
if (results.violations.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`[axe:${name}]`,
|
`[axe:${name}]`,
|
||||||
JSON.stringify(
|
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,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
--secondary: oklch(0.95 0.003 80);
|
--secondary: oklch(0.95 0.003 80);
|
||||||
--secondary-foreground: oklch(0.32 0.01 70);
|
--secondary-foreground: oklch(0.32 0.01 70);
|
||||||
--muted: oklch(0.95 0.003 80);
|
--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: oklch(0.68 0.16 46);
|
||||||
--accent-strong: oklch(0.55 0.17 46);
|
--accent-strong: oklch(0.55 0.17 46);
|
||||||
--accent-foreground: oklch(0.985 0.002 80);
|
--accent-foreground: oklch(0.985 0.002 80);
|
||||||
|
|||||||
@@ -312,6 +312,8 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={attachBox}
|
ref={attachBox}
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);div 无交互语义属滚动区可聚焦的通行做法
|
||||||
|
tabIndex={0}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
onClick={onZoneClick}
|
onClick={onZoneClick}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user