diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..f6d66af --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,41 @@ +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: Render configs + working-directory: deploy + run: ./prepare.sh + - 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 diff --git a/.gitignore b/.gitignore index 46229b5..a6e6f37 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ tasks/ .playwright*/ frontend/tsconfig.tsbuildinfo +playwright-report/ +test-results/ +dist-stats/ diff --git a/AGENTS.md b/AGENTS.md index f08998e..c449826 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ Root: `docs` (docs + changelogs), `backend` (Go webui service), `frontend` (node - Talks to the backend only over the HTTP APIs in `backend/cmd/webui/api` — no direct infra access (DB, redis) from the browser app. - Build output (`dist/`) is disposable and git-ignored; only `src`/`public` are committed. - Dev runs from the source mount with `node_modules` provided by the image — install new deps inside the container and commit the lockfile. +- TypeScript runs side-by-side (`frontend/package.json`): `typescript` = TS 6.0.2 JS API for tooling (typescript-eslint doesn't support TS 7 yet), `@typescript/native` = native 7.0.2 providing `tsc`. Do not re-alias `typescript` to 7.x until typescript-eslint ships TS 7 support. Plain `npm install` needs `--legacy-peer-deps` (also pinned in `frontend/.npmrc`); `npm ci` is unaffected. ### deploy - Only config templates/examples are tracked by git; rendered `*.conf` and `logs/` are git-ignored. diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 8d074c9..3af75ef 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -10,6 +10,18 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Added / 新增 +- 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`。 + +- 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 违规会使测试失败。 + +- 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。 + +- Lint toolchain note: TypeScript runs side-by-side (`typescript` = TS 6.0.2 JS API for tooling, `@typescript/native` = native 7.0.2 for `tsc`) because typescript-eslint does not support TS 7 yet; do not alias `typescript` to 7.x until typescript-eslint ships TS 7 support. +- 工具链备注:TypeScript 双轨并存(`typescript` = TS 6.0.2 JS API 供 lint 工具链,`@typescript/native` = native 7.0.2 提供 `tsc`),因 typescript-eslint 暂不支持 TS 7;待其支持前勿把 `typescript` 别名改回 7.x。 + - CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone. - CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。 @@ -51,6 +63,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab - Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page. - 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。 +- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/` automatically). +- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。 + ### Removed / 移除 - The admin 删除 button is gone from the reader header: deleting books now lives only in the shelf cover-card menu (with a confirm dialog), so a destructive tap can't happen one step away from the page-turn zone while reading. @@ -58,6 +73,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Fixed / 修复 +- Accessibility fixes: login screen muted text meets WCAG AA contrast in light theme (muted-foreground token darkened); the CBZ reading pane is now keyboard-scrollable (scroll container is focusable). +- 无障碍修复:登录页弱化文本在亮色主题下达到 WCAG AA 对比度(muted-foreground 色值加深);CBZ 阅读面板现在可用键盘滚动(滚动容器可聚焦)。 + - CBZ reader no longer snaps to the top (or visibly bounces) when scrolling or dragging the scrollbar near the end of a long chapter: re-estimating every unmeasured page from each newly measured page made the scroll geometry oscillate on books with mixed page orientations (and the correction was computed from a stale anchor), so the estimator now learns once from the first measured page and then stays put; after a geometry change the viewport is re-anchored absolutely (keep the current page under the same screen spot) instead of by a delta, and while the scrollbar is being dragged the reader doesn't write scrollTop at all, leaving the browser in sole control. - CBZ 阅读器在长章节末尾滚动或拖滚动条时不再出现"到底后被重置到上方"的跳变:此前每量完一页就用它重估所有未量页的高度,横竖版混排的书里估高来回翻转、滚动几何剧烈呼吸,且补偿量基于已过期的 anchor 计算;现改为估高只从首个实测页学习一次、之后保持稳定,几何变化后按"当前页钉回原屏幕位置"绝对重锚(不再用增量补偿),拖拽滚动条期间阅读器完全不写 scrollTop,避免与浏览器抢滚动控制权。 @@ -73,10 +91,3 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab - GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。 - Reading a large .txt no longer freezes the browser: the text is split into chapters (「第X章」/Chapter X/序章… markers, pseudo-sections for unmarked files) and rendered one chapter at a time with a chapter selector and prev/next navigation; 「第X卷/部」 are volume groupings (dropdown optgroups), not chapter breaks, and books with only volumes split by volume; reading progress restores to the saved chapter. - 阅读大 txt 不再卡死浏览器:正文按章节切分(识别「第X章」/Chapter X/序章等标题,无标记的按段落切成小节),每次只渲染一章,并提供章节目录选择与上一章/下一章导航;「第X卷/部」按卷分组(下拉框分组标签)而非章节边界,只有卷没有章的书按卷切分;阅读进度会恢复到上次的章节。 - -### Changed / 变更 - -- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/` automatically). -- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。 - -_No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._ diff --git a/docs/README.md b/docs/README.md index c4f096f..b8c48c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,7 +48,7 @@ go test -p 1 -count=1 ./... `-p 1` is required: integration tests share one PG database and each clears tables with `DELETE FROM ...` — running in parallel deletes each other's data and fails randomly. Bring the dev stack down with `down` (not `rm`), or the anonymous node_modules volume becomes an orphan. Tests that need PG/Redis skip automatically when absent; Redis downtime doesn't break functionality (the whole chain degrades to miss/passthrough, see spec §9). -Frontend gate: `cd frontend && npm run check` (tsc + vitest + vite build). +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`. ## Old-volume migration (one-off, upgrading from the previous deploy layout) diff --git a/docs/README_zh.md b/docs/README_zh.md index 17a719e..83c6603 100644 --- a/docs/README_zh.md +++ b/docs/README_zh.md @@ -48,7 +48,7 @@ go test -p 1 -count=1 ./... `-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。 -前端门槛:`cd frontend && npm run check`(tsc + vitest + vite build)。 +前端门槛:`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`。 ## 旧卷迁移(一次性,升级自上一版部署) diff --git a/docs/bundle-review.md b/docs/bundle-review.md new file mode 100644 index 0000000..5bd7d8d --- /dev/null +++ b/docs/bundle-review.md @@ -0,0 +1,59 @@ +# 前端 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 + +实测基线(`frontend/dist/`,rollup/rolldown 构建产物):总计约 2.6 MB(assets 2,609,869 B;`du -sh` 显示 2.6M,含 PWA precache 全部条目)。每 chunk 构成如下。**注意两套口径**:chunk 级 `stat` 为 minified 实测产物大小(与 `ls dist/assets` 一致);`备注` 列中的依赖级数字为**源码级(未压缩,rollup `renderedLength`)口径**,仅用于依赖之间的相对占比,**不可与 stat/minified 字节数相加或比对**。gzip/brotli 来自 visualizer 输出;总注:gzip 列为 visualizer 容器口径,与宿主机 `gzip -c | wc -c` 在多个文件上存在 ≤2% 的小数位差异(如 index 145.3 vs 143.8 kB、TextReader 27.0 vs 26.7 kB),属统计口径差异。 + +Measured baseline (`frontend/dist/`): ~2.6 MB total (assets 2,609,869 B; `du -sh` 2.6M). Composition per chunk below. **Two calibers apply**: chunk-level `stat` is the measured minified artifact size (matches `ls dist/assets`); dependency-level figures in the `notes` column are **source-level (uncompressed rollup `renderedLength`)** and are only for relative shares among dependencies — **they must not be summed with or compared against stat/minified byte counts**. gzip/brotli come from the visualizer output; note: the gzip column uses the visualizer's in-container figure, which differs from host-side `gzip -c | wc -c` by ≤2% on several files (e.g. index 145.3 vs 143.8 kB, TextReader 27.0 vs 26.7 kB) — a statistics-caliber difference. + +| chunk | 入口/来源 | stat | gzip | brotli | 备注 | +| --- | --- | --- | --- | --- | --- | +| `index-CGr3zft8.js` | 主入口(`src/main.tsx`,shelf/login/admin + react-dom/router/query/radix) | 462.6 kB | 145.3 kB | 122.3 kB | 依赖级(源码级口径,相对占比):react-dom 459.8 + react-router 94.9 + tailwind-merge 56 + radix-ui 各子包共 ~131 + lucide-react(34 个图标) 17.3(gzip 10.1)+ (src) 83.9 kB | +| `pdf.worker-C0DQFlrB.js` | `PdfReader.tsx` worker(`pdfjs-dist/build/pdf.worker.mjs`,Vite worker 语法独立产物) | 1,189.3 kB | 367.0 kB | ~ | 独立文件,不进 index 主包 | +| `PdfReader-Bv6vuw8i.js` | `pages/Reader.tsx` → `lazy(() => import(...))` | 434.4 kB | 130.5 kB | 137.9 kB | pdfjs-dist 主库 764.3 kB(源码级口径,本 chunk 最大依赖;brotli 135.4 kB) | +| `src-BNcXNGhn.js` | 共享 chunk:EpubReader 的 `await import("epubjs")` 链 | 345.4 kB | 103.6 kB | 135.5 kB | 依赖级(源码级口径):epubjs 229.0 + jszip 205.6 + @xmldom/xmldom 97.9 + localforage 65.2 kB + marks-pane/path-webpack/lodash 等 | +| `TextReader-CmvYmFNB.js` | `lazy()`(txt 与 md 共用) | 79.3 kB | 27.0 kB | 30.1 kB | 依赖级(源码级口径):marked 53.7 + dompurify 59.7 kB(gzip 14.2/14.5 kB) | +| `CbzReader-BEFlX6jt.js` | `lazy()` | 12.6 kB | 5.1 kB | 6.2 kB | 仅 (src) 21.1 kB(源码级口径)里的渲染逻辑,jszip 共享 chunk 已含 | +| `useProgress-BHPrULZV.js` | `lib/useProgress`(多 reader 共享) | 7.0 kB | 2.8 kB | 4.7 kB | | +| `EpubReader-BIpid_pd.js` | `lazy()` | 3.1 kB | 1.5 kB | 1.5 kB | 仅薄壳;epubjs 全链在 `src-BNcXNGhn.js` | +| `workbox-window.prod.es5-Bd17z0YL.js` | vite-plugin-pwa(registerSW) | 5.7 kB | 2.2 kB | 2.3 kB | | +| `rolldown-runtime-Dd_uD5pT.js` | 运行时 | 1.1 kB | ~0.9 kB | 0.8 kB | index.html 里 `` | +| `index-C4Rgqjhl.css` | Tailwind v4 产物 | 69.4 kB | 12.1 kB | ~ | | + +- 五个阅读器(cbz/txt/md/pdf/epub)均已通过 `pages/Reader.tsx` 的 `lazy()` 按需加载:是(实测确认——`dist/assets` 有 Cbz/Pdf/Text/Epub 四个独立 reader chunk;txt/md 共用 TextReader 一个 chunk;pdf 另有独立 pdf.worker 文件)。 + Yes (verified): `dist/assets` contains separate chunks for Cbz/Pdf/Text/Epub readers; txt and md share the TextReader chunk; pdf additionally has a separate pdf.worker file. +- PWA precache(`workbox.globPatterns`)当前包含哪些大文件:generateSW 报告 "precache 15 entries (2549.92 KiB)",实测把全部 11 个 JS/CSS 都打进 precache,15 条 = 11 JS/CSS + index.html + icon.svg×2(icon.svg 在清单中出现两次)+ manifest.webmanifest;其中最大的五个:`pdf.worker` 1,189.3 kB、`index` 主包 462.6 kB、`PdfReader` 434.4 kB、`src-BNcXNGhn.js`(epubjs 链)345.4 kB、`TextReader` 79.3 kB。`maximumFileSizeToCacheInBytes` 是 generateSW 构建配置项(`vite.config.ts` 未设置,走默认 2 MiB),非 `sw.js` 内字段——默认值下 1.19 MB 的 pdf.worker 未被排除、仍在 precache 内。 + The precache (reported as "15 entries, 2549.92 KiB") includes all 11 JS/CSS files; the 15 entries = 11 JS/CSS + index.html + icon.svg×2 (icon.svg appears twice in the manifest) + manifest.webmanifest; the five largest are pdf.worker 1,189.3 kB, index 462.6 kB, PdfReader 434.4 kB, src-BNcXNGhn.js (epubjs chain) 345.4 kB, TextReader 79.3 kB. `maximumFileSizeToCacheInBytes` is a generateSW build-time option (not set in `vite.config.ts`, so the default 2 MiB applies), not a field inside `sw.js` — under the default, the 1.19 MB pdf.worker is not excluded and stays in the precache. + +## 可优化点 / Optimization backlog + +| # | 问题 | 证据(chunk/体积) | 建议动作 | 归属 | +| --- | --- | --- | --- | --- | +| 1 | PWA precache 把全站 JS 全量缓存:pdf.worker(1.19 MB)+PdfReader(434 kB)+epubjs 链(345 kB)+TextReader(79 kB) 均在首装/首启 precache 清单,未打开对应阅读器的用户也要下载这 ~2.05 MB | sw.js precache 15 entries(11 JS/CSS + html + icon.svg×2 + webmanifest,2,549.92 KiB);globPatterns `**/*.{js,css,html,svg,woff2}` 未排除 | 首装 precache 仅保留 index/css/主包,reader chunk 改 runtime caching 或自定义 precache 过滤 | 后续 spec | +| 2 | epubjs 依赖链以单体共享 chunk `src-BNcXNGhn.js`(345.4 kB) 存在,体积大且被 precache 全量缓存(见 #1):虽然拆分本身是正确的——实测该 chunk 仅被 `EpubReader-*.js` 动态 import 引用,index 主包无静态引用、无 modulepreload,首屏不加载——但打开 epub 书需一次性下载 345.4 kB(gzip 103.6 kB),且单 chunk 无法按 epubjs/jszip 细分 | `src-BNcXNGhn.js` 345.4 kB:epubjs 229.0 + jszip 205.6 + @xmldom/xmldom 97.9 + localforage 65.2 kB(依赖级,源码级口径);`grep import` 确认仅 EpubReader 动态引用 | 与 #1 的 offline 策略一并处理:reader chunk 改 runtime caching 后,此 chunk 仅在用户真正打开 epub 时下载;或评估 `advancedChunks` 按 epubjs/jszip 拆小 chunk 提升缓存粒度 | 后续 spec | +| 3 | react-dom 459.8 kB(gzip 87.1 kB)是 index 主包内最大的单一依赖(依赖级,源码级口径),无 vendor 拆分;brotli 后 71.4 kB 仍是首屏最大单块 | index chunk rendered 明细:react-dom 459.8 kB / react-router 94.9 kB / query-core 64.2 kB | 评估 `build.rollupOptions.output.manualChunks`(或 vite advancedChunks)拆 vendor,提升缓存命中(业务代码改动不会使 react-dom 失效) | 后续 spec | +| 4 | `lucide-react` 34 个按需 import 的图标最终以完整组件体形式进 index(17.3 kB stat / gzip 10.1 kB——压缩比 ~59%、节省 ~41%,低于典型 JS 的 ~70% 节省水平,可能与 treeshake 不足有关,需进一步确认);且每个 reader chunk 各自带少量 lucide 模块(CbzReader 1.0 kB、PdfReader 0.7 kB、useProgress 2.8 kB)导致重复 | index 内 lucide-react rendered=17.3 kB(gzip 10.1 kB) mods=34;各 reader chunk 再零散重复 | 核查 treeshake 行为(rolldown 下 `lucide-react` named import 是否保留死代码);必要时统一走本仓库 `components/icons.tsx` 自绘 SVG | 后续 spec | +| 5 | radix-ui 单包多模块:`radix-ui` 包内 Select/Menu/Dialog/DropdownMenu/AlertDialog 等 ~29 个子模块共 ~131 kB(其中 react-select 43.0 kB、react-menu 22.1 kB;另有 radix 的定位依赖 floating-ui 46.5 kB,独立包)进 index 主包 | index chunk 内 @radix-ui/* 合计 rendered ≈ 131 kB(gzip ≈ 30 kB,依赖级口径) | 非首屏必需的 Select/DropdownMenu 所在页面(如 Shelf 筛选、admin 页)可评估下沉到对应 route chunk;仅在使用处再 import | 后续 spec | +| 6 | marked+dompurify 只被 TextReader(md 格式) 用,却进了 txt 也复用的 TextReader chunk——纯 txt 用户也下载这两个库(依赖级合计 ~113 kB,源码级口径) | `TextReader-CmvYmFNB.js` 79.3 kB 内 marked 53.7 + dompurify 59.7 kB(依赖级,源码级口径) | md 渲染再拆一层:TextReader 内对 md 分支用 `await import("marked")`/`await import("dompurify")`,txt 分支零依赖 | 后续 spec | + +## 结论 / Verdict + +当前总体体积(首屏 gzip 约 145 kB + CSS 12 kB)对个人书库场景可以接受,五阅读器 lazy 拆分也确实生效(epubjs 链仅被 EpubReader 动态 import,首屏不加载),pdf.worker 已是独立文件不进主包。最优先的两项是 #1(precache 全量缓存 2.05 MB reader 代码,与 spec 的离线策略直接冲突)和 #2(epubjs 链 345 kB 单体 chunk 被 precache 全量缓存,与 #1 同根),建议在下一个 spec 批次(S5 之后的优化轮)落地;#3–#6 属于收尾打磨,可与 #1 同期或更晚处理。 + +Total size is acceptable for a personal library (first-load gzip ≈145 kB + 12 kB CSS), and all five readers are properly split via `lazy()` (the epubjs chain is only dynamically imported by EpubReader and never loads on first paint), with pdf.worker already a standalone file. The two top priorities are #1 (precache ships ~2.05 MB of reader code users may never open — conflicts with the offline spec) and #2 (the 345 kB monolithic epubjs-chain chunk is fully precached, same root cause as #1); both belong to the next optimization round after S5. Items #3–#6 are polish that can land together with #1 or later. + +--- + +### 实测数据来源 / Measurement provenance + +- `npm run analyze`(容器内 vite build + rollup-plugin-visualizer 7.1.1,gzip/brotli 选项开启)→ `frontend/dist-stats/stats.html`;chunk/依赖级 stat、gzip、brotli 数值用 node 脚本从 stats.html 内嵌 `nodeParts`/`nodeMetas` JSON 提取(`nodeParts[uid] = {renderedLength, gzipLength, brotliLength}`,`nodeMetas[uid].moduleParts` 给出模块归属 chunk)。 +- 产物文件级数值:`ls -la frontend/dist/assets/`(与 stats.html 的 chunk 级 stat/minified 大小一致;依赖级 `renderedLength` 是另一套源码级口径,见上)。 +- gzip 交叉核对:宿主机 `gzip -c | wc -c`(index 143,763 / PdfReader 128,873 / src-BNcXNGhn 102,696 / TextReader 26,677 / pdf.worker 366,988 / CbzReader 5,061 / EpubReader 1,500 / useProgress 2,778 / index.css 12,113 字节——与 visualizer 值存在 ≤2% 小数位差异,见现状节总注)。 +- precache:`grep url:"..." frontend/dist/sw.js` 列出 15 条目(11 个 JS/CSS + index.html + icon.svg×2 + manifest.webmanifest)+ 容器内 build 输出 "precache 15 entries (2549.92 KiB)"。 +- dist 总计:`du -sh frontend/dist/` = 2.6M;assets 目录 `du -b` 求和 2,609,869 字节。 +- `Reader.tsx` lazy() 现状与 `dist/assets` 中四 reader chunk + pdf.worker 独立文件直接核对。 + +Provenance: values come from (a) the JSON embedded in `dist-stats/stats.html` produced by `npm run analyze` (rollup-plugin-visualizer 7.1.1 with gzip+brotli), extracted with a node script over `nodeParts`/`nodeMetas`; (b) `ls -la frontend/dist/assets/`; (c) host-side `gzip -c | wc -c` cross-checks; (d) `sw.js` precache URL list and the build log line "precache 15 entries (2549.92 KiB)"; (e) `du -sh frontend/dist` (2.6M) and per-file `du -b` sum (2,609,869 B); (f) direct comparison of `Reader.tsx` `lazy()` sources with the four reader chunks and the standalone pdf.worker file in `dist/assets`. diff --git a/docs/superpowers/plans/2026-09-15-frontend-quality.md b/docs/superpowers/plans/2026-09-15-frontend-quality.md new file mode 100644 index 0000000..35d6420 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-frontend-quality.md @@ -0,0 +1,1185 @@ +# 前端工程质量(④)实现计划 / 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`); +- 书架卡片:外层 ` - diff --git a/frontend/src/components/Cover.tsx b/frontend/src/components/Cover.tsx index 8472df0..e46fea7 100644 --- a/frontend/src/components/Cover.tsx +++ b/frontend/src/components/Cover.tsx @@ -5,15 +5,25 @@ import { btn, formatBadge } from "./ui"; import { TriangleAlert } from "lucide-react"; export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } { - const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false }); + const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ + key: "", + src: "", + failed: false, + }); const [nonce, setNonce] = useState(0); const key = (url ?? "") + "#" + nonce; + // url/nonce 变化时先落回占位态:React 官方推荐的「渲染期按下发 key 重置本地 state」, + // 替代原先 effect 内的同步 setState(级联渲染),行为等价——加载中同样呈现 src="" 的占位 + const [prevKey, setPrevKey] = useState(key); + if (prevKey !== key) { + setPrevKey(key); + setState({ key, src: "", failed: false }); + } useEffect(() => { if (!url) return; let dead = false; const k = url + "#" + nonce; - setState({ key: k, src: "", failed: false }); fetchObjectUrl(url) .then((src) => !dead && setState({ key: k, src, failed: false })) .catch(() => !dead && setState({ key: k, src: "", failed: true })); @@ -30,10 +40,10 @@ export function Cover({ book, className }: { book: Book; className?: string }) { const { src, failed, retry } = useAuthedImage(book.cover_url); if (!book.cover_url || failed) { return ( -
- {Array.from(book.title)[0] ?? "?"} +
+ + {Array.from(book.title)[0] ?? "?"} + {book.format} {failed && (
diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index 9678fad..1d26d86 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -36,7 +36,11 @@ export function Toaster() { t.kind === "ok" ? "border-border" : "border-destructive/40" }`} > - {t.kind === "ok" ? : } + {t.kind === "ok" ? ( + + ) : ( + + )} {t.text}
))} diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index 8e7b4f5..4e62aaa 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -4,7 +4,14 @@ import { Link, NavLink, Outlet, useLocation } from "react-router-dom"; import { api } from "@/api/client"; import { useAuth } from "@/auth/AuthContext"; import { Button } from "@/components/ui/button"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { ThemeToggle } from "./theme-toggle"; import { cn } from "@/lib/utils"; @@ -12,7 +19,11 @@ import type { ReactNode } from "react"; function Brand() { return ( - + @@ -57,7 +68,10 @@ export function AppShell() { - {user?.username}{isAdmin ? " · admin" : ""} + + {user?.username} + {isAdmin ? " · admin" : ""} + {isAdmin && ( <> @@ -88,14 +102,25 @@ export function AppShell() {