# 前端工程质量(④)实现计划 / Frontend engineering quality implementation plan > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 按 spec `docs/superpowers/specs/2026-09-15-frontend-quality-design.md` 落地四批次前端基建:ESLint+Prettier(全库 0 error)、组件测试基建(jsdom + testing-library + 3 样板)、Playwright e2e(dev 栈手动门禁 + axe 扫描)、bundle 分析(visualizer + 书面结论)。 **架构:** 全部改动限于 `frontend/`(外加 `.github/workflows/e2e.yml`、`docs/bundle-review.md`、`docs/README*.md`、`docs/CHANGELOG_web.md`)。四批次依序:B1 lint/format → B2 组件测试 → B3 e2e+axe → B4 bundle。每批结束 `npm run check` 必须绿。 **技术栈:** ESLint 9 flat config、typescript-eslint、eslint-plugin-react-hooks、eslint-plugin-jsx-a11y、Prettier、vitest 5 projects、@testing-library/react、jsdom、@playwright/test、@axe-core/playwright、rollup-plugin-visualizer。 **实施分支:** `feat/frontend-quality`(从 master 建;设计文档已在 `docs/frontend-quality-spec` 分支,实施前先合回 master 或直接 cherry-pick spec commit,由用户定)。**master 上不 commit。** **前置条件(每个涉及安装/运行的任务都依赖):** dev 栈已起:`docker compose -f deploy/docker-compose.dev.yml up -d --build`(在仓库根执行;下文简写 `docker compose -f dev up`)。所有 npm 依赖安装在 web 容器内(`node_modules` 是匿名卷,宿主直接 npm install 无效): ```bash # 仓库根执行,下文简写「容器内 npm …」 docker compose -f deploy/docker-compose.dev.yml exec web npm ``` **changelog 规则(AGENTS.md):** 本期全部是 `frontend/` 改动 → 只记 `docs/CHANGELOG_web.md`,`## [Unreleased]` 下 `### Added / 新增`;同一条目英文一行、中文紧接一行(中间不空行),不同条目之间空行。 **验证命令速查:** - 前端总门禁:`docker compose -f deploy/docker-compose.dev.yml exec web npm run check` - e2e(B3 起,需 dev 栈 + 凭据):完整命令见任务 10 步骤 10.2 / 任务 12 步骤 12.2 - 后端不受本期影响,无需跑后端门禁 --- ## 文件结构 / File structure **B1 创建/修改:** - 创建 `frontend/eslint.config.js` — flat config,规则分层(基础/reader 豁免/e2e/测试) - 创建 `frontend/.prettierrc`、`frontend/.prettierignore` - 修改 `frontend/package.json` — scripts:lint/lint:fix/format/format:check/check - 修改全部存量源文件 — prettier 基线格式化(独立 commit)+ eslint 修复 - 修改 `docs/CHANGELOG_web.md` **B2 创建/修改:** - 创建 `frontend/test/setup.ts` — jest-dom + cleanup + jsdom polyfill - 修改 `frontend/vitest.config.ts` — projects 双环境(unit=node / components=jsdom) - 创建 `frontend/src/components/ui/button.test.tsx` - 创建 `frontend/src/components/ui/dialog.test.tsx` - 创建 `frontend/src/components/theme-toggle.test.tsx` **B3 创建/修改:** - 创建 `frontend/playwright.config.ts` - 创建 `frontend/e2e/fixtures/e2e-sample.cbz`(二进制,脚本生成后提交) - 创建 `frontend/e2e/helpers/api.ts`、`frontend/e2e/helpers/axe.ts` - 创建 `frontend/e2e/auth-shelf.spec.ts`、`frontend/e2e/admin-smoke.spec.ts` - 创建 `.github/workflows/e2e.yml` - 修改 `frontend/package.json`(e2e/e2e:ui)、`frontend/eslint.config.js`(e2e override 已在 B1 预置)、根 `.gitignore` **B4 创建/修改:** - 修改 `frontend/vite.config.ts` — 条件启用 visualizer - 修改 `frontend/package.json`(analyze)、根 `.gitignore`(dist-stats) - 创建 `docs/bundle-review.md`(双语) - 修改 `docs/README.md`、`docs/README_zh.md`(双语等价) - 修改 `docs/CHANGELOG_web.md` --- ## B1 批次 1:ESLint + Prettier ### 任务 1:安装依赖 + 创建 lint/format 配置 **文件:** - 创建:`frontend/eslint.config.js`、`frontend/.prettierrc`、`frontend/.prettierignore` - 修改:`frontend/package.json`(scripts,`check` 本任务先不改) - [ ] **步骤 1.1:容器内安装 dev 依赖** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm install -D \ eslint @eslint/js typescript-eslint \ eslint-plugin-react-hooks eslint-plugin-jsx-a11y \ eslint-config-prettier prettier ``` - [ ] **步骤 1.2:创建 `frontend/eslint.config.js`** ```js import js from "@eslint/js"; import tseslint from "typescript-eslint"; import reactHooks from "eslint-plugin-react-hooks"; import jsxA11y from "eslint-plugin-jsx-a11y"; import prettier from "eslint-config-prettier"; export default tseslint.config( { ignores: ["dist/**", "dist-stats/**", "node_modules/**", "playwright-report/**", "test-results/**"] }, js.configs.recommended, ...tseslint.configs.recommended, // react-hooks 插件的 flat 导出名随版本而异(recommended-latest / configs.flat.recommended), // 以安装版本实际导出为准,用 npx eslint --print-config src/main.tsx 验证规则已生效 reactHooks.configs["recommended-latest"], jsxA11y.flatConfigs.recommended, { files: ["**/*.{ts,tsx}"], rules: { "@typescript-eslint/no-unused-vars": [ "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, ], }, }, { // 旧阅读器 chrome 的点击区/交互元素将被 ② 阅读器改版迁入 radix 原语, // 届时收严为 error;本期对存量文件降 warn(spec S1 决策) files: [ "src/readers/**/*.tsx", "src/components/Bookmarks.tsx", "src/components/reader-nav.tsx", "src/components/rd-slider.tsx", "src/pages/Reader.tsx", ], rules: { "jsx-a11y/click-events-have-key-events": "warn", "jsx-a11y/no-static-element-interactions": "warn", "jsx-a11y/no-noninteractive-element-interactions": "warn", }, }, { files: ["e2e/**/*.ts"], languageOptions: { globals: { process: "readonly", console: "readonly", URL: "readonly" } }, rules: { "@typescript-eslint/no-explicit-any": "off" }, }, { files: ["**/*.test.{ts,tsx}", "test/**/*.ts"], rules: { "@typescript-eslint/no-explicit-any": "off" }, }, prettier, // 必须最后:关闭所有与 prettier 冲突的格式规则 ); ``` 注意:`e2e/` override 块此刻没有匹配文件(B3 才创建),eslint 对空匹配不报错,保留即可。 - [ ] **步骤 1.3:创建 `frontend/.prettierrc`** ```json { "printWidth": 120, "tabWidth": 2, "semi": true, "singleQuote": false, "trailingComma": "all" } ``` 依据:存量代码主流是双引号 + 分号(`api/client.ts`、`components/theme.tsx`),printWidth 120 贴近现有行宽,基线 format 的 diff 最小。`components/ui/*.tsx`(shadcn 生成、无分号)会被统一为带分号——这是预期。 - [ ] **步骤 1.4:创建 `frontend/.prettierignore`** ``` dist dist-stats node_modules package-lock.json playwright-report test-results ``` - [ ] **步骤 1.5:`frontend/package.json` 增加 scripts(`check` 不动,任务 4 才接入)** 在 `"scripts"` 中加: ```json "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check ." ``` - [ ] **步骤 1.6:验证工具可跑(此时有 error 是预期,不修)** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npx eslint --print-config src/main.tsx | head -5 docker compose -f deploy/docker-compose.dev.yml exec web npm run lint 2>&1 | tail -20 ``` 预期:print-config 输出含 `react-hooks` 与 `jsx-a11y` 规则(证明插件接通);`npm run lint` 报出存量 error 清单。 - [ ] **步骤 1.7:Commit** ```bash git add frontend/eslint.config.js frontend/.prettierrc frontend/.prettierignore frontend/package.json frontend/package-lock.json git commit -m "chore(frontend): eslint flat config + prettier baseline config" ``` ### 任务 2:Prettier 全量基线格式化(独立 commit) **文件:** 修改:`frontend/` 下全部被 prettier 改写的源文件 - [ ] **步骤 2.1:全量格式化** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm run format ``` - [ ] **步骤 2.2:确认无逻辑改动** ```bash git diff --stat | tail -5 git diff | grep -E '^[-+]' | grep -vE '^[-+][-+]' | grep -cE '\w' ``` 逐屏浏览 `git diff`:应当只有引号/分号/换行/缩进/class 字符串折行变化。若出现标识符或逻辑变化,停止并排查(prettier 不会改语义,出现即异常)。 - [ ] **步骤 2.3:跑存量门禁确认不破坏** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npx tsc --noEmit docker compose -f deploy/docker-compose.dev.yml exec web npx vitest run ``` 预期:tsc 0 error;9 个测试文件全过。 - [ ] **步骤 2.4:Commit(纯格式,单独成 commit 便于回滚)** ```bash git add frontend git commit -m "style(frontend): prettier baseline format (no logic change)" ``` ### 任务 3:ESLint 全库 0 error **文件:** 修改:所有报 error 的源文件;必要时微调 `frontend/eslint.config.js` - [ ] **步骤 3.1:拿到 error 清单并分类** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm run lint 2>&1 | grep -E 'error' | sort | uniq -c | sort -rn ``` - [ ] **步骤 3.2:逐类修复** 修复策略(按 spec S1「全库 0 error 一次到位」): - `no-unused-vars`:删除死代码;确需保留的参数加 `_` 前缀(config 已配 ignore pattern); - `@typescript-eslint/no-explicit-any`:能写出精确类型就写;确实动态的(如 JSON 解析结果)用 `unknown` + 收窄,或该行 `// eslint-disable-next-line @typescript-eslint/no-explicit-any -- <一句话理由>`; - `react-hooks/exhaustive-deps`:recommended 集里默认是 warn,不阻断;若某条是 error 级,优先修依赖数组,修不动(故意省略)则行内 disable + 注释理由; - `jsx-a11y/*`(reader 豁免区之外,如 `pages/Shelf.tsx`、`app-shell.tsx`):属性级小修(加 `role`、`aria-label`、键盘事件);确实属于将被 ② 重写的旧代码,把该文件加进 eslint.config.js 的 reader 豁免 override 的 `files` 列表,并在该块注释里补一行说明(不要新开无注释的豁免块); - 其余规则:能修则修;某规则全库过噪且不适合本期修,才允许在 config 中降 warn/关闭,且必须带注释理由。 每修一类重跑: ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm run lint ``` - [ ] **步骤 3.3:验证 0 error** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm run lint ``` 预期:退出码 0,无 error 行(warn 允许存在,每条 warn 对应的规则降级在 config 内有注释)。 - [ ] **步骤 3.4:确认修复没破坏行为** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npx tsc --noEmit docker compose -f deploy/docker-compose.dev.yml exec web npx vitest run ``` 预期:全绿。 - [ ] **步骤 3.5:Commit** ```bash git add frontend git commit -m "fix(frontend): resolve all eslint errors to zero (B1)" ``` ### 任务 4:接入 check 门禁 + changelog + B1 验收 **文件:** - 修改:`frontend/package.json`(check)、`docs/CHANGELOG_web.md` - [ ] **步骤 4.1:`check` 脚本改为** ```json "check": "tsc --noEmit && eslint . && prettier --check . && vitest run && vite build" ``` - [ ] **步骤 4.2:全量门禁验证** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm run check ``` 预期:五段全部通过、退出码 0。 - [ ] **步骤 4.3:changelog(`docs/CHANGELOG_web.md`,`## [Unreleased]` → `### Added / 新增` 顶部插入)** ```markdown - Developer tooling: ESLint (flat config, typescript-eslint + react-hooks + jsx-a11y) and Prettier are now part of `npm run check` and CI; the whole codebase passes with zero eslint errors. - 开发工具链:ESLint(flat config,typescript-eslint + react-hooks + jsx-a11y)与 Prettier 纳入 `npm run check` 和 CI;全库 0 eslint error。 ``` - [ ] **步骤 4.4:Commit** ```bash git add frontend/package.json docs/CHANGELOG_web.md git commit -m "chore(frontend): wire lint+format into check gate, changelog (B1 done)" ``` --- ## B2 批次 2:组件测试基建 ### 任务 5:测试依赖 + setup + vitest projects 配置 **文件:** - 创建:`frontend/test/setup.ts` - 修改:`frontend/vitest.config.ts` - [ ] **步骤 5.1:容器内安装** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm install -D \ jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom ``` - [ ] **步骤 5.2:创建 `frontend/test/setup.ts`** ```ts import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest"; afterEach(() => cleanup()); // jsdom 缺口 polyfill(spec S2):radix 原语在 jsdom 下的已知缺失,一次配好全体组件测试共用。 class ResizeObserverStub { observe() {} unobserve() {} disconnect() {} } globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver; Element.prototype.scrollIntoView ??= function scrollIntoView() {}; // radix 的焦点/拖拽逻辑会探测 pointer capture API,jsdom 未实现 Element.prototype.hasPointerCapture ??= (() => false) as never; Element.prototype.setPointerCapture ??= (() => {}) as never; Element.prototype.releasePointerCapture ??= (() => {}) as never; ``` - [ ] **步骤 5.3:重写 `frontend/vitest.config.ts` 为 projects 双环境** ```ts import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { "@": new URL("./src", import.meta.url).pathname } }, test: { passWithNoTests: true, projects: [ { extends: true, test: { name: "unit", include: ["test/**/*.test.ts"], environment: "node" }, }, { extends: true, test: { name: "components", include: ["src/**/*.test.tsx"], environment: "jsdom", setupFiles: ["test/setup.ts"], }, }, ], }, }); ``` - [ ] **步骤 5.4:验证存量测试不受影响** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npx vitest run ``` 预期:`unit` project 9 个文件全过;`components` 无测试(passWithNoTests)。 - [ ] **步骤 5.5:Commit** ```bash git add frontend/test/setup.ts frontend/vitest.config.ts frontend/package.json frontend/package-lock.json git commit -m "test(frontend): component testing infra (jsdom + testing-library + vitest projects)" ``` ### 任务 6:三个样板组件测试 **文件:** - 创建:`frontend/src/components/ui/button.test.tsx`、`frontend/src/components/ui/dialog.test.tsx`、`frontend/src/components/theme-toggle.test.tsx` - [ ] **步骤 6.1:`frontend/src/components/ui/button.test.tsx`** ```tsx import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { Button } from "./button"; describe("Button", () => { it("renders label, exposes variant/size data attrs, fires onClick", async () => { const onClick = vi.fn(); render( , ); const btn = screen.getByRole("button", { name: "保存" }); expect(btn).toHaveAttribute("data-variant", "default"); expect(btn).toHaveAttribute("data-size", "default"); await userEvent.click(btn); expect(onClick).toHaveBeenCalledTimes(1); }); it("applies non-default variant/size and blocks clicks when disabled", async () => { const onClick = vi.fn(); render( , ); const btn = screen.getByRole("button"); expect(btn).toHaveAttribute("data-variant", "outline"); expect(btn).toHaveAttribute("data-size", "sm"); expect(btn).toBeDisabled(); await userEvent.click(btn); expect(onClick).not.toHaveBeenCalled(); }); }); ``` - [ ] **步骤 6.2:`frontend/src/components/ui/dialog.test.tsx`** ```tsx import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./dialog"; describe("Dialog", () => { it("opens via trigger, renders title, closes via Escape (radix under jsdom)", async () => { render( 打开对话框 测试标题

