Files
book-comic-library/docs/superpowers/plans/2026-09-15-frontend-quality.md
T

1186 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端工程质量(④)实现计划 / 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 <args>
```
**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(
<Button onClick={onClick}>
保存
</Button>,
);
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(
<Button variant="outline" size="sm" disabled onClick={onClick}>
x
</Button>,
);
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(
<Dialog>
<DialogTrigger>打开对话框</DialogTrigger>
<DialogContent>
<DialogTitle>测试标题</DialogTitle>
<p>正文内容</p>
</DialogContent>
</Dialog>,
);
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(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>,
);
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<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));
}
}
```
- [ ] **步骤 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<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([]);
}
```
- [ ] **步骤 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`);
- 书架卡片:外层 `<button>` 可访问名含书名(`pages/Shelf.tsx` BookCard);
- 阅读器容器:`aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"`(`readers/CbzReader.tsx:298`);chrome 默认展开(`pages/Reader.tsx` chromeOn=true);
- 导航抽屉:工具条按钮文案「导航」(`aria-controls="reader-nav"`),书签面板「加书签」按钮(`components/Bookmarks.tsx:70`),成功 toast「已加书签」,关闭按钮 `aria-label="关闭导航"`(`components/reader-nav.tsx:58`);
- 退出:侧栏「账户 <用户名>」dropdown → 「退出登录」(`components/app-shell.tsx:52,79`)。
- [ ] **步骤 10.1:创建 `frontend/e2e/auth-shelf.spec.ts`**
```ts
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;
test.beforeAll(async () => {
admin = await adminApi();
bookId = await ensureSampleBook(admin, "e2e");
});
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;样本卡可见并点进阅读器
await expect(page.getByRole("button", { name: /e2e-sample/ })).toBeVisible();
await scanA11y(page, "shelf");
await page.getByRole("button", { name: /e2e-sample/ }).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");
// 右缘点击翻屏 → 页码前进
const box = await reader.boundingBox();
if (!box) throw new Error("阅读器容器无 boundingBox");
await page.mouse.click(box.x + box.width * 0.9, box.y + box.height / 2);
await expect(page.getByText("2/3")).toBeVisible();
// 导航抽屉 → 书签 tab → 加书签 → toast
await page.getByRole("button", { name: "导航", exact: false }).click();
const nav = page.locator("#reader-nav");
await expect(nav).toBeVisible();
await nav.getByRole("button", { name: "书签" }).click();
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/);
});
```
- [ ] **步骤 10.2:起 dev 栈并跑通**
```bash
# 仓库根:确保栈在跑
docker compose -f deploy/docker-compose.dev.yml ps
# 注入凭据后跑(deploy/.env 提供 ADMIN_USER/ADMIN_PASSWORD)
set -a; source deploy/.env; set +a
cd frontend && docker compose -f ../deploy/docker-compose.dev.yml exec -T \
-e E2E_ADMIN_USER="$ADMIN_USER" -e E2E_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
web npx playwright test auth-shelf
```
预期:1 passed。若选择器失配(实现与上述锚点有出入),以 DOM 实况为准修选择器,**不允许改用例语义**(翻页/书签/退出都必须真实发生)。
- [ ] **步骤 10.3:处理 axe 红灯**
`login`/`shelf` 页若有 critical/serious 违规:属性级小修(补 aria/对比度),修完重跑,修复记入 changelog(任务 12 步骤 12.4)。`reader-chrome` 的违规若源于旧 `rd-*` 样式(将被 ② 重写):在 `helpers/axe.ts` 的 `WAIVERS` 加一条(page=`reader-chrome`、rule=违规 id、reason 注明「旧 chrome,② 迁移后移除本豁免」)。a11y 豁免只进 WAIVERS,不另开文档。
- [ ] **步骤 10.4:Commit**
```bash
git add frontend/e2e/auth-shelf.spec.ts frontend/e2e/helpers/axe.ts frontend/src
git commit -m "test(frontend): e2e main flow (login→shelf→cbz→bookmark→logout) + axe scans"
```
### 任务 11:admin 冒烟 spec
**文件:**
- 创建:`frontend/e2e/admin-smoke.spec.ts`
UI 锚点:库管理页输入框 placeholder 以「库名」开头、按钮「建库」、成功 toast「库「<名>」已创建」、行内「扫描」按钮、toast「扫描已触发」(`pages/admin/Libraries.tsx`)。无删库 API(③ 范围),每次运行用唯一库名,dev 库中残留属预期。
- [ ] **步骤 11.1:创建 `frontend/e2e/admin-smoke.spec.ts`**
```ts
import { expect, test } from "@playwright/test";
import { creds } from "./helpers/api";
test("admin:建库 → 触发扫描(冒烟)", async ({ page }) => {
const { user, pass } = creds();
const libName = `e2e-admin-${Date.now()}`;
await page.goto("/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(/\/$/);
await page.goto("/admin/libraries");
await page.getByPlaceholder(/库名/).fill(libName);
await page.getByRole("button", { name: "建库" }).click();
await expect(page.getByText(`库「${libName}」已创建`)).toBeVisible();
await expect(page.getByRole("cell", { name: libName })).toBeVisible();
const row = page.getByRole("row", { name: new RegExp(libName) });
await row.getByRole("button", { name: /扫描/ }).click();
await expect(page.getByText("扫描已触发")).toBeVisible();
});
```
- [ ] **步骤 11.2:跑通**
```bash
cd frontend && docker compose -f ../deploy/docker-compose.dev.yml exec -T \
-e E2E_ADMIN_USER="$ADMIN_USER" -e E2E_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
web npx playwright test admin-smoke
```
预期:1 passed。注意本用例会真实建库并触发扫描;库残留在 dev 数据卷中(无删除 API,③ 补)。
- [ ] **步骤 11.3:Commit**
```bash
git add frontend/e2e/admin-smoke.spec.ts
git commit -m "test(frontend): e2e admin smoke (create library + scan)"
```
### 任务 12:CI e2e workflow + B3 收尾
**文件:**
- 创建:`.github/workflows/e2e.yml`
- 修改:`docs/CHANGELOG_web.md`
- [ ] **步骤 12.1:创建 `.github/workflows/e2e.yml`**
```yaml
name: e2e
on: workflow_dispatch
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Start dev stack
working-directory: deploy
env:
JWT_SECRET: ci-e2e-only-secret
ADMIN_USER: admin
ADMIN_PASSWORD: ci-e2e-password
run: docker compose -f docker-compose.dev.yml up -d --build
- name: Wait for web
run: |
for i in $(seq 1 90); do
if curl -fsS http://localhost:5173/ >/dev/null 2>&1; then exit 0; fi
sleep 5
done
echo "web not up in 450s"; docker compose -f deploy/docker-compose.dev.yml logs --tail 50; exit 1
- run: cd frontend && npm ci
- run: cd frontend && npx playwright install --with-deps chromium
- name: Run e2e
working-directory: frontend
env:
E2E_ADMIN_USER: admin
E2E_ADMIN_PASSWORD: ci-e2e-password
run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: frontend/playwright-report
```
说明:runner 未就绪期间此 workflow 不会自动跑(仅 workflow_dispatch),形同文档;凭据是 CI 一次性值,与 deploy/.env 无关。
- [ ] **步骤 12.2:全量 e2e 本地验证(B3 验收)**
```bash
set -a; source deploy/.env; set +a
cd frontend && docker compose -f ../deploy/docker-compose.dev.yml exec -T \
-e E2E_ADMIN_USER="$ADMIN_USER" -e E2E_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
web npm run e2e
```
预期:2 spec 全 passed。
- [ ] **步骤 12.3:`npm run check` 仍绿(e2e 不进 check)**
```bash
docker compose -f deploy/docker-compose.dev.yml exec web npm run check
```
- [ ] **步骤 12.4:changelog(`### Added / 新增` 顶部插入;若任务 10 做了 a11y 属性修复,在 `### Fixed / 修复`(无此小节则新建)补一条双语说明修了什么)**
```markdown
- E2E test suite (Playwright + axe-core) covering login→shelf→CBZ reading→bookmark→logout and an admin create-library/scan smoke, run against the dev compose stack via `npm run e2e` (manual pre-release gate); critical/serious a11y violations fail the run.
- 新增 e2e 测试套件(Playwright + axe-core):覆盖 登录→书架→CBZ 阅读→书签→退出 主流程与 admin 建库/扫描冒烟,针对 dev compose 栈以 `npm run e2e` 手动门禁运行;critical/serious 级 a11y 违规会使测试失败。
```
- [ ] **步骤 12.5:Commit**
```bash
git add .github/workflows/e2e.yml docs/CHANGELOG_web.md
git commit -m "ci: e2e workflow (workflow_dispatch) + changelog (B3 done)"
```
---
## B4 批次 4:bundle 分析
### 任务 13:visualizer 接入
**文件:**
- 修改:`frontend/vite.config.ts`、`frontend/package.json`
- [ ] **步骤 13.1:容器内安装**
```bash
docker compose -f deploy/docker-compose.dev.yml exec web npm install -D rollup-plugin-visualizer
```
- [ ] **步骤 13.2:`frontend/vite.config.ts` 条件启用**
顶部加 import:
```ts
import { visualizer } from "rollup-plugin-visualizer";
```
`plugins` 数组末尾(`VitePWA({...})` 之后)加:
```ts
// npm run analyze:体积报告输出到 dist-stats/(git-ignore,不进发布产物)
...(process.env.ANALYZE ? [visualizer({ filename: "dist-stats/stats.html", gzipSize: true, brotliSize: true })] : []),
```
- [ ] **步骤 13.3:`frontend/package.json` scripts 增加**
```json
"analyze": "ANALYZE=1 vite build"
```
- [ ] **步骤 13.4:验证报告生成且默认 build 不受影响**
```bash
docker compose -f deploy/docker-compose.dev.yml exec web npm run analyze
docker compose -f deploy/docker-compose.dev.yml exec web ls dist-stats/
docker compose -f deploy/docker-compose.dev.yml exec web npm run build
docker compose -f deploy/docker-compose.dev.yml exec web sh -c 'ls dist-stats 2>/dev/null; test -f dist/stats.html && echo "PWA build ok"'
```
预期:analyze 生成 `dist-stats/stats.html`;普通 build 不生成/不更新它,dist 产物正常。
- [ ] **步骤 13.5:Commit**
```bash
git add frontend/vite.config.ts frontend/package.json frontend/package-lock.json
git commit -m "chore(frontend): bundle analyzer via npm run analyze (B4)"
```
### 任务 14:`docs/bundle-review.md` 审视结论
**文件:**
- 创建:`docs/bundle-review.md`
- [ ] **步骤 14.1:采集数据**
打开 `frontend/dist-stats/stats.html`(浏览器),记录:dist 总大小、每个 chunk 的 stat/parsed/gzip/brotli 体积、Top 依赖(预期大头:pdfjs-dist、epubjs、react-dom、react-query、radix-ui、marked、dompurify、lucide-react)。同时核对 `frontend/src/pages/Reader.tsx` 的 `lazy()` 现状(cbz/txt/md/pdf/epub 五读者已是动态 import)。
- [ ] **步骤 14.2:写 `docs/bundle-review.md`(双语,结构如下,数值填步骤 14.1 实测值)**
```markdown
# 前端 bundle 审视(2026-09) / Frontend bundle review
> 由 `cd frontend && npm run analyze` 生成 `dist-stats/stats.html` 后人工审视得出;本期只记录结论,不实施优化(spec ④ S4)。
> Generated from `dist-stats/stats.html` (`npm run analyze`); findings only — no optimization in this batch (spec ④ S4).
## 现状 / Current state
| chunk | 入口/来源 | stat | gzip | brotli | 备注 |
| --- | --- | --- | --- | --- | --- |
| (实测逐行填写:index 主包、各 lazy reader chunk、vendor 拆分情况) | | | | | |
- 五个阅读器(cbz/txt/md/pdf/epub)均已通过 `pages/Reader.tsx` 的 `lazy()` 按需加载:是/否(实测确认)。
- PWA precache(`workbox.globPatterns`)当前包含哪些大文件:(实测列出)。
## 可优化点 / Optimization backlog
| # | 问题 | 证据(chunk/体积) | 建议动作 | 归属 |
| --- | --- | --- | --- | --- |
| 1 | (例)pdfjs worker 是否进主包 | | (例)确认 worker 独立 chunk + 懒加载 | ② 或后续 |
| 2 | (例)epubjs 依赖链体积 | | | |
| … | | | | |
## 结论 / Verdict
(2-3 句:当前体积是否可接受、最优先的 1-2 个优化项、建议何时做。)
```
表格行与 backlog 条目必须来自实测,不写猜测值;「归属」列只能填「②」「③」或「后续 spec」。
- [ ] **步骤 14.3:Commit**
```bash
git add docs/bundle-review.md
git commit -m "docs: bundle review findings (B4)"
```
### 任务 15:README 双语更新
**文件:**
- 修改:`docs/README.md`、`docs/README_zh.md`(内容等价,同批)
- [ ] **步骤 15.1:更新前端门禁行**
`docs/README.md`(现 L51):
```markdown
Frontend gate: `cd frontend && npm run check` (tsc + eslint + prettier + vitest + vite build). E2E is a separate manual pre-release gate against the dev stack: `cd frontend && npm run e2e` (requires `E2E_ADMIN_USER`/`E2E_ADMIN_PASSWORD`, or source `deploy/.env`; one-off browser install inside the web container: `npx playwright install --with-deps chromium`). Bundle report: `npm run analyze` → `dist-stats/stats.html`.
```
`docs/README_zh.md`(现 L51,等价中文):
```markdown
前端门槛:`cd frontend && npm run check`(tsc + eslint + prettier + vitest + vite build)。e2e 是独立的手动发布门禁,针对 dev 栈运行:`cd frontend && npm run e2e`(需 `E2E_ADMIN_USER`/`E2E_ADMIN_PASSWORD`,或 source `deploy/.env`;浏览器一次性安装于 web 容器内:`npx playwright install --with-deps chromium`)。体积报告:`npm run analyze` → `dist-stats/stats.html`。
```
- [ ] **步骤 15.2:双语等价自查**
两段 README 的 diff 只应触及对应行;英文行与中文行信息点一一对应(check 组成、e2e 前提与凭据、playwright 安装、analyze 产物)。
- [ ] **步骤 15.3:Commit**
```bash
git add docs/README.md docs/README_zh.md
git commit -m "docs: README frontend gates (lint/e2e/analyze), bilingual"
```
### 任务 16:总验收(DoD)+ 收尾
- [ ] **步骤 16.1:对照 spec S5 逐项验证**
```bash
# 1. check 全绿(tsc + eslint 0 error + prettier + vitest 12 文件 + build)
docker compose -f deploy/docker-compose.dev.yml exec web npm run check
# 2. e2e 全绿
set -a; source deploy/.env; set +a
cd frontend && docker compose -f ../deploy/docker-compose.dev.yml exec -T \
-e E2E_ADMIN_USER="$ADMIN_USER" -e E2E_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
web npm run e2e && cd ..
# 3. analyze 可出报告
docker compose -f deploy/docker-compose.dev.yml exec web npm run analyze
# 4. 文档齐备
ls docs/bundle-review.md && git diff master --stat -- docs/README.md docs/README_zh.md docs/CHANGELOG_web.md
# 5. lockfile 已提交、prod 镜像无新依赖
git status --short
grep -c playwright frontend/Dockerfile.prod || echo "prod 未引入 playwright(预期 grep 无命中)"
```
- [ ] **步骤 16.2:changelog 补 B2/B4 条目(`### Added / 新增` 顶部,B1/B3 条目之上按时间倒序)**
```markdown
- Developer tooling: component testing infrastructure (vitest jsdom project + Testing Library) with sample tests for shared UI components; reader component tests are deferred to the reader redesign. Bundle analysis available via `npm run analyze`, findings documented in `docs/bundle-review.md`.
- 开发工具链:组件测试基建(vitest jsdom project + Testing Library),公共 UI 组件配样板测试;阅读器组件测试留待阅读器改版。`npm run analyze` 可出 bundle 体积报告,结论见 `docs/bundle-review.md`。
```
- [ ] **步骤 16.3:最终 Commit + 汇报**
```bash
git add docs/CHANGELOG_web.md
git commit -m "docs: changelog for component testing + bundle review (B4 done, spec ④ complete)"
git log --oneline master..HEAD
```
向用户汇报四批次验收结果,走 finishing-a-development-branch 收尾(合并由用户决定)。
---
## 自检记录 / Self-check
- 规格覆盖度:S1→任务 1-4;S2→任务 5-6;S3→任务 7-12;S4→任务 13-15;S5→任务 16;S6 范围外各项均无对应任务(正确)。spec S2「约定写入 README 或 spec 附录」——附录已在 spec 内,README 在任务 15 补命令说明,覆盖。
- 占位符扫描:任务 14 的表格数值为运行时实测数据(文档模板即交付物结构),非计划占位符;其余步骤均含完整代码/命令。
- 类型一致性:`creds()/adminApi()/ensureLibrary()/ensureSampleBook()/scanA11y()` 在任务 9 定义、任务 10/11 使用,签名一致;`FIXTURE_CBZ` 路径与任务 8 产物一致;`e2e-sample` 标题串在 fixture 文件名、waitBook 查询、书架选择器三处一致。