1186 lines
45 KiB
Markdown
1186 lines
45 KiB
Markdown
# 前端工程质量(④)实现计划 / 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 查询、书架选择器三处一致。
|