正文内容

, ); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "打开对话框" })); expect(await screen.findByRole("dialog")).toBeInTheDocument(); expect(screen.getByText("测试标题")).toBeInTheDocument(); expect(screen.getByText("正文内容")).toBeInTheDocument(); await userEvent.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); }); }); ``` 此测试是基建的关键验证点:radix Portal/焦点管理/动画在 jsdom 下能走通,② 的 Sheet/Popover 迁移才可依赖同一套环境。若因动画卡住,允许在断言前加 `await waitFor(...)`,不允许 mock radix 内部。 - [ ] **步骤 6.3:`frontend/src/components/theme-toggle.test.tsx`** ```tsx import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { ThemeProvider } from "./theme"; import { ThemeToggle } from "./theme-toggle"; describe("ThemeToggle", () => { it("switches theme via dropdown, persists to localStorage, toggles dark class", async () => { localStorage.clear(); document.documentElement.classList.remove("dark"); render( , ); await userEvent.click(screen.getByRole("button", { name: "切换主题" })); await userEvent.click(await screen.findByText("深色")); await waitFor(() => expect(localStorage.getItem("ui.theme")).toBe("dark")); expect(document.documentElement.classList.contains("dark")).toBe(true); }); }); ``` (`theme.tsx` 的持久化 key 是 `ui.theme`,dark 模式落在 `documentElement` 的 `dark` class——断言直接对齐实现,不引入新抽象。) - [ ] **步骤 6.4:运行验证** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npx vitest run ``` 预期:unit 9 文件 + components 3 文件全过。失败则修测试环境问题(polyfill 缺口补进 `test/setup.ts`),不改被测组件行为。 - [ ] **步骤 6.5:lint/format 新文件并全量门禁** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm run check ``` 预期:绿(新测试文件也过 eslint/prettier;不过则 `lint:fix`/`format` 后复跑)。 - [ ] **步骤 6.6:Commit** ```bash git add frontend/src git commit -m "test(frontend): sample component tests for button/dialog/theme-toggle (B2)" ``` --- ## B3 批次 3:e2e + a11y 运行时扫描 ### 任务 7:Playwright 依赖 + 配置 + scripts **文件:** - 创建:`frontend/playwright.config.ts` - 修改:`frontend/package.json`(scripts)、根 `.gitignore` - [ ] **步骤 7.1:容器内安装** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm install -D @playwright/test @axe-core/playwright docker compose -f deploy/docker-compose.dev.yml exec web npx playwright install --with-deps chromium ``` 浏览器只装 chromium;安装命令将写入 README(任务 15)。 - [ ] **步骤 7.2:创建 `frontend/playwright.config.ts`** ```ts import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "e2e", timeout: 60_000, use: { baseURL: process.env.E2E_BASE_URL ?? "http://localhost:5173", trace: "retain-on-failure", }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], }); ``` 不由 Playwright 管理 server:前提是 dev 栈已手动起好(spec S3 决策),连不上时测试直接失败并暴露网络错误。 - [ ] **步骤 7.3:`frontend/package.json` scripts 增加** ```json "e2e": "playwright test", "e2e:ui": "playwright test --ui" ``` - [ ] **步骤 7.4:根 `.gitignore` 增加(`.playwright*/` 已存在)** ``` playwright-report/ test-results/ dist-stats/ ``` - [ ] **步骤 7.5:验证配置可被发现** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npx playwright test --list ``` 预期:0 个测试、无配置错误(e2e 目录还不存在时输出 "no tests found",退出码非 0 也可接受,只要不是配置解析错误)。 - [ ] **步骤 7.6:Commit** ```bash git add frontend/playwright.config.ts frontend/package.json frontend/package-lock.json .gitignore git commit -m "test(frontend): playwright e2e scaffolding (config + scripts)" ``` ### 任务 8:生成 CBZ fixture **文件:** - 创建:`frontend/e2e/fixtures/e2e-sample.cbz`(二进制,约 1KB) - [ ] **步骤 8.1:用 python3 生成 3 页 800x1200 纯色 PNG 的 CBZ** ```bash mkdir -p frontend/e2e/fixtures python3 - <<'PY' import struct, zlib, zipfile W, H = 800, 1200 def chunk(tag, data): body = tag + data return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) def solid_png(rgb): raw = b"".join(b"\x00" + bytes(rgb) * W for _ in range(H)) ihdr = struct.pack(">IIBBBBB", W, H, 8, 2, 0, 0, 0) return ( b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"") ) with zipfile.ZipFile("frontend/e2e/fixtures/e2e-sample.cbz", "w", zipfile.ZIP_STORED) as z: for i, rgb in enumerate([(220, 60, 60), (60, 200, 60), (60, 60, 220)], 1): z.writestr(f"{i:03d}.png", solid_png(rgb)) PY ``` 800x1200 保证每页高于 e2e 视口(1280x720),右缘点击翻屏可观测到页码变化。 - [ ] **步骤 8.2:验证 fixture 合法** ```bash python3 -c "import zipfile; z=zipfile.ZipFile('frontend/e2e/fixtures/e2e-sample.cbz'); print(z.namelist(), z.testzip())" ``` 预期:`['001.png', '002.png', '003.png'] None`。 - [ ] **步骤 8.3:Commit** ```bash git add frontend/e2e/fixtures/e2e-sample.cbz git commit -m "test(frontend): tiny 3-page CBZ fixture for e2e" ``` ### 任务 9:e2e helpers(API fixture + axe) **文件:** - 创建:`frontend/e2e/helpers/api.ts`、`frontend/e2e/helpers/axe.ts` - [ ] **步骤 9.1:创建 `frontend/e2e/helpers/api.ts`** ```ts 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)); } } ``` - [ ] **步骤 9.2:创建 `frontend/e2e/helpers/axe.ts`** ```ts 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([]); } ``` - [ ] **步骤 9.3:lint 验证 + Commit** ```bash docker compose -f deploy/docker-compose.dev.yml exec web npm run lint git add frontend/e2e/helpers git commit -m "test(frontend): e2e api/axe helpers" ``` ### 任务 10:主流程 spec `auth-shelf.spec.ts` **文件:** - 创建:`frontend/e2e/auth-shelf.spec.ts` UI 锚点(来自现有代码,写死选择器时以此为准): - 登录页:`#login-user`、`#login-pass`、提交按钮文案「登录」(`pages/Login.tsx`); - 书架卡片:外层 `