From 19411e5be6b0d33ccf3aa8e370ed39c57f8c2cce Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 13 Jul 2026 10:07:44 +0000 Subject: [PATCH 1/6] perf(test): switch vitest default env to node, move jsdom to 3 DOM files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vite.config.ts: set default environment to `node` - add `// @vitest-environment jsdom` to 3 files referencing DOM APIs - remove @testing-library/svelte and @testing-library/jest-dom (0 imports) - compose.yaml: use named volume for node_modules to avoid virtiofs overhead - .claude/rules/testing.md: document per-file jsdom opt-in rule - docs/dev-notes/2026-07-11/build-performance: add survey and TODO with measurements wall clock 33.3s → 11.3s (2.9x); environment cumulative 161s → 5.7s Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/testing.md | 4 + compose.yaml | 7 +- .../2026-07-11/build-performance/survey.md | 171 +++++++ .../2026-07-11/build-performance/todo.md | 68 +++ package.json | 3 - pnpm-lock.yaml | 444 +----------------- .../stores/active_contest_type.svelte.test.ts | 2 + .../stores/replenishment_workbook.test.ts | 2 + .../active_problem_list_tab.svelte.test.ts | 2 + vite.config.ts | 3 +- 10 files changed, 258 insertions(+), 448 deletions(-) create mode 100644 docs/dev-notes/2026-07-11/build-performance/survey.md create mode 100644 docs/dev-notes/2026-07-11/build-performance/todo.md diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 75577c16d..0705e9bdb 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -28,6 +28,10 @@ E2E files: **must** use `.spec.ts` extension (`.test.ts` not detected). Route unit tests: `src/routes/**/*.test.ts` is included by `vite.config.ts`. **Never use `+` as a filename prefix** — SvelteKit reserves it and `pnpm check` will error. Name route test files `page_server.test.ts`, not `+page.server.test.ts`. +## Test Environment + +Default is `node` (set in `vite.config.ts`). Only files touching the real DOM (`window` / `document` / `localStorage`) opt in with a top-of-file `// @vitest-environment jsdom`. **Never set jsdom globally** — most tests are pure units and per-file jsdom construction is ~5.5x slower. `$app/environment`'s `browser` is controllable via `vi.mock`, but keep jsdom on DOM-referencing files so browser-branch coverage isn't silently dropped. + ## Unit Testing Patterns ### Assertions diff --git a/compose.yaml b/compose.yaml index ca78cccf6..a363729bb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,8 +6,10 @@ services: - '5555:5555' tty: true volumes: - - .:/usr/src/app:cached - - ./node_modules:/usr/src/app/node_modules:cached + - .:/usr/src/app + # Named volume keeps node_modules on the Linux VM disk instead of crossing the + # virtiofs host boundary on every open/stat/read (`:cached` is a no-op on OrbStack). + - node_modules:/usr/src/app/node_modules environment: - NODE_ENV=development - DATABASE_URL=postgresql://db_user:db_password@db:5432/test_db?pgbouncer=true&connection_limit=10&connect_timeout=60&statement_timeout=60000 # Note: Local server cannot start if port is set to db:6543. @@ -31,5 +33,6 @@ services: - '6543:5432' volumes: + node_modules: postgres-data: driver: local diff --git a/docs/dev-notes/2026-07-11/build-performance/survey.md b/docs/dev-notes/2026-07-11/build-performance/survey.md new file mode 100644 index 000000000..a886240da --- /dev/null +++ b/docs/dev-notes/2026-07-11/build-performance/survey.md @@ -0,0 +1,171 @@ +# pnpm build / vitest 低速問題の調査 + +調査日: 2026-07-11 + +## 概要 + +`pnpm build` が約 30 秒、vitest が約 2,700 テストで約 30 秒かかる問題のボトルネック調査。環境は Mac Air M3 + OrbStack の Node コンテナ(Node v24.13.0、8 コア、RAM 11GiB)。コードベースは約 4 万行 / 350 ファイル。 + +**結論: コードベース規模の問題ではなく、(1) vitest の全ファイル jsdom 環境(最大要因・実測 5.5 倍)、(2) メモリ枯渇によるスワップ、(3) node_modules の virtiofs バインドマウント、(4) SvelteKit 2.x + Vite 8(rolldown)の guard プラグイン非効率、の複合。** 設定は「デフォルトのまま」で正しいが、デフォルトがこの環境・テスト構成に合っていない。 + +## 計測条件の注記 + +本調査の計測時、コンテナは**スワップ 8GiB 使用の高負荷状態**だった(RAM 11GiB ほぼ満杯)。そのため絶対値は平常時(約 30 秒)の約 5 倍に悪化している。**絶対値は割り引いて読む必要があるが、内訳の比率は構造的ボトルネックをそのまま示している。** + +## 実測データ + +再現コマンドと結果: + +```bash +time pnpm exec prisma generate # real 5.2s (sys 2.2s) +time pnpm exec vite build # real 2m27s (user 1m46s, sys 37s) +time pnpm exec vitest run # real 2m19s (user 5m45s, sys 3m10s) +``` + +### vite build の内訳 + +- クライアントビルド: 49.6s(1,140 モジュール変換) +- SSR ビルド: 1m46s +- PLUGIN_TIMINGS 警告: **`vite-plugin-sveltekit-guard` がプラグイン時間の 88%**、`vite-plugin-sveltekit-virtual-modules` 6% + +### vitest の内訳(Duration 139.3s) + +注意: 以下の項目はいずれも **wall clock ではなく全ワーカー横断の累積値**。またスワップ圧で膨張した状態の計測値(後述の node 環境実験では同一テストの tests が 8.7s だった = 約 2.5 倍膨張)。 + +| 項目 | 時間 | 備考 | +| ----------------------- | --------------- | ------------------------------------------------------------------------------------------- | +| tests(テスト本体) | 累積 21.9s | 平均 約 3ms/テスト。DB モックや nock を含む構成として普通(ボトルネックではないが速くもない) | +| environment(jsdom 構築) | **累積 687.2s** | 最大の無駄 | +| import(モジュール読込) | 累積 130.8s | jsdom 本体の import + virtiofs + プロセス分離で増幅 | +| transform | 累積 78.0s | 同上 | + +sys 3m10s(カーネル時間)が user 5m45s に対して異常に大きいが、node 環境実験で virtiofs 同一のまま sys 23s に減少したため、大半は jsdom 構築に伴う大量のモジュール読込とメモリ圧(ページフォルト)由来。並列化はすでに効いている(fork プール 8 ワーカー、user 5m45s ÷ real 2m19s ≈ 2.5)。問題は並列の幅ではなく、各ワーカーがファイルごとに jsdom を構築する「重さ」。 + +### 検証実験: jsdom を外すと 5.5 倍速くなる + +設定変更なし・CLI フラグのみ・スワップ 7.7GiB 使用中の同一コンテナで実測: + +```bash +time pnpm exec vitest run --environment=node +# Duration 20.49s (transform 32.6s, import 56.1s, tests 8.7s, environment 0.12s) +# real 0m25.4s — 80 ファイル全パス(2,745 passed / 1 skipped) +``` + +- wall clock **2m19s → 25.4s**(このうち周辺負荷差も一部混在するが、environment 687s → 0.12s と import 半減は構造的な差) +- **DOM を参照するはずの 3 ファイルも node 環境でパス**(`typeof window` ガード等でブラウザ側分岐を通らずにパスしたと推測)。ただし「パスした = 同じ分岐をテストしている」ではないため、この 3 ファイルには `// @vitest-environment jsdom` を付ける方式を維持する(追記 Q&A 参照) +- jsdom × 8 ワーカーのメモリ消費がスワップを悪化させ、スワップが全体をさらに遅くする自己増悪ループも同時に解消される + +### flaky テストの観測 + +初回フル実行で 1 件失敗 → 直後の再実行(`--bail=1`)では 2,745 件全パス。高負荷(スワップ使用中)時のみ失敗するタイミング依存のテストが 1 件存在する。ファイル特定は未実施。 + +## ボトルネック分析(影響度順) + +### 1. vitest: 全 80 テストファイルが jsdom 環境で実行【設定・最大要因】 + +`vite.config.ts` で `environment: 'jsdom'` がグローバル指定されているが、実測で: + +- `@testing-library/svelte` を import するテストファイル: **0 件**(パッケージ自体が未使用) +- DOM API(`window` / `document` / `localStorage`)を参照するテスト: **3 ファイルのみ** + - `src/test/lib/stores/active_problem_list_tab.svelte.test.ts` + - `src/features/tasks/stores/active_contest_type.svelte.test.ts` + - `src/features/workbooks/stores/replenishment_workbook.test.ts` + +残り 77 ファイルは純粋なユニットテストなのに、ファイルごとに jsdom 環境(重い DOM 実装の import + 初期化)を構築している。environment 累積 687s は純粋な無駄。CPU だけでなくメモリのピークも押し上げてスワップ突入(2)を誘発する、二重の悪化要因。node 環境実験で **wall clock 5.5 倍の改善を実測済み**。 + +### 2. メモリ枯渇とスワップ【環境】 + +計測時点で RAM 11GiB ほぼ満杯 + スワップ 8GiB 使用。平常時 30 秒のビルドが本調査で 2 分半かかった直接原因。スワップに乗った状態の Node プロセスは数倍遅くなる。 + +常駐の内訳(実測): VS Code Server 群 約 2.6GB + `vite dev` 648MB(PID 実測で確定、7/10 から常駐)+ claude 等。**テスト開始前からほぼ満杯**のところに jsdom × 8 ワーカーのピークが乗ってスワップに突入する構図。jsdom をやめること自体がメモリ対策にもなる。dev サーバーの再起動でベースラインも約 650MB 空く。 + +### 3. node_modules が virtiofs バインドマウント【環境・二番手(追加効果は未実測)】 + +`compose.yaml`: + +```yaml +volumes: + - .:/usr/src/app:cached + - ./node_modules:/usr/src/app/node_modules:cached +``` + +node_modules の実体が mac ホスト側にあり、コンテナからの open/stat/read が 1 回ずつ virtiofs(VM 境界)を越える。Node のモジュール解決は小さいアクセスを数千回行うため累積する。node 環境実験でも import 累積 56s が残っており短縮余地はあるが、named volume 化による追加効果は A/B 未計測。`:cached` フラグは Docker Desktop 時代の指定で、OrbStack の virtiofs では効果がない。 + +### 4. vite build: sveltekit-guard プラグインが 88%【upstream】 + +SvelteKit 2.x のプラグインは rolldown-vite(Vite 8)の hook filters に未対応で、全モジュールが JS プラグインフックを通過する。[sveltejs/kit#13756](https://github.com/sveltejs/kit/issues/13756)(Adopt rolldown-vite hook filters)で解決済みだが **milestone は 3.0** — kit 2.69.2 では対処不可、SvelteKit 3.0 待ち。 + +補足([公式 docs](https://vite.dev/guide/api-plugin) 確認済み): + +- 88% は「プラグイン処理時間のうち guard が占めた割合」であり、ビルド総時間の 88% ではない +- sveltekit-guard = サーバー専用モジュール(`$lib/server`、`$env/*/private` 等)のクライアント混入を全モジュール検査で防ぐ門番プラグイン +- hook filters = Rolldown 発の仕組み(Vite 6.3+ / Rollup 4.38+)。フックに静的パターン(`filter: { id: /\.svelte$/ }` 等)を宣言すると、Rust 側で照合して不一致モジュールは JS フック呼び出し自体をスキップできる。Vite 8 はコアが Rust(Rolldown)のため、filter なしだと全モジュール × 複数フック × 2 パス(クライアント + SSR)で Rust→JS 境界越えのコストが発生する — これが 88% の正体 +- kit 3.0 に更新するだけで(本プロジェクト側の変更なしに)解消される見込み。ただしプラグイン時間がビルド総時間に占める割合は未計測のため、短縮幅は見積もれない(Rust 側のバンドル処理は filter では縮まない) + +### 5. vitest デフォルト設定(pool: forks、isolate: true)【設定】 + +ファイルごとにプロセスを分離し全依存を再 import するため、3 の virtiofs コストと掛け算になり import 130s / transform 78s に増幅されている。 + +## 対策 TODO + +[todo.md](./todo.md) に分離。upstream 待ち(SvelteKit 3.0 / kit#13756)は対応不可のため TODO 対象外。 + +## 期待効果の見積もり + +| 対策 | 根拠 | +| ----------------------- | ---------------------------------------------------------------------------------------------- | +| jsdom → node デフォルト | **実測済み: wall clock 2m19s → 25.4s(5.5 倍)**。メモリ消費減によるスワップ緩和も同時に得られる | +| named volume 化 | node 環境実験後も残る import 累積 56s の短縮余地(追加効果は A/B 未計測) | +| スワップ解消 | 今回の実測 5 倍悪化ぶんが戻る(30 秒台の平常値に安定) | +| kit 3.0(将来) | vite build のプラグイン時間 88% を占める guard のオーバーヘッド解消 | + +テスト本体は累積 8.7s(node 環境実測)と健全なため、上記がすべて入れば vitest は実行時間の大半がテスト本体という妥当な状態(wall clock 10 秒前後)に近づく見込み。 + +## 追記: レビュー Q&A(2026-07-11) + +批判的レビューで判明した訂正と、質疑の要約(訂正は本文に反映済み)。 + +**Q. sys 3m10s は virtiofs の積算?(本文の「最大要因」は正しい?)** +訂正。node 環境実験では virtiofs 同一のまま sys が 3m10s → 23s に激減した。sys の大半は jsdom × 80 回の構築とメモリ圧(ページフォルト)由来。**優先順位は「jsdom が最大要因(実測 5.5 倍)、named volume 化は二番手(追加効果は A/B 未計測)」に訂正**する。 + +**Q. node_modules マウントは「ローカルと VM の往復」で遅い?** +ファイルのコピー往復ではなく、実体が mac 側にあるため open/stat/read が 1 回ずつ VM 境界(virtiofs)を越えるオーバーヘッドの積算。Node のモジュール解決は小さいアクセスを数千回行うため累積する。 + +**Q. メンバーに Docker Desktop 利用者がいるが named volume 化して大丈夫?** +問題ない(記法は両ツール共通)。根拠の確度は分けて記す: + +- OrbStack: **公式明文あり**。volume は「Linux 側に直接格納されバインドマウントよりはるかに高速」「For best performance, use volumes when possible」([公式 docs](https://docs.orbstack.dev/docker/file-sharing)) +- Docker Desktop: **格納場所からの推論**。公式 docs は「volume は `/var/lib/docker/volumes/` に格納」「コンテナ・イメージは VM のディスクイメージ内」とまでは明記するが、bind mount との性能比較の明文は確認できず。構造上ファイル共有を経由しないため速いはず、という推論(コミュニティでは定説) +- pnpm: ストアを別ファイルシステムに**明示固定した場合のみ**ハードリンク → コピーにフォールバック([公式 FAQ](https://pnpm.io/faq))。**デフォルト設定ではファイルシステムごとにストアを自動作成するためリンクは維持される**。named volume 化で特別な設定は不要 + +**Q. VS Code ターミナルの pnpm はホスト側で動いている?** +勘違い。vscode-server(linux-arm64)がコンテナ内に常駐する Dev Containers 構成のため、**統合ターミナルはコンテナ内**(`uname -s` が `Linux` なら確定)。`~/.gitconfig` と `~/.ssh` がマウントされているため git がホスト同然に動き、区別がつきにくいだけ。したがって named volume 化しても VS Code ターミナルからの commit(lefthook)は壊れない。ホスト側 commit への影響は当初懸念より小さい(次項の Q&A 参照)。 + +**Q. メモリは単純に不足している?** +「ベースラインでほぼ満杯 + テストのピーク」の複合。ベースライン実測: VS Code Server 群 約 2.6GB + `vite dev` 648MB(PID 実測で正体確定、7/10 から常駐)+ claude 等。そこに jsdom × 8 ワーカーのピークが乗ってスワップ突入。jsdom 廃止はピーク削減を兼ねる。dev サーバーの再起動でベースラインも約 650MB 空く。 + +**Q. jsdom 環境 = コンポーネントテスト用の環境を単体テストでも実行している?** +その通り。jsdom は `document`/`window` を再実装した擬似ブラウザで、本来 `@testing-library/svelte` の render に必要なもの。現状コンポーネントテスト 0 件なのに全 80 ファイルで毎回起動していた。 + +**方針の再訂正(jsdom アノテーション)**: 「node 環境でパスした = 同じものをテストしている」ではない。`typeof window` ガードがあるとブラウザ側分岐(localStorage 永続化など)を通らずにパスし、カバレッジが静かに落ちる。コストはほぼゼロのため、**DOM 参照のある 3 ファイルには `// @vitest-environment jsdom` を付ける当初方式を維持**する。 + +**Q. lint が落ちた PR を送ってくるメンバーがいるのはこれが原因?** +その可能性が高い。`.git/hooks/pre-commit`(lefthook 生成スクリプト)の実物を確認した結果: + +- フォールバックの全候補で lefthook の実体が見つからない場合、`echo "Can't find lefthook in PATH"` を出すだけで **exit 0(commit は素通り)**。GUI クライアントではこの表示すら見えない +- フックにはコンテナ内で生成された `/usr/src/app/node_modules/.pnpm/lefthook-linux-arm64@2.1.10/...` というハードコードパスがあり、mac には存在しない +- pnpm install はコンテナ(Linux)内で実行されるため、node_modules には **linux-arm64 バイナリしか入っておらず**、ホスト側から node_modules が見えている現状でも mac で実行できる lefthook はない + +つまり **mac ホスト側からの commit(Terminal / GUI クライアント / Claude Desktop + ローカル MCP 等)は現状でも lefthook が静かにスキップされ、lint なしで commit される**。クラウド経由(Claude Code Web 版、GitHub 直接編集)もフック自体が clone に含まれないため元から未実行。lint の最終防衛線は PR の CI のみ。 + +対処の選択肢: (a) 「commit はコンテナ内(VS Code 統合ターミナル / Source Control)から」を周知、(b) CI の lint を required check 化、(c) フックに頼らず CI 側で自動修正。現実的には (a)+(b)。 + +**Q. Windows(Docker/WSL)のメンバーは lefthook-windows 版がなくてスルーされる?** +バイナリ不在は事実(pnpm install は実行環境のプラットフォーム分しか入れないため、各自の node_modules に windows 版はない)。ただし挙動は経路で異なる(lefthook の JS ラッパー実コード確認済み): + +- node_modules が見える経路(C:\ 側から commit 等)では `lefthook/bin/index.js` が MODULE_NOT_FOUND で exit 1 → **静かなスルーではなく、うるさくブロック**される +- WSL 内 clone + WSL から commit なら linux-x64 バイナリがそのまま動く(lint 実行には WSL に pnpm/node が必要) +- **無音でスルーされるのは「`.git/hooks` にフック自体が未インストールの clone」か「`--no-verify` / `LEFTHOOK=0` の常用」のみ**。lint 落ち PR が繰り返し来る観測に合うのはこの 2 つ。確認は本人の `ls .git/hooks` と commit 手順のヒアリング + +**Q. CI は lint 失敗で止まる設定済み。それで十分?** +最終防衛線としては十分。確認するなら「CI が落ちる」と「落ちたらマージ不可」は別設定という点のみ(branch protection / ruleset で lint を required status check に指定していなければ赤い PR もマージ可能)。残る問題は品質ではなくノイズ(レビュー往復・CI 無駄実行)なので、本人の環境是正か、コスパが合わなければ放置も合理的。 diff --git a/docs/dev-notes/2026-07-11/build-performance/todo.md b/docs/dev-notes/2026-07-11/build-performance/todo.md new file mode 100644 index 000000000..a9f3a9259 --- /dev/null +++ b/docs/dev-notes/2026-07-11/build-performance/todo.md @@ -0,0 +1,68 @@ +# pnpm build / vitest 低速問題: 対策 TODO + +調査: [survey.md](./survey.md) +実施記録: [下部の「実施記録(2026-07-13)」](#実施記録2026-07-13) + +## vitest 設定(効果: 大・実測済み) + +- [x] デフォルト環境を `node` に変更 + DOM 参照のある 3 ファイルの先頭に `// @vitest-environment jsdom` を追加(実測で wall clock 2m19s → 25.4s、environment 累積 687s → 0.12s)。3 ファイルは node 環境でもパスするが、ブラウザ側分岐のカバレッジを保つためアノテーションを付ける。将来のコンポーネントテストも同方式 → **2026-07-13 実施。平常負荷でも wall clock 33.3s → 11.3s、environment 累積 161s → 5.7s。全 80 ファイル / 2745 パスで不変** +- [ ] `isolate: false` の実験: テスト本体 21.9s に対し import 130s のため、分離をやめれば大幅短縮の可能性。ただしグローバル状態(モジュールスコープの可変状態、シングルトン store)に依存するテストが壊れるリスクがあるため、全パス確認とセットで実施 → **見送り(2026-07-13)。まず環境変更の効果を確定させ、global 状態依存テストの破損リスクを一度に混ぜない方針** + +## 環境(効果: 中〜大、追加効果は未実測) + +- [x] node_modules を named volume 化: compose.yaml で `node_modules:/usr/src/app/node_modules`(named volume)に変更。virtiofs 越えのファイルアクセスを解消。OrbStack / Docker Desktop 共通の記法でどちらにも有効 → **2026-07-13 compose.yaml 設定変更済み。効果反映には後述のコンテナ再構築が必要(追加効果は A/B 未計測)** +- [ ] メモリの見直し: OrbStack のメモリ割当と常駐プロセスの整理でスワップ常用を解消。即効性があるのは長期常駐の `vite dev` の再起動(約 650MB)。`free -h` でスワップ使用量ゼロが目標 → **今回対象外(ホスト設定・運用作業でリポジトリ変更ではない)。計測時点でスワップは既にほぼゼロ(61Mi)** + +## その他(効果: 小) + +- [x] `@testing-library/svelte` が import 0 件(未使用)。削除するかはユーザー判断(dead dependency の三条件確認の上で) → **2026-07-13 削除。リポジトリ全体で参照は package.json のみ(import 0・jest-dom マッチャ 0)を確認し、`@testing-library/jest-dom` も未使用だったため併せて削除(計 20 パッケージ prune)。※ `@types/jest` も未使用を確認したが今回はスコープ外で保留** +- [ ] 高負荷時のみ失敗する flaky テスト 1 件の特定と修正(通常負荷では再現しない) → **未着手。計測時点でスワップほぼゼロのため再現せず(全 3 回の実行で全パス)。特定は高負荷再現が前提** +- [ ] `prisma generate` 5.2s は build スクリプトに毎回含まれる。スキーマ変更時のみ実行する運用にすれば毎ビルド 5 秒短縮(ただし生成漏れ事故とのトレードオフ) → **今回対象外(ユーザー未選択)** + +## 計測(各対策の実施後) + +- [x] `time pnpm exec vitest run` で wall clock と Duration 内訳(transform / import / environment / tests)を記録 → 下表 +- [ ] `time pnpm exec vite build` で wall clock を記録 → **未実施(build は今回の変更対象外。kit 3.0 待ちの guard プラグインが支配的なため本セッションでは測定省略)** +- [x] `free -h` でスワップ使用量を記録 → 計測前後ともスワップ 61Mi(ほぼゼロ) +- [x] 対策前のベースライン(survey.md の実測値)と比較し、改善幅を確認 → 下表 + +## 実施記録(2026-07-13) + +### vitest 環境変更の実測(平常負荷・スワップほぼゼロ) + +| 指標 | Before (jsdom 全ファイル) | After (node + 3 ファイルのみ jsdom) | +| ------------------- | ----------------------------- | ----------------------------------- | +| wall clock (real) | 33.3s | **11.3s**(約 2.9 倍) | +| Duration | 29.99s | 8.87s | +| environment(累積) | 161.06s | **5.71s** | +| import(累積) | 14.85s | 21.66s ※ | +| transform(累積) | 8.50s | 13.09s ※ | +| tests(累積) | 2.71s | 2.98s | +| sys | 1m8.0s | 12.3s | +| 結果 | 80 files / 2745 pass / 1 skip | 同左(不変) | + +※ import / transform の「累積値」は環境間で単純比較できない。jsdom を外すとワーカーが早く空くぶん各ワーカーの担当ファイルが増え、per-worker 累積が増えて見えることがある。判断は wall clock(33.3s → 11.3s)と environment(161s → 5.7s)で行う。 + +補足: survey.md のベースライン(wall 2m19s 等)はスワップ 8GiB 使用の高負荷時。本記録は平常負荷での再計測で、改善は高負荷特有ではなく構造的であることを確認した。 + +### compose.yaml named volume 化: 反映に必要な手順(未実行) + +named volume は初回マウント時に**空**で作成されるため、現行の bind マウント node_modules は引き継がれない。反映にはコンテナ内での再インストールが必須: + +```bash +docker compose down +docker compose up -d +docker compose exec web pnpm install # 空の named volume に node_modules を再構築 +``` + +- 効果(import 短縮)の A/B は再構築後に `time pnpm exec vitest run` で要計測 +- チーム共有ファイルの変更。Docker Desktop 利用者にも記法は有効(survey.md の Q&A 参照) +- 本セッションでは稼働中環境を壊さないため `docker compose` は実行していない + +### 後続候補(未実施) + +- `isolate: false` 実験(global 状態依存テストの全パス確認とセット) +- flaky テスト 1 件の特定(高負荷再現が前提) +- `prisma generate` の build 分離(stale client トレードオフ) +- `@types/jest` 削除(未使用を確認済み・スコープ外で保留) +- vite build の測定(kit 3.0 で guard プラグイン解消見込み) diff --git a/package.json b/package.json index b7dd940d7..799d6cce4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "@sveltejs/vite-plugin-svelte": "7.2.0", "@tailwindcss/forms": "0.5.11", "@tailwindcss/vite": "4.3.2", - "@testing-library/jest-dom": "6.9.1", "@types/gtag.js": "0.0.20", "@types/jsdom": "28.0.3", "@typescript-eslint/eslint-plugin": "8.63.0", @@ -77,8 +76,6 @@ "@lucia-auth/adapter-prisma": "3.0.2", "@lucide/svelte": "1.24.0", "@prisma/client": "5.22.0", - "@testing-library/svelte": "5.4.2", - "@types/jest": "30.0.0", "@types/node": "25.9.5", "debug": "4.4.3", "lucia": "2.7.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cea2b7fd..0ea66e6b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,12 +42,6 @@ importers: '@prisma/client': specifier: 5.22.0 version: 5.22.0(prisma@5.22.0) - '@testing-library/svelte': - specifier: 5.4.2 - version: 5.4.2(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5))(vitest@4.1.10) - '@types/jest': - specifier: 30.0.0 - version: 30.0.0 '@types/node': specifier: 25.9.5 version: 25.9.5 @@ -103,9 +97,6 @@ importers: '@tailwindcss/vite': specifier: 4.3.2 version: 4.3.2(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)) - '@testing-library/jest-dom': - specifier: 6.9.1 - version: 6.9.1 '@types/gtag.js': specifier: 0.0.20 version: 0.0.20 @@ -214,9 +205,6 @@ importers: packages: - '@adobe/css-tools@4.4.4': - resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - '@ark/schema@0.56.1': resolution: {integrity: sha512-1Cf2g9nKD8K/3JGRu+gCCfYw5d4qR8YLLjDs5W5kpmaButCYWAPFUJqSXyBATPjglzCd4tIkp398iPYVs8MjRA==} @@ -238,10 +226,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -763,30 +747,6 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@jest/diff-sequences@30.0.1': - resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/expect-utils@30.2.0': - resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/get-type@30.1.0': - resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/pattern@30.0.1': - resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/schemas@30.0.5': - resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/types@30.2.0': - resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1501,9 +1461,6 @@ packages: '@sinclair/typebox@0.25.24': resolution: {integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==} - '@sinclair/typebox@0.34.41': - resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1644,33 +1601,6 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - - '@testing-library/svelte-core@1.1.3': - resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} - engines: {node: '>=16'} - peerDependencies: - svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 - - '@testing-library/svelte@5.4.2': - resolution: {integrity: sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==} - engines: {node: '>= 10'} - peerDependencies: - svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 - vite: '*' - vitest: '*' - peerDependenciesMeta: - vite: - optional: true - vitest: - optional: true - '@tootallnate/once@2.0.1': resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} @@ -1684,9 +1614,6 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1705,18 +1632,6 @@ packages: '@types/gtag.js@0.0.20': resolution: {integrity: sha512-wwAbk3SA2QeU67unN7zPxjEHmPmlXwZXZvQEpbEUQuMCRGgKyE1m6XDuTUA9b6pCGb/GqJmdfMOY5LuDjJSbbg==} - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@30.0.0': - resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} - '@types/jsdom@28.0.3': resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} @@ -1732,9 +1647,6 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -1744,12 +1656,6 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.34': - resolution: {integrity: sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==} - '@typeschema/class-validator@0.3.0': resolution: {integrity: sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==} peerDependencies: @@ -2030,18 +1936,6 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - any-base@1.1.0: resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} @@ -2060,17 +1954,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - aria-query@5.3.1: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - arkregex@0.0.7: resolution: {integrity: sha512-O/Ltrn9EUSn3ui0KVzfyrWGDUsHlzKxDVBtpQxL/6JmLRMAZAebfSNf/A/J5Ny5S6QIwrXX+RfXsu888HMs35A==} @@ -2180,10 +2067,6 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - chokidar@4.0.0: resolution: {integrity: sha512-mxIojEAQcuEvT/lyXq+jf/3cO/KoA6z4CeNDGGevTybECPOMFCnQy3OPahluUkbqgPNGw5Bi78UC7Po6Lhy+NA==} engines: {node: '>= 14.16.0'} @@ -2196,10 +2079,6 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - ci-info@4.3.1: - resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} - engines: {node: '>=8'} - citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -2216,13 +2095,6 @@ packages: code-block-writer@10.1.1: resolution: {integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2259,9 +2131,6 @@ packages: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2331,10 +2200,6 @@ packages: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -2348,12 +2213,6 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -2403,10 +2262,6 @@ packages: engines: {node: '>=18'} hasBin: true - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2533,10 +2388,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - expect@30.2.0: - resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -2752,10 +2603,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2823,30 +2670,6 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jest-diff@30.2.0: - resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-matcher-utils@30.2.0: - resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-message-util@30.2.0: - resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-mock@30.2.0: - resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-regex-util@30.0.1: - resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-util@30.2.0: - resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2863,9 +2686,6 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true @@ -3091,10 +2911,6 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3139,10 +2955,6 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - mini-svg-data-uri@1.4.4: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true @@ -3512,14 +3324,6 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - - pretty-format@30.2.0: - resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - pretty-ms@7.0.1: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} @@ -3577,20 +3381,10 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -3714,10 +3508,6 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -3747,10 +3537,6 @@ packages: engines: {node: '>=20.16.0'} hasBin: true - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3781,10 +3567,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4277,8 +4059,6 @@ packages: snapshots: - '@adobe/css-tools@4.4.4': {} - '@ark/schema@0.56.1': dependencies: '@ark/util': 0.56.1 @@ -4307,12 +4087,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -4321,7 +4095,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.2': + optional: true '@babel/types@7.29.7': dependencies: @@ -4677,33 +4452,6 @@ snapshots: dependencies: minipass: 7.1.3 - '@jest/diff-sequences@30.0.1': {} - - '@jest/expect-utils@30.2.0': - dependencies: - '@jest/get-type': 30.1.0 - - '@jest/get-type@30.1.0': {} - - '@jest/pattern@30.0.1': - dependencies: - '@types/node': 25.9.5 - jest-regex-util: 30.0.1 - - '@jest/schemas@30.0.5': - dependencies: - '@sinclair/typebox': 0.34.41 - - '@jest/types@30.2.0': - dependencies: - '@jest/pattern': 30.0.1 - '@jest/schemas': 30.0.5 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 25.9.5 - '@types/yargs': 17.0.34 - chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5212,8 +4960,6 @@ snapshots: '@sinclair/typebox@0.25.24': {} - '@sinclair/typebox@0.34.41': {} - '@standard-schema/spec@1.1.0': {} '@sveltejs/acorn-typescript@1.0.10(acorn@8.17.0)': @@ -5334,39 +5080,6 @@ snapshots: tailwindcss: 4.3.2 vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5) - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.29.2 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/jest-dom@6.9.1': - dependencies: - '@adobe/css-tools': 4.4.4 - aria-query: 5.3.2 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 - redent: 3.0.0 - - '@testing-library/svelte-core@1.1.3(svelte@5.56.4(@typescript-eslint/types@8.63.0))': - dependencies: - svelte: 5.56.4(@typescript-eslint/types@8.63.0) - - '@testing-library/svelte@5.4.2(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5))(vitest@4.1.10)': - dependencies: - '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.1.3(svelte@5.56.4(@typescript-eslint/types@8.63.0)) - svelte: 5.56.4(@typescript-eslint/types@8.63.0) - optionalDependencies: - vite: 8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5) - vitest: 4.1.10(@edge-runtime/vm@3.2.0)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.5)) - '@tootallnate/once@2.0.1': {} '@tootallnate/quickjs-emscripten@0.23.0': {} @@ -5383,8 +5096,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/aria-query@5.0.4': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -5400,21 +5111,6 @@ snapshots: '@types/gtag.js@0.0.20': {} - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest@30.0.0': - dependencies: - expect: 30.2.0 - pretty-format: 30.2.0 - '@types/jsdom@28.0.3': dependencies: '@types/node': 25.9.5 @@ -5434,8 +5130,6 @@ snapshots: '@types/resolve@1.20.2': {} - '@types/stack-utils@2.0.3': {} - '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': {} @@ -5443,12 +5137,6 @@ snapshots: '@types/validator@13.15.10': optional: true - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.34': - dependencies: - '@types/yargs-parser': 21.0.3 - '@typeschema/class-validator@0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.4)': dependencies: '@typeschema/core': 0.14.0(@types/json-schema@7.0.15) @@ -6010,14 +5698,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - any-base@1.1.0: {} any-promise@1.3.0: {} @@ -6030,14 +5710,8 @@ snapshots: argparse@2.0.1: {} - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - aria-query@5.3.1: {} - aria-query@5.3.2: {} - arkregex@0.0.7: dependencies: '@ark/util': 0.56.1 @@ -6133,11 +5807,6 @@ snapshots: chai@6.2.2: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - chokidar@4.0.0: dependencies: readdirp: 4.1.2 @@ -6148,8 +5817,6 @@ snapshots: chownr@3.0.0: {} - ci-info@4.3.1: {} - citty@0.1.6: dependencies: consola: 3.4.2 @@ -6167,12 +5834,6 @@ snapshots: code-block-writer@10.1.1: {} - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - commander@2.20.3: {} concat-map@0.0.1: {} @@ -6200,8 +5861,6 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 - css.escape@1.5.1: {} - cssesc@3.0.0: {} cssfilter@0.0.10: {} @@ -6248,8 +5907,6 @@ snapshots: depd@1.1.2: {} - dequal@2.0.3: {} - destr@2.0.5: {} detect-libc@2.1.2: {} @@ -6259,10 +5916,6 @@ snapshots: dlv@1.1.3: optional: true - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - dotenv@16.6.1: {} edge-runtime@2.5.9: @@ -6363,8 +6016,6 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 - escape-string-regexp@2.0.0: {} - escape-string-regexp@4.0.0: {} escodegen@2.1.0: @@ -6531,15 +6182,6 @@ snapshots: expect-type@1.3.0: {} - expect@30.2.0: - dependencies: - '@jest/expect-utils': 30.2.0 - '@jest/get-type': 30.1.0 - jest-matcher-utils: 30.2.0 - jest-message-util: 30.2.0 - jest-mock: 30.2.0 - jest-util: 30.2.0 - exsolve@1.0.8: {} fast-check@3.23.2: @@ -6774,8 +6416,6 @@ snapshots: imurmurhash@0.1.4: {} - indent-string@4.0.0: {} - inherits@2.0.4: {} ip-address@10.2.0: {} @@ -6827,49 +6467,6 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jest-diff@30.2.0: - dependencies: - '@jest/diff-sequences': 30.0.1 - '@jest/get-type': 30.1.0 - chalk: 4.1.2 - pretty-format: 30.2.0 - - jest-matcher-utils@30.2.0: - dependencies: - '@jest/get-type': 30.1.0 - chalk: 4.1.2 - jest-diff: 30.2.0 - pretty-format: 30.2.0 - - jest-message-util@30.2.0: - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 30.2.0 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 30.2.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@30.2.0: - dependencies: - '@jest/types': 30.2.0 - '@types/node': 25.9.5 - jest-util: 30.2.0 - - jest-regex-util@30.0.1: {} - - jest-util@30.2.0: - dependencies: - '@jest/types': 30.2.0 - '@types/node': 25.9.5 - chalk: 4.1.2 - ci-info: 4.3.1 - graceful-fs: 4.2.11 - picomatch: 4.0.4 - jiti@2.7.0: {} joi@17.13.4: @@ -6887,8 +6484,6 @@ snapshots: js-tokens@10.0.0: {} - js-tokens@4.0.0: {} - js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -7080,8 +6675,6 @@ snapshots: luxon@3.7.2: {} - lz-string@1.5.0: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7123,8 +6716,6 @@ snapshots: mimic-fn@2.1.0: {} - min-indent@1.0.1: {} - mini-svg-data-uri@1.4.4: {} minimatch@10.2.5: @@ -7418,18 +7009,6 @@ snapshots: prettier@3.9.5: {} - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - - pretty-format@30.2.0: - dependencies: - '@jest/schemas': 30.0.5 - ansi-styles: 5.2.0 - react-is: 18.3.1 - pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 @@ -7496,17 +7075,8 @@ snapshots: defu: 6.1.7 destr: 2.0.5 - react-is@17.0.2: {} - - react-is@18.3.1: {} - readdirp@4.1.2: {} - redent@3.0.0: - dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 - require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -7647,8 +7217,6 @@ snapshots: sisteransi@1.0.5: {} - slash@3.0.0: {} - smart-buffer@4.2.0: {} smol-toml@1.7.0: {} @@ -7673,10 +7241,6 @@ snapshots: srvx@0.11.16: {} - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} stat-mode@0.3.0: {} @@ -7708,10 +7272,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - strip-json-comments@3.1.1: {} super-sitemap@1.0.12(svelte@5.56.4(@typescript-eslint/types@8.63.0)): diff --git a/src/features/tasks/stores/active_contest_type.svelte.test.ts b/src/features/tasks/stores/active_contest_type.svelte.test.ts index c1dc22941..34081286e 100644 --- a/src/features/tasks/stores/active_contest_type.svelte.test.ts +++ b/src/features/tasks/stores/active_contest_type.svelte.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import type { ContestTableProviderGroups } from '$features/tasks/utils/contest-table/contest_table_provider'; diff --git a/src/features/workbooks/stores/replenishment_workbook.test.ts b/src/features/workbooks/stores/replenishment_workbook.test.ts index 8dcbff159..d3ea32bf4 100644 --- a/src/features/workbooks/stores/replenishment_workbook.test.ts +++ b/src/features/workbooks/stores/replenishment_workbook.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { expect, test, vi, type Mock } from 'vitest'; import { replenishmentWorkBooksStore } from '$features/workbooks/stores/replenishment_workbook.svelte'; diff --git a/src/test/lib/stores/active_problem_list_tab.svelte.test.ts b/src/test/lib/stores/active_problem_list_tab.svelte.test.ts index 6af9f0f4a..674fcb466 100644 --- a/src/test/lib/stores/active_problem_list_tab.svelte.test.ts +++ b/src/test/lib/stores/active_problem_list_tab.svelte.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { describe, test, expect, vi, beforeEach } from 'vitest'; import { diff --git a/vite.config.ts b/vite.config.ts index 65227f17c..f1c220a99 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,7 +22,8 @@ export default defineConfig({ 'dist/**', 'node_modules/**', ], - environment: 'jsdom', + // Default to node; DOM-dependent tests opt in via `// @vitest-environment jsdom`. + environment: 'node', globals: true, coverage: { provider: 'v8', From 87cca4671a418b11a6e684872dcb18c8393f0d7e Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 13 Jul 2026 11:21:41 +0000 Subject: [PATCH 2/6] revert(compose): use bind mount for node_modules instead of named volume Named volume's initial mount is an empty root-owned directory, which causes EACCES when the node user runs pnpm install under the `.:/usr/src/app` bind mount. The layering of bind mount + named volume doesn't allow Docker image initialization to populate it. Co-Authored-By: Claude Sonnet 5 --- compose.yaml | 5 +---- docs/dev-notes/2026-07-11/build-performance/todo.md | 5 ++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/compose.yaml b/compose.yaml index a363729bb..28e479c63 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,9 +7,7 @@ services: tty: true volumes: - .:/usr/src/app - # Named volume keeps node_modules on the Linux VM disk instead of crossing the - # virtiofs host boundary on every open/stat/read (`:cached` is a no-op on OrbStack). - - node_modules:/usr/src/app/node_modules + - ./node_modules:/usr/src/app/node_modules environment: - NODE_ENV=development - DATABASE_URL=postgresql://db_user:db_password@db:5432/test_db?pgbouncer=true&connection_limit=10&connect_timeout=60&statement_timeout=60000 # Note: Local server cannot start if port is set to db:6543. @@ -33,6 +31,5 @@ services: - '6543:5432' volumes: - node_modules: postgres-data: driver: local diff --git a/docs/dev-notes/2026-07-11/build-performance/todo.md b/docs/dev-notes/2026-07-11/build-performance/todo.md index a9f3a9259..09e90d2a2 100644 --- a/docs/dev-notes/2026-07-11/build-performance/todo.md +++ b/docs/dev-notes/2026-07-11/build-performance/todo.md @@ -10,12 +10,12 @@ ## 環境(効果: 中〜大、追加効果は未実測) -- [x] node_modules を named volume 化: compose.yaml で `node_modules:/usr/src/app/node_modules`(named volume)に変更。virtiofs 越えのファイルアクセスを解消。OrbStack / Docker Desktop 共通の記法でどちらにも有効 → **2026-07-13 compose.yaml 設定変更済み。効果反映には後述のコンテナ再構築が必要(追加効果は A/B 未計測)** +- [ ] ~~node_modules を named volume 化~~ → **2026-07-13 取り消し。named volume の初回マウントが root:root 所有の空ディレクトリになり、node ユーザーが pnpm install できず EACCES。原因: `.:/usr/src/app` bind mount と `node_modules` named volume の重ね掛けでは Docker のイメージ初期化が機能しない。複雑さのコストが効果を上回るため bind mount に戻した。** - [ ] メモリの見直し: OrbStack のメモリ割当と常駐プロセスの整理でスワップ常用を解消。即効性があるのは長期常駐の `vite dev` の再起動(約 650MB)。`free -h` でスワップ使用量ゼロが目標 → **今回対象外(ホスト設定・運用作業でリポジトリ変更ではない)。計測時点でスワップは既にほぼゼロ(61Mi)** ## その他(効果: 小) -- [x] `@testing-library/svelte` が import 0 件(未使用)。削除するかはユーザー判断(dead dependency の三条件確認の上で) → **2026-07-13 削除。リポジトリ全体で参照は package.json のみ(import 0・jest-dom マッチャ 0)を確認し、`@testing-library/jest-dom` も未使用だったため併せて削除(計 20 パッケージ prune)。※ `@types/jest` も未使用を確認したが今回はスコープ外で保留** +- [x] `@testing-library/svelte` が import 0 件(未使用)。削除するかはユーザー判断(dead dependency の三条件確認の上で) → **2026-07-13 削除。リポジトリ全体で参照は package.json のみ(import 0・jest-dom マッチャ 0)を確認し、`@testing-library/jest-dom` も未使用だったため併せて削除(計 20 パッケージ prune)。さらに `@types/jest` も未使用(source 参照 0・tsconfig `types` 非登録・`jest.*` 使用 0)を確認して削除(追加 34 パッケージ prune)。`pnpm check` は 0 errors で不変** - [ ] 高負荷時のみ失敗する flaky テスト 1 件の特定と修正(通常負荷では再現しない) → **未着手。計測時点でスワップほぼゼロのため再現せず(全 3 回の実行で全パス)。特定は高負荷再現が前提** - [ ] `prisma generate` 5.2s は build スクリプトに毎回含まれる。スキーマ変更時のみ実行する運用にすれば毎ビルド 5 秒短縮(ただし生成漏れ事故とのトレードオフ) → **今回対象外(ユーザー未選択)** @@ -64,5 +64,4 @@ docker compose exec web pnpm install # 空の named volume に node_modules - `isolate: false` 実験(global 状態依存テストの全パス確認とセット) - flaky テスト 1 件の特定(高負荷再現が前提) - `prisma generate` の build 分離(stale client トレードオフ) -- `@types/jest` 削除(未使用を確認済み・スコープ外で保留) - vite build の測定(kit 3.0 で guard プラグイン解消見込み) From 9dcda0301fff2824a9d13536e2c8c9fab817b4ab Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 13 Jul 2026 11:23:16 +0000 Subject: [PATCH 3/6] chore(docs): remove completed build-performance dev-notes Investigation and TODO items are done; findings already folded into prior commits (compose.yaml revert, dependency prune). Co-Authored-By: Claude Sonnet 5 --- .../2026-07-11/build-performance/survey.md | 171 ------------------ .../2026-07-11/build-performance/todo.md | 67 ------- 2 files changed, 238 deletions(-) delete mode 100644 docs/dev-notes/2026-07-11/build-performance/survey.md delete mode 100644 docs/dev-notes/2026-07-11/build-performance/todo.md diff --git a/docs/dev-notes/2026-07-11/build-performance/survey.md b/docs/dev-notes/2026-07-11/build-performance/survey.md deleted file mode 100644 index a886240da..000000000 --- a/docs/dev-notes/2026-07-11/build-performance/survey.md +++ /dev/null @@ -1,171 +0,0 @@ -# pnpm build / vitest 低速問題の調査 - -調査日: 2026-07-11 - -## 概要 - -`pnpm build` が約 30 秒、vitest が約 2,700 テストで約 30 秒かかる問題のボトルネック調査。環境は Mac Air M3 + OrbStack の Node コンテナ(Node v24.13.0、8 コア、RAM 11GiB)。コードベースは約 4 万行 / 350 ファイル。 - -**結論: コードベース規模の問題ではなく、(1) vitest の全ファイル jsdom 環境(最大要因・実測 5.5 倍)、(2) メモリ枯渇によるスワップ、(3) node_modules の virtiofs バインドマウント、(4) SvelteKit 2.x + Vite 8(rolldown)の guard プラグイン非効率、の複合。** 設定は「デフォルトのまま」で正しいが、デフォルトがこの環境・テスト構成に合っていない。 - -## 計測条件の注記 - -本調査の計測時、コンテナは**スワップ 8GiB 使用の高負荷状態**だった(RAM 11GiB ほぼ満杯)。そのため絶対値は平常時(約 30 秒)の約 5 倍に悪化している。**絶対値は割り引いて読む必要があるが、内訳の比率は構造的ボトルネックをそのまま示している。** - -## 実測データ - -再現コマンドと結果: - -```bash -time pnpm exec prisma generate # real 5.2s (sys 2.2s) -time pnpm exec vite build # real 2m27s (user 1m46s, sys 37s) -time pnpm exec vitest run # real 2m19s (user 5m45s, sys 3m10s) -``` - -### vite build の内訳 - -- クライアントビルド: 49.6s(1,140 モジュール変換) -- SSR ビルド: 1m46s -- PLUGIN_TIMINGS 警告: **`vite-plugin-sveltekit-guard` がプラグイン時間の 88%**、`vite-plugin-sveltekit-virtual-modules` 6% - -### vitest の内訳(Duration 139.3s) - -注意: 以下の項目はいずれも **wall clock ではなく全ワーカー横断の累積値**。またスワップ圧で膨張した状態の計測値(後述の node 環境実験では同一テストの tests が 8.7s だった = 約 2.5 倍膨張)。 - -| 項目 | 時間 | 備考 | -| ----------------------- | --------------- | ------------------------------------------------------------------------------------------- | -| tests(テスト本体) | 累積 21.9s | 平均 約 3ms/テスト。DB モックや nock を含む構成として普通(ボトルネックではないが速くもない) | -| environment(jsdom 構築) | **累積 687.2s** | 最大の無駄 | -| import(モジュール読込) | 累積 130.8s | jsdom 本体の import + virtiofs + プロセス分離で増幅 | -| transform | 累積 78.0s | 同上 | - -sys 3m10s(カーネル時間)が user 5m45s に対して異常に大きいが、node 環境実験で virtiofs 同一のまま sys 23s に減少したため、大半は jsdom 構築に伴う大量のモジュール読込とメモリ圧(ページフォルト)由来。並列化はすでに効いている(fork プール 8 ワーカー、user 5m45s ÷ real 2m19s ≈ 2.5)。問題は並列の幅ではなく、各ワーカーがファイルごとに jsdom を構築する「重さ」。 - -### 検証実験: jsdom を外すと 5.5 倍速くなる - -設定変更なし・CLI フラグのみ・スワップ 7.7GiB 使用中の同一コンテナで実測: - -```bash -time pnpm exec vitest run --environment=node -# Duration 20.49s (transform 32.6s, import 56.1s, tests 8.7s, environment 0.12s) -# real 0m25.4s — 80 ファイル全パス(2,745 passed / 1 skipped) -``` - -- wall clock **2m19s → 25.4s**(このうち周辺負荷差も一部混在するが、environment 687s → 0.12s と import 半減は構造的な差) -- **DOM を参照するはずの 3 ファイルも node 環境でパス**(`typeof window` ガード等でブラウザ側分岐を通らずにパスしたと推測)。ただし「パスした = 同じ分岐をテストしている」ではないため、この 3 ファイルには `// @vitest-environment jsdom` を付ける方式を維持する(追記 Q&A 参照) -- jsdom × 8 ワーカーのメモリ消費がスワップを悪化させ、スワップが全体をさらに遅くする自己増悪ループも同時に解消される - -### flaky テストの観測 - -初回フル実行で 1 件失敗 → 直後の再実行(`--bail=1`)では 2,745 件全パス。高負荷(スワップ使用中)時のみ失敗するタイミング依存のテストが 1 件存在する。ファイル特定は未実施。 - -## ボトルネック分析(影響度順) - -### 1. vitest: 全 80 テストファイルが jsdom 環境で実行【設定・最大要因】 - -`vite.config.ts` で `environment: 'jsdom'` がグローバル指定されているが、実測で: - -- `@testing-library/svelte` を import するテストファイル: **0 件**(パッケージ自体が未使用) -- DOM API(`window` / `document` / `localStorage`)を参照するテスト: **3 ファイルのみ** - - `src/test/lib/stores/active_problem_list_tab.svelte.test.ts` - - `src/features/tasks/stores/active_contest_type.svelte.test.ts` - - `src/features/workbooks/stores/replenishment_workbook.test.ts` - -残り 77 ファイルは純粋なユニットテストなのに、ファイルごとに jsdom 環境(重い DOM 実装の import + 初期化)を構築している。environment 累積 687s は純粋な無駄。CPU だけでなくメモリのピークも押し上げてスワップ突入(2)を誘発する、二重の悪化要因。node 環境実験で **wall clock 5.5 倍の改善を実測済み**。 - -### 2. メモリ枯渇とスワップ【環境】 - -計測時点で RAM 11GiB ほぼ満杯 + スワップ 8GiB 使用。平常時 30 秒のビルドが本調査で 2 分半かかった直接原因。スワップに乗った状態の Node プロセスは数倍遅くなる。 - -常駐の内訳(実測): VS Code Server 群 約 2.6GB + `vite dev` 648MB(PID 実測で確定、7/10 から常駐)+ claude 等。**テスト開始前からほぼ満杯**のところに jsdom × 8 ワーカーのピークが乗ってスワップに突入する構図。jsdom をやめること自体がメモリ対策にもなる。dev サーバーの再起動でベースラインも約 650MB 空く。 - -### 3. node_modules が virtiofs バインドマウント【環境・二番手(追加効果は未実測)】 - -`compose.yaml`: - -```yaml -volumes: - - .:/usr/src/app:cached - - ./node_modules:/usr/src/app/node_modules:cached -``` - -node_modules の実体が mac ホスト側にあり、コンテナからの open/stat/read が 1 回ずつ virtiofs(VM 境界)を越える。Node のモジュール解決は小さいアクセスを数千回行うため累積する。node 環境実験でも import 累積 56s が残っており短縮余地はあるが、named volume 化による追加効果は A/B 未計測。`:cached` フラグは Docker Desktop 時代の指定で、OrbStack の virtiofs では効果がない。 - -### 4. vite build: sveltekit-guard プラグインが 88%【upstream】 - -SvelteKit 2.x のプラグインは rolldown-vite(Vite 8)の hook filters に未対応で、全モジュールが JS プラグインフックを通過する。[sveltejs/kit#13756](https://github.com/sveltejs/kit/issues/13756)(Adopt rolldown-vite hook filters)で解決済みだが **milestone は 3.0** — kit 2.69.2 では対処不可、SvelteKit 3.0 待ち。 - -補足([公式 docs](https://vite.dev/guide/api-plugin) 確認済み): - -- 88% は「プラグイン処理時間のうち guard が占めた割合」であり、ビルド総時間の 88% ではない -- sveltekit-guard = サーバー専用モジュール(`$lib/server`、`$env/*/private` 等)のクライアント混入を全モジュール検査で防ぐ門番プラグイン -- hook filters = Rolldown 発の仕組み(Vite 6.3+ / Rollup 4.38+)。フックに静的パターン(`filter: { id: /\.svelte$/ }` 等)を宣言すると、Rust 側で照合して不一致モジュールは JS フック呼び出し自体をスキップできる。Vite 8 はコアが Rust(Rolldown)のため、filter なしだと全モジュール × 複数フック × 2 パス(クライアント + SSR)で Rust→JS 境界越えのコストが発生する — これが 88% の正体 -- kit 3.0 に更新するだけで(本プロジェクト側の変更なしに)解消される見込み。ただしプラグイン時間がビルド総時間に占める割合は未計測のため、短縮幅は見積もれない(Rust 側のバンドル処理は filter では縮まない) - -### 5. vitest デフォルト設定(pool: forks、isolate: true)【設定】 - -ファイルごとにプロセスを分離し全依存を再 import するため、3 の virtiofs コストと掛け算になり import 130s / transform 78s に増幅されている。 - -## 対策 TODO - -[todo.md](./todo.md) に分離。upstream 待ち(SvelteKit 3.0 / kit#13756)は対応不可のため TODO 対象外。 - -## 期待効果の見積もり - -| 対策 | 根拠 | -| ----------------------- | ---------------------------------------------------------------------------------------------- | -| jsdom → node デフォルト | **実測済み: wall clock 2m19s → 25.4s(5.5 倍)**。メモリ消費減によるスワップ緩和も同時に得られる | -| named volume 化 | node 環境実験後も残る import 累積 56s の短縮余地(追加効果は A/B 未計測) | -| スワップ解消 | 今回の実測 5 倍悪化ぶんが戻る(30 秒台の平常値に安定) | -| kit 3.0(将来) | vite build のプラグイン時間 88% を占める guard のオーバーヘッド解消 | - -テスト本体は累積 8.7s(node 環境実測)と健全なため、上記がすべて入れば vitest は実行時間の大半がテスト本体という妥当な状態(wall clock 10 秒前後)に近づく見込み。 - -## 追記: レビュー Q&A(2026-07-11) - -批判的レビューで判明した訂正と、質疑の要約(訂正は本文に反映済み)。 - -**Q. sys 3m10s は virtiofs の積算?(本文の「最大要因」は正しい?)** -訂正。node 環境実験では virtiofs 同一のまま sys が 3m10s → 23s に激減した。sys の大半は jsdom × 80 回の構築とメモリ圧(ページフォルト)由来。**優先順位は「jsdom が最大要因(実測 5.5 倍)、named volume 化は二番手(追加効果は A/B 未計測)」に訂正**する。 - -**Q. node_modules マウントは「ローカルと VM の往復」で遅い?** -ファイルのコピー往復ではなく、実体が mac 側にあるため open/stat/read が 1 回ずつ VM 境界(virtiofs)を越えるオーバーヘッドの積算。Node のモジュール解決は小さいアクセスを数千回行うため累積する。 - -**Q. メンバーに Docker Desktop 利用者がいるが named volume 化して大丈夫?** -問題ない(記法は両ツール共通)。根拠の確度は分けて記す: - -- OrbStack: **公式明文あり**。volume は「Linux 側に直接格納されバインドマウントよりはるかに高速」「For best performance, use volumes when possible」([公式 docs](https://docs.orbstack.dev/docker/file-sharing)) -- Docker Desktop: **格納場所からの推論**。公式 docs は「volume は `/var/lib/docker/volumes/` に格納」「コンテナ・イメージは VM のディスクイメージ内」とまでは明記するが、bind mount との性能比較の明文は確認できず。構造上ファイル共有を経由しないため速いはず、という推論(コミュニティでは定説) -- pnpm: ストアを別ファイルシステムに**明示固定した場合のみ**ハードリンク → コピーにフォールバック([公式 FAQ](https://pnpm.io/faq))。**デフォルト設定ではファイルシステムごとにストアを自動作成するためリンクは維持される**。named volume 化で特別な設定は不要 - -**Q. VS Code ターミナルの pnpm はホスト側で動いている?** -勘違い。vscode-server(linux-arm64)がコンテナ内に常駐する Dev Containers 構成のため、**統合ターミナルはコンテナ内**(`uname -s` が `Linux` なら確定)。`~/.gitconfig` と `~/.ssh` がマウントされているため git がホスト同然に動き、区別がつきにくいだけ。したがって named volume 化しても VS Code ターミナルからの commit(lefthook)は壊れない。ホスト側 commit への影響は当初懸念より小さい(次項の Q&A 参照)。 - -**Q. メモリは単純に不足している?** -「ベースラインでほぼ満杯 + テストのピーク」の複合。ベースライン実測: VS Code Server 群 約 2.6GB + `vite dev` 648MB(PID 実測で正体確定、7/10 から常駐)+ claude 等。そこに jsdom × 8 ワーカーのピークが乗ってスワップ突入。jsdom 廃止はピーク削減を兼ねる。dev サーバーの再起動でベースラインも約 650MB 空く。 - -**Q. jsdom 環境 = コンポーネントテスト用の環境を単体テストでも実行している?** -その通り。jsdom は `document`/`window` を再実装した擬似ブラウザで、本来 `@testing-library/svelte` の render に必要なもの。現状コンポーネントテスト 0 件なのに全 80 ファイルで毎回起動していた。 - -**方針の再訂正(jsdom アノテーション)**: 「node 環境でパスした = 同じものをテストしている」ではない。`typeof window` ガードがあるとブラウザ側分岐(localStorage 永続化など)を通らずにパスし、カバレッジが静かに落ちる。コストはほぼゼロのため、**DOM 参照のある 3 ファイルには `// @vitest-environment jsdom` を付ける当初方式を維持**する。 - -**Q. lint が落ちた PR を送ってくるメンバーがいるのはこれが原因?** -その可能性が高い。`.git/hooks/pre-commit`(lefthook 生成スクリプト)の実物を確認した結果: - -- フォールバックの全候補で lefthook の実体が見つからない場合、`echo "Can't find lefthook in PATH"` を出すだけで **exit 0(commit は素通り)**。GUI クライアントではこの表示すら見えない -- フックにはコンテナ内で生成された `/usr/src/app/node_modules/.pnpm/lefthook-linux-arm64@2.1.10/...` というハードコードパスがあり、mac には存在しない -- pnpm install はコンテナ(Linux)内で実行されるため、node_modules には **linux-arm64 バイナリしか入っておらず**、ホスト側から node_modules が見えている現状でも mac で実行できる lefthook はない - -つまり **mac ホスト側からの commit(Terminal / GUI クライアント / Claude Desktop + ローカル MCP 等)は現状でも lefthook が静かにスキップされ、lint なしで commit される**。クラウド経由(Claude Code Web 版、GitHub 直接編集)もフック自体が clone に含まれないため元から未実行。lint の最終防衛線は PR の CI のみ。 - -対処の選択肢: (a) 「commit はコンテナ内(VS Code 統合ターミナル / Source Control)から」を周知、(b) CI の lint を required check 化、(c) フックに頼らず CI 側で自動修正。現実的には (a)+(b)。 - -**Q. Windows(Docker/WSL)のメンバーは lefthook-windows 版がなくてスルーされる?** -バイナリ不在は事実(pnpm install は実行環境のプラットフォーム分しか入れないため、各自の node_modules に windows 版はない)。ただし挙動は経路で異なる(lefthook の JS ラッパー実コード確認済み): - -- node_modules が見える経路(C:\ 側から commit 等)では `lefthook/bin/index.js` が MODULE_NOT_FOUND で exit 1 → **静かなスルーではなく、うるさくブロック**される -- WSL 内 clone + WSL から commit なら linux-x64 バイナリがそのまま動く(lint 実行には WSL に pnpm/node が必要) -- **無音でスルーされるのは「`.git/hooks` にフック自体が未インストールの clone」か「`--no-verify` / `LEFTHOOK=0` の常用」のみ**。lint 落ち PR が繰り返し来る観測に合うのはこの 2 つ。確認は本人の `ls .git/hooks` と commit 手順のヒアリング - -**Q. CI は lint 失敗で止まる設定済み。それで十分?** -最終防衛線としては十分。確認するなら「CI が落ちる」と「落ちたらマージ不可」は別設定という点のみ(branch protection / ruleset で lint を required status check に指定していなければ赤い PR もマージ可能)。残る問題は品質ではなくノイズ(レビュー往復・CI 無駄実行)なので、本人の環境是正か、コスパが合わなければ放置も合理的。 diff --git a/docs/dev-notes/2026-07-11/build-performance/todo.md b/docs/dev-notes/2026-07-11/build-performance/todo.md deleted file mode 100644 index 09e90d2a2..000000000 --- a/docs/dev-notes/2026-07-11/build-performance/todo.md +++ /dev/null @@ -1,67 +0,0 @@ -# pnpm build / vitest 低速問題: 対策 TODO - -調査: [survey.md](./survey.md) -実施記録: [下部の「実施記録(2026-07-13)」](#実施記録2026-07-13) - -## vitest 設定(効果: 大・実測済み) - -- [x] デフォルト環境を `node` に変更 + DOM 参照のある 3 ファイルの先頭に `// @vitest-environment jsdom` を追加(実測で wall clock 2m19s → 25.4s、environment 累積 687s → 0.12s)。3 ファイルは node 環境でもパスするが、ブラウザ側分岐のカバレッジを保つためアノテーションを付ける。将来のコンポーネントテストも同方式 → **2026-07-13 実施。平常負荷でも wall clock 33.3s → 11.3s、environment 累積 161s → 5.7s。全 80 ファイル / 2745 パスで不変** -- [ ] `isolate: false` の実験: テスト本体 21.9s に対し import 130s のため、分離をやめれば大幅短縮の可能性。ただしグローバル状態(モジュールスコープの可変状態、シングルトン store)に依存するテストが壊れるリスクがあるため、全パス確認とセットで実施 → **見送り(2026-07-13)。まず環境変更の効果を確定させ、global 状態依存テストの破損リスクを一度に混ぜない方針** - -## 環境(効果: 中〜大、追加効果は未実測) - -- [ ] ~~node_modules を named volume 化~~ → **2026-07-13 取り消し。named volume の初回マウントが root:root 所有の空ディレクトリになり、node ユーザーが pnpm install できず EACCES。原因: `.:/usr/src/app` bind mount と `node_modules` named volume の重ね掛けでは Docker のイメージ初期化が機能しない。複雑さのコストが効果を上回るため bind mount に戻した。** -- [ ] メモリの見直し: OrbStack のメモリ割当と常駐プロセスの整理でスワップ常用を解消。即効性があるのは長期常駐の `vite dev` の再起動(約 650MB)。`free -h` でスワップ使用量ゼロが目標 → **今回対象外(ホスト設定・運用作業でリポジトリ変更ではない)。計測時点でスワップは既にほぼゼロ(61Mi)** - -## その他(効果: 小) - -- [x] `@testing-library/svelte` が import 0 件(未使用)。削除するかはユーザー判断(dead dependency の三条件確認の上で) → **2026-07-13 削除。リポジトリ全体で参照は package.json のみ(import 0・jest-dom マッチャ 0)を確認し、`@testing-library/jest-dom` も未使用だったため併せて削除(計 20 パッケージ prune)。さらに `@types/jest` も未使用(source 参照 0・tsconfig `types` 非登録・`jest.*` 使用 0)を確認して削除(追加 34 パッケージ prune)。`pnpm check` は 0 errors で不変** -- [ ] 高負荷時のみ失敗する flaky テスト 1 件の特定と修正(通常負荷では再現しない) → **未着手。計測時点でスワップほぼゼロのため再現せず(全 3 回の実行で全パス)。特定は高負荷再現が前提** -- [ ] `prisma generate` 5.2s は build スクリプトに毎回含まれる。スキーマ変更時のみ実行する運用にすれば毎ビルド 5 秒短縮(ただし生成漏れ事故とのトレードオフ) → **今回対象外(ユーザー未選択)** - -## 計測(各対策の実施後) - -- [x] `time pnpm exec vitest run` で wall clock と Duration 内訳(transform / import / environment / tests)を記録 → 下表 -- [ ] `time pnpm exec vite build` で wall clock を記録 → **未実施(build は今回の変更対象外。kit 3.0 待ちの guard プラグインが支配的なため本セッションでは測定省略)** -- [x] `free -h` でスワップ使用量を記録 → 計測前後ともスワップ 61Mi(ほぼゼロ) -- [x] 対策前のベースライン(survey.md の実測値)と比較し、改善幅を確認 → 下表 - -## 実施記録(2026-07-13) - -### vitest 環境変更の実測(平常負荷・スワップほぼゼロ) - -| 指標 | Before (jsdom 全ファイル) | After (node + 3 ファイルのみ jsdom) | -| ------------------- | ----------------------------- | ----------------------------------- | -| wall clock (real) | 33.3s | **11.3s**(約 2.9 倍) | -| Duration | 29.99s | 8.87s | -| environment(累積) | 161.06s | **5.71s** | -| import(累積) | 14.85s | 21.66s ※ | -| transform(累積) | 8.50s | 13.09s ※ | -| tests(累積) | 2.71s | 2.98s | -| sys | 1m8.0s | 12.3s | -| 結果 | 80 files / 2745 pass / 1 skip | 同左(不変) | - -※ import / transform の「累積値」は環境間で単純比較できない。jsdom を外すとワーカーが早く空くぶん各ワーカーの担当ファイルが増え、per-worker 累積が増えて見えることがある。判断は wall clock(33.3s → 11.3s)と environment(161s → 5.7s)で行う。 - -補足: survey.md のベースライン(wall 2m19s 等)はスワップ 8GiB 使用の高負荷時。本記録は平常負荷での再計測で、改善は高負荷特有ではなく構造的であることを確認した。 - -### compose.yaml named volume 化: 反映に必要な手順(未実行) - -named volume は初回マウント時に**空**で作成されるため、現行の bind マウント node_modules は引き継がれない。反映にはコンテナ内での再インストールが必須: - -```bash -docker compose down -docker compose up -d -docker compose exec web pnpm install # 空の named volume に node_modules を再構築 -``` - -- 効果(import 短縮)の A/B は再構築後に `time pnpm exec vitest run` で要計測 -- チーム共有ファイルの変更。Docker Desktop 利用者にも記法は有効(survey.md の Q&A 参照) -- 本セッションでは稼働中環境を壊さないため `docker compose` は実行していない - -### 後続候補(未実施) - -- `isolate: false` 実験(global 状態依存テストの全パス確認とセット) -- flaky テスト 1 件の特定(高負荷再現が前提) -- `prisma generate` の build 分離(stale client トレードオフ) -- vite build の測定(kit 3.0 で guard プラグイン解消見込み) From fbd13492e9f8b9d6bdf2fc957de76c7899cf3727 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 13 Jul 2026 13:14:23 +0000 Subject: [PATCH 4/6] fix(test): stop vi.mock double-registration from clamping browser=false in store tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each store test file registered `$app/environment` twice (top-level `{browser:true}` plus an SSR-describe `{browser:false}`). vi.mock calls are hoisted and the last registration for a module wins, so every test in these files silently ran with browser=false — the localStorage branches in ActiveContestTypeStore, ReplenishmentWorkBooksStore, and ActiveProblemListTabStore never executed, and the "invalid localStorage key" tests were false-positives. Replace the double registration with a single vi.mock using a vi.hoisted flag toggled per describe, and use jsdom's real Storage instead of a vi.stubGlobal mock so assertions can check actual persisted state. Un-skip the replenishment persistence test (its skip comment blamed jsdom, not the real cause). Export ReplenishmentWorkBooksStore so tests can construct fresh instances for the invalid-JSON catch branch. Document the pitfall in testing.md. Co-Authored-By: Claude Sonnet 5 --- .claude/rules/testing.md | 36 +++- .../store-test-browser-mock-fix/plan.md | 164 ++++++++++++++++++ .../stores/active_contest_type.svelte.test.ts | 83 +++++---- .../stores/replenishment_workbook.svelte.ts | 2 +- .../stores/replenishment_workbook.test.ts | 85 +++++---- .../active_problem_list_tab.svelte.test.ts | 63 ++++--- 6 files changed, 338 insertions(+), 95 deletions(-) create mode 100644 docs/dev-notes/2026-07-13/store-test-browser-mock-fix/plan.md diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 0705e9bdb..dad6022ed 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -30,7 +30,41 @@ Route unit tests: `src/routes/**/*.test.ts` is included by `vite.config.ts`. **N ## Test Environment -Default is `node` (set in `vite.config.ts`). Only files touching the real DOM (`window` / `document` / `localStorage`) opt in with a top-of-file `// @vitest-environment jsdom`. **Never set jsdom globally** — most tests are pure units and per-file jsdom construction is ~5.5x slower. `$app/environment`'s `browser` is controllable via `vi.mock`, but keep jsdom on DOM-referencing files so browser-branch coverage isn't silently dropped. +Default is `node` (set in `vite.config.ts`). Only files touching the real DOM (`window` / `document` / `localStorage`) opt in with a top-of-file `// @vitest-environment jsdom`. **Never set jsdom globally** — most tests are pure units and per-file jsdom construction is ~5.5x slower. + +### Toggling `browser` per describe + +**Never register `vi.mock('$app/environment')` twice in one file.** Every `vi.mock` is hoisted above the imports and runs once before any test, so a `{ browser: false }` mock written inside an SSR `describe`/`beforeEach` silently overwrites the `{ browser: true }` one at the top — the whole file ends up pinned to `browser = false`, the localStorage branches never execute, and tests that "verify" them become false-positives while still passing. + +Use **one dynamic mock** driven by a `vi.hoisted` flag, and toggle the flag in `beforeEach`. Default the flag to `false` so singletons constructed at import time stay SSR-safe: + +```typescript +// @vitest-environment jsdom +const browserState = vi.hoisted(() => ({ value: false })); + +vi.mock('$app/environment', () => ({ + get browser() { + return browserState.value; + }, +})); + +describe('MyStore', () => { + beforeEach(() => { + browserState.value = true; + localStorage.clear(); + }); + // … +}); + +describe('MyStore in SSR', () => { + beforeEach(() => { + browserState.value = false; + }); + // … +}); +``` + +In jsdom files, do **not** stub localStorage with `vi.stubGlobal` — use jsdom's real `Storage` and assert on state (`localStorage.getItem(key)`), not on spy calls. Assert SSR by constructing a fresh store with data already in localStorage: it must return the default and leave `getItem(key)` null. ## Unit Testing Patterns diff --git a/docs/dev-notes/2026-07-13/store-test-browser-mock-fix/plan.md b/docs/dev-notes/2026-07-13/store-test-browser-mock-fix/plan.md new file mode 100644 index 000000000..7e8ea0bfb --- /dev/null +++ b/docs/dev-notes/2026-07-13/store-test-browser-mock-fix/plan.md @@ -0,0 +1,164 @@ +# 計画: store テストの browser mock クランプ修正(Option C) + +**ステータス: Phase 1-3 実装完了(2026-07-13)** + +## Context(背景) + +commit `19411e5b`(2026-07-13)で vitest の default 環境を node にし、DOM 参照を持つ 3 つの store テストにだけ `// @vitest-environment jsdom` を付与した。しかし調査で以下が判明した: + +- 3 ファイルはいずれも先頭に `vi.mock('$app/environment', () => ({ browser: true }))`、さらに SSR 用 describe の `beforeEach` 内で `vi.mock(..., { browser: false })` を宣言している。 +- vitest は **全ての `vi.mock` をホイストし、同一モジュールでは後勝ち**になるため、実測で全テストが `browser = false` になる(`PROBE-MAIN browser = false`)。 +- 結果、store の localStorage 分岐(`getInitialValue`/`loadInitialState` の `localStorage.getItem`、setter/`toggleView` の `localStorage.setItem`)は **jsdom でも node でも一度も実行されない**。`vi.stubGlobal('localStorage', …)` の足場も store 経由では実質未使用。 +- 副作用として `active_contest_type` の "invalid localStorage key" / "with null" テストは `mockStorage` を読まず常に default を返す **false-positive**。`replenishment` の "persists state in localStorage" は「JSDOM では localStorage をモックできない」という誤ったコメント付きで `test.skip` されている(真因は browser=false)。 + +つまり testing.md の根拠「jsdom を残せば browser 分岐カバレッジが守られる」は、この 3 ファイルでは成立していない。本計画は **browser mock を main=true / SSR=false に正しく効かせ、localStorage 分岐を実際にカバーする**。 + +## 設計方針(確定事項) + +- **環境**: 3 ファイルとも `// @vitest-environment jsdom` を維持し、jsdom の**実 localStorage** を使う(`vi.stubGlobal('localStorage', …)` のモックは廃止)。検証は状態ベース(`localStorage.getItem(key)` の値を assert)。 +- **browser 切替**: 単一ファイル内で `vi.hoisted` の可変フラグ + getter 形式の 1 個の `vi.mock` に統一。describe ごとに `beforeEach` でフラグを切替える。**同一モジュールへの 2 個目の `vi.mock` は作らない**(クランプの再発防止)。 +- import 時の singleton 構築が SSR-safe になるよう、フラグ既定値は `false`(localStorage 未参照で構築)。main describe の `beforeEach` で `true` に切替える。 + +## 共通パターン(3 ファイル共通) + +各テストファイルの mock を以下に置換する: + +```typescript +// @vitest-environment jsdom +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +// … store imports + +// import 時は browser=false(singleton は localStorage 非参照で構築)。 +const browserState = vi.hoisted(() => ({ value: false })); + +vi.mock('$app/environment', () => ({ + get browser() { + return browserState.value; + }, +})); +``` + +- main describe の `beforeEach`: `browserState.value = true; localStorage.clear();`(+ 必要に応じ fresh store 生成) +- SSR describe の `beforeEach`: `browserState.value = false;`(既存の `vi.restoreAllMocks()` / 2 個目の `vi.mock` は削除) +- `afterEach`: `localStorage.clear();`(旧 `vi.unstubAllGlobals()` は不要) + +## Phase 1: production の export 追加(低リスク・加算のみ) + +**対象**: `src/features/workbooks/stores/replenishment_workbook.svelte.ts` + +`ReplenishmentWorkBooksStore` クラスが未 export のため、テストで fresh インスタンスを作れず `loadInitialState` の invalid-data catch 分岐を真に検証できない。他 2 store(`ActiveContestTypeStore` / `ActiveProblemListTabStore`)と同様に**クラスも named export** する(singleton export はそのまま)。 + +```typescript +export class ReplenishmentWorkBooksStore { … } // 既存 class に export を付与 +export const replenishmentWorkBooksStore = new ReplenishmentWorkBooksStore(); +``` + +## Phase 2: 3 テストファイルの改修 + +いずれも上記「共通パターン」を適用し、localStorage を実 Storage に置換する。 + +### `src/features/tasks/stores/active_contest_type.svelte.test.ts` + +- `mockLocalStorage` / `mockStorage` / `vi.stubGlobal` / `vi.unstubAllGlobals` を削除。 +- `beforeEach`: `browserState.value = true; localStorage.clear(); store = new ActiveContestTypeStore(); store.reset();` +- "invalid localStorage key" / "with null": `mockStorage[...] = …` を `localStorage.setItem('contest_table_providers', JSON.stringify(...))` に置換 → `new ActiveContestTypeStore()` が browser=true で実際に getItem を読み、invalid → default 'abs' の分岐を真に通す。 +- 追加: `set()` が localStorage に永続化することを 1 件状態 assert(例 `set('abc319Onwards')` 後 `JSON.parse(localStorage.getItem('contest_table_providers')!) === 'abc319Onwards'`)。 +- SSR describe: `beforeEach` を `browserState.value = false;` のみに(`restoreAllMocks` と 2 個目の mock を削除)。singleton `activeContestTypeStore.get()` は import 時構築の 'abs' を返す。 + +### `src/features/workbooks/stores/replenishment_workbook.test.ts` + +- 未使用になる `type Mock` import を削除。`mockLocalStorage` / `vi.stubGlobal` を削除。 +- `beforeEach`: `browserState.value = true; localStorage.clear(); replenishmentWorkBooksStore.reset();` +- `test.skip('persists state in localStorage')` を **un-skip し状態 assert に書換え**: + ```typescript + replenishmentWorkBooksStore.toggleView(); // false -> true + expect(localStorage.getItem(localStorageKey)).toBe(JSON.stringify(true)); + replenishmentWorkBooksStore.toggleView(); // true -> false + expect(localStorage.getItem(localStorageKey)).toBe(JSON.stringify(false)); + ``` +- "handles invalid localStorage data" を Phase 1 の export を使い実分岐化: + ```typescript + localStorage.setItem(localStorageKey, 'invalid-json'); + expect(new ReplenishmentWorkBooksStore().canView()).toBe(false); // loadInitialState の catch を通す + ``` +- `toBeFalsy()` → `toBe(false)`(testing.md のアサーション規約に整合)。 +- SSR describe: `beforeEach` を `browserState.value = false;` のみに。 + +### `src/test/lib/stores/active_problem_list_tab.svelte.test.ts` + +- `mockLocalStorage` / `mockStorage` / `vi.stubGlobal` / `vi.unstubAllGlobals` を削除。 +- `beforeEach`: `browserState.value = true; localStorage.clear(); store = new ActiveProblemListTabStore(); store.reset();` +- 追加: `set()` の localStorage 永続化を 1 件状態 assert。 +- SSR describe: `beforeEach` を `browserState.value = false;` のみに。singleton `.get()` は 'contestTable'。 + +## Phase 3: testing.md のルール更新(低リスク) + +**対象**: `.claude/rules/testing.md` の "Test Environment" 節。 + +「jsdom を残せば browser 分岐が守られる」の記述を実態に合わせ、真の落とし穴を明文化する: + +- 同一ファイルで `vi.mock('$app/environment')` を **2 回登録しない**。全 `vi.mock` はホイストされ後勝ちのため、SSR describe 内の `{browser:false}` がトップの `{browser:true}` を黙って上書きし、全テストが browser=false に固定され browser 分岐カバレッジが落ちる。 +- browser を describe ごとに切替えるには **1 個の動的 mock**(`vi.hoisted` フラグ + `get browser()`)を使い、フラグを `beforeEach` でトグルする。既定値は false にして import 時 singleton 構築を SSR-safe に保つ。 +- jsdom オプトインファイルでは localStorage は `vi.stubGlobal` でモックせず jsdom の実 Storage を使い、状態ベースで検証する。 + +## 検証(Verification) + +1. `pnpm vitest run src/features/tasks/stores/active_contest_type.svelte.test.ts src/features/workbooks/stores/replenishment_workbook.test.ts src/test/lib/stores/active_problem_list_tab.svelte.test.ts` — 全 pass。旧 skip テストが実行され pass することを確認(skipped 0)。 +2. **browser 両分岐の実行確認**: main(browser=true)の永続化 assert が pass = localStorage 分岐が実際に走る。SSR(browser=false)テストも pass。 +3. **カバレッジ回帰確認**: `pnpm coverage` で 3 つの store source(`active_contest_type.svelte.ts` / `replenishment_workbook.svelte.ts` / `active_problem_list_tab.svelte.ts` と `local_storage_helper.svelte.ts`)の `localStorage.getItem`/`setItem` 行が **covered**(従来 0)になることを確認。 +4. `pnpm lint` / `pnpm check` — 未使用 import(`Mock` 等)除去後にクリーン。 +5. `pnpm test:unit` フルラン — 他テストへの回帰なし。 +6. `pnpm format`(コミット前)。 + +**フォールバック**: 万一 `import { browser }` が getter を live binding として拾わない場合(環境依存の可能性は低い)、各ファイルの SSR describe を `browser:false` 固定のトップレベル `vi.mock` を持つ別 `*.test.ts` に分離する方式に切替える(ホイスト衝突が原理的に起きない)。 + +## 実装結果(2026-07-13) + +### 実施内容 + +- **Phase 1**: `ReplenishmentWorkBooksStore` に `export` を付与(singleton export は据え置き)。 +- **Phase 2**: 3 テストファイルを共通パターン(`vi.hoisted` フラグ + getter 形式の単一 `vi.mock`)に統一。`vi.stubGlobal('localStorage', …)` / `mockStorage` / `vi.unstubAllGlobals` / `vi.restoreAllMocks` / 未使用の `type Mock` import をすべて削除し、jsdom の実 Storage による状態ベース検証に置換。`test.skip('persists state in localStorage')` を un-skip。`toBeFalsy()` → `toBe(false)`。 +- **Phase 3**: `.claude/rules/testing.md` の "Test Environment" 節に "Toggling `browser` per describe" 小節を追加(2 重 `vi.mock` 禁止 + 単一動的 mock のコード例 + `vi.stubGlobal` 不使用の方針)。 + +### 計画からの変更点 + +- **SSR describe を singleton assert → fresh インスタンス assert に変更**(ユーザー判断)。localStorage にデータを入れた状態で `new XxxStore()` を browser=false で生成し default が返ることを assert する。SSR 経路(localStorage 無視)を実際に通すうえ、singleton の残留状態=テスト実行順序への依存が消える。`activeContestTypeStore` / `activeProblemListTabStore` の singleton assert は「import 時構築が SSR-safe」の確認として SSR describe に併存させた(main describe が singleton を触らないため順序非依存)。 +- **カバレッジ穴埋めのテストを追加**(当初計画に無し)。実装後の実測でまだ 0 だった分岐: + - `local_storage_helper.svelte.ts` の `getInitialValue` catch(不正 JSON)→ active_contest_type に `'invalid-json'` ケース追加。計画の "invalid localStorage key" は**正当な JSON**(`JSON.stringify('invalidContestType')`)なので catch を通らない。 + - `browser=false` 時の書き込みガード(`toggleView` / setter の `if (browser)` false 側)→ 各 SSR describe に「set/toggle しても `getItem` が null のまま」を追加。 + - replenishment の「localStorage が空」ケース(`savedStatus ? … : false` の null 側)。 + +### 検証結果 + +- 対象 3 ファイル: **36 passed / 0 skipped**(旧 `test.skip` は実行され pass)。 +- スコープ付きカバレッジ(4 つの store source): **Statements 100% / Lines 100% / Functions 100% / Branches 92.59%**(従来 localStorage 行は 0)。残る未カバー 2 分岐は `getActiveContestTypeStore` / `getActiveProblemListTabStore` の `if (!instance)` false 側(singleton メモ化)で、追試の価値なしと判断。 +- `pnpm lint` クリーン / `pnpm check` 0 errors(30 warnings は既存の無関係な route ファイル)。 +- `pnpm test:unit` フルラン: **80 files / 2757 passed**、回帰なし。 +- フォールバック(SSR を別ファイルに分離)は**不要**だった。`import { browser }` は ESM live binding として getter を毎回評価するため、`vi.hoisted` フラグの切替えがそのまま効く。 + +### novel lessons + +- `vi.mock` の 2 重登録は**エラーにも警告にもならず**、後勝ちで静かにクランプする。テストは緑のまま分岐カバレッジだけが落ちるため、レビューでもすり抜ける。`grep -c "vi.mock('\$app/environment'" ` が 2 以上なら赤信号(実施時点で該当は本 3 ファイルのみ)。 +- 「invalid な値」テストを書くときは**どの分岐を狙っているか**を区別する。ドメイン的に不正(`'invalidContestType'`)と JSON として不正(`'invalid-json'`)は別の分岐で、前者だけでは parse の catch を通らない。 +- テストの skip 理由コメントは真因の記録として信用できない(本件の「JSDOM では localStorage をモックできない」は誤り。真因は browser クランプ)。 + +## 却下した代替案 + +- **node + モック localStorage(spy 検証)へ移行**: perf は微増するが jsdom が不要化し「C = jsdom を意味あるものにする」趣旨に反する。localStorage 分岐を実 Storage で状態検証する今回方針の方が実態に忠実。 +- **現状維持 + テスト名だけ修正**: browser クランプという真因を放置し、カバレッジ 0 のまま。 + +## QA(真因メカニズムの要約) + +**Q. `vi.mock` の「ホイスト」とは?** +Vitest はファイル変換時に全 `vi.mock(...)` を `import` より上へ機械的に巻き上げる。`import`(ESM)はコードより先に評価されるため、モック登録が手遅れにならないようにする措置。`beforeEach` や `describe` の内側に書いた `vi.mock` も同様に巻き上げられ、**「その行に来たとき」ではなく全テストより前に一度だけ実行される**(Vitest も "not at the top level ... hoisted and executed before any tests run" と警告)。 + +**Q. 「同一モジュールで後勝ち」とは?** +本 3 ファイルは `$app/environment` を 2 回モックしている(先頭 `{browser:true}`、SSR describe の `beforeEach` 内 `{browser:false}`)。両方が巻き上げられ、同一モジュールのモック登録は 1 つだけ保持される → **後に登録した `{browser:false}` が先の `{browser:true}` を上書き**。結果、SSR describe だけでなく browser=true を期待していたメイン describe も含め、**ファイル全体で `browser=false`** に固定される(実測 `PROBE-MAIN browser = false`)。 + +**Q. 「全テストが browser=false」= 実質 store をテストしていない?** +概ね正しい(テストは緑だが肝心の分岐に未到達)。store は `browser` で SSR 経路(デフォルト返却)と localStorage 経路を分けている。`browser=false` 固定のため: + +- コンストラクタの `getInitialValue` は `localStorage.getItem` を通らず即デフォルト返却 +- setter/`toggleView` の `localStorage.setItem` も未実行 + +→ **ストアの存在意義である localStorage 読み書きが一行も実行されていない**。`stubGlobal('localStorage', …)` の足場も store 経由では呼ばれず空回り。例えば "invalid localStorage key" テストは `mockStorage` を読まずデフォルトを返すだけで pass する **false-positive**。皮肉にも `19411e5b` が「browser 分岐カバレッジを守るため」に付けた jsdom も、browser=false ゆえ localStorage コードが走らず無効化している。これが本計画で直す本体。 diff --git a/src/features/tasks/stores/active_contest_type.svelte.test.ts b/src/features/tasks/stores/active_contest_type.svelte.test.ts index 34081286e..90967b826 100644 --- a/src/features/tasks/stores/active_contest_type.svelte.test.ts +++ b/src/features/tasks/stores/active_contest_type.svelte.test.ts @@ -8,40 +8,35 @@ import { ActiveContestTypeStore, } from '$features/tasks/stores/active_contest_type.svelte'; +// WHY: a single dynamic mock is the only way to toggle `browser` per describe. Every vi.mock is +// hoisted and the last registration for a module wins, so a second vi.mock of `$app/environment` +// would silently clamp the whole file to one branch. +// Defaults to false so the singleton constructed at import time stays SSR-safe (no localStorage). +const browserState = vi.hoisted(() => ({ value: false })); + vi.mock('$app/environment', () => ({ - browser: true, + get browser() { + return browserState.value; + }, })); +const localStorageKey = 'contest_table_providers'; + +afterEach(() => { + localStorage.clear(); +}); + describe('ActiveContestTypeStore', () => { let store: ActiveContestTypeStore; - const mockLocalStorage: Storage = { - getItem: vi.fn((key) => mockStorage[key] || null), - setItem: vi.fn((key, value) => { - mockStorage[key] = value; - }), - removeItem: vi.fn(), - clear: vi.fn(), - length: 0, - key: vi.fn(), - }; - const mockStorage: Record = {}; - beforeEach(() => { - vi.clearAllMocks(); - // Clear mockStorage before each test - Object.keys(mockStorage).forEach((key) => delete mockStorage[key]); - // Setup mock for localStorage - vi.stubGlobal('localStorage', mockLocalStorage); + browserState.value = true; + localStorage.clear(); store = new ActiveContestTypeStore(); store.reset(); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - test('expects to initialize with default value', () => { expect(store.get()).toBe('abs'); }); @@ -62,6 +57,17 @@ describe('ActiveContestTypeStore', () => { expect(store.get()).toBe('abc319Onwards'); }); + test('expects to persist the value in localStorage when calling set()', () => { + store.set('abc319Onwards' as ContestTableProviderGroups); + expect(localStorage.getItem(localStorageKey)).toBe(JSON.stringify('abc319Onwards')); + }); + + test('expects to restore the value persisted in localStorage', () => { + localStorage.setItem(localStorageKey, JSON.stringify('fromAbc212ToAbc318')); + + expect(new ActiveContestTypeStore().get()).toBe('fromAbc212ToAbc318'); + }); + test('expects to correctly determine if contest type is the same with isSame()', () => { expect(store.isSame('abs' as ContestTableProviderGroups)).toBe(true); expect(store.isSame('abc319Onwards' as ContestTableProviderGroups)).toBe(false); @@ -96,15 +102,21 @@ describe('ActiveContestTypeStore', () => { }); test('expects to reset to default when initialized with invalid localStorage key', () => { - // Simulate invalid key in localStorage - mockStorage['contest_table_providers'] = JSON.stringify('invalidContestType'); + localStorage.setItem(localStorageKey, JSON.stringify('invalidContestType')); + + const newStore = new ActiveContestTypeStore(); + expect(newStore.get()).toBe('abs'); + }); + + test('expects to reset to default when localStorage holds invalid JSON', () => { + localStorage.setItem(localStorageKey, 'invalid-json'); const newStore = new ActiveContestTypeStore(); expect(newStore.get()).toBe('abs'); }); test('expects to reset to default when initialized with null', () => { - mockStorage['contest_table_providers'] = JSON.stringify(null); + localStorage.setItem(localStorageKey, JSON.stringify(null)); const newStore = new ActiveContestTypeStore(); expect(newStore.get()).toBe('abs'); @@ -126,17 +138,24 @@ describe('ActiveContestTypeStore', () => { describe('Active contest type store in SSR', () => { beforeEach(() => { - vi.restoreAllMocks(); - vi.mock('$app/environment', () => ({ - browser: false, - })); + browserState.value = false; }); - afterEach(() => { - vi.restoreAllMocks(); + test('expects to ignore localStorage and initialize with default value', () => { + localStorage.setItem(localStorageKey, JSON.stringify('abc319Onwards')); + + expect(new ActiveContestTypeStore().get()).toBe('abs'); + }); + + test('expects not to persist the value in localStorage when calling set()', () => { + const store = new ActiveContestTypeStore(); + store.set('abc319Onwards' as ContestTableProviderGroups); + + expect(store.get()).toBe('abc319Onwards'); + expect(localStorage.getItem(localStorageKey)).toBeNull(); }); - test('handles SSR gracefully', () => { + test('expects the singleton constructed at import time to hold the default value', () => { expect(activeContestTypeStore.get()).toBe('abs'); }); }); diff --git a/src/features/workbooks/stores/replenishment_workbook.svelte.ts b/src/features/workbooks/stores/replenishment_workbook.svelte.ts index 9cf790e25..6eb7fa62c 100644 --- a/src/features/workbooks/stores/replenishment_workbook.svelte.ts +++ b/src/features/workbooks/stores/replenishment_workbook.svelte.ts @@ -7,7 +7,7 @@ import { browser } from '$app/environment'; // https://svelte.dev/docs/svelte/$state const IS_SHOWN_REPLENISHMENT_WORKBOOKS = 'is_shown_replenishment_workbooks'; -class ReplenishmentWorkBooksStore { +export class ReplenishmentWorkBooksStore { private isShown = $state(this.loadInitialState()); // Note: diff --git a/src/features/workbooks/stores/replenishment_workbook.test.ts b/src/features/workbooks/stores/replenishment_workbook.test.ts index d3ea32bf4..cbba5d863 100644 --- a/src/features/workbooks/stores/replenishment_workbook.test.ts +++ b/src/features/workbooks/stores/replenishment_workbook.test.ts @@ -1,38 +1,40 @@ // @vitest-environment jsdom -import { expect, test, vi, type Mock } from 'vitest'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; -import { replenishmentWorkBooksStore } from '$features/workbooks/stores/replenishment_workbook.svelte'; +import { + replenishmentWorkBooksStore, + ReplenishmentWorkBooksStore, +} from '$features/workbooks/stores/replenishment_workbook.svelte'; + +// WHY: a single dynamic mock is the only way to toggle `browser` per describe. Every vi.mock is +// hoisted and the last registration for a module wins, so a second vi.mock of `$app/environment` +// would silently clamp the whole file to one branch. +// Defaults to false so the singleton constructed at import time stays SSR-safe (no localStorage). +const browserState = vi.hoisted(() => ({ value: false })); vi.mock('$app/environment', () => ({ - browser: true, + get browser() { + return browserState.value; + }, })); -describe('Replenishment workbooks store', () => { - const localStorageKey = 'is_shown_replenishment_workbooks'; - const mockLocalStorage: Storage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - length: 0, - key: vi.fn(), - }; +const localStorageKey = 'is_shown_replenishment_workbooks'; +afterEach(() => { + localStorage.clear(); +}); + +describe('Replenishment workbooks store', () => { beforeEach(() => { - vi.clearAllMocks(); - // Setup mock for localStorage - vi.stubGlobal('localStorage', mockLocalStorage); + browserState.value = true; + localStorage.clear(); replenishmentWorkBooksStore.reset(); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - test('expects to be invisible before toggling', () => { - expect(replenishmentWorkBooksStore.canView()).toBeFalsy(); + expect(replenishmentWorkBooksStore.canView()).toBe(false); }); test.each([ @@ -47,30 +49,47 @@ describe('Replenishment workbooks store', () => { expect(replenishmentWorkBooksStore.canView()).toBe(expected); }); - // Note: This test is skipped because it is not possible to mock localStorage in JSDOM. - test.skip('persists state in localStorage', () => { - (mockLocalStorage.getItem as Mock).mockReturnValue(JSON.stringify(false)); + test('expects to persist state in localStorage', () => { + replenishmentWorkBooksStore.toggleView(); + expect(localStorage.getItem(localStorageKey)).toBe(JSON.stringify(true)); replenishmentWorkBooksStore.toggleView(); + expect(localStorage.getItem(localStorageKey)).toBe(JSON.stringify(false)); + }); - expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); - expect(mockLocalStorage.setItem).toHaveBeenCalledWith(localStorageKey, JSON.stringify(true)); + test('expects to be invisible when localStorage holds no state', () => { + expect(new ReplenishmentWorkBooksStore().canView()).toBe(false); }); - test('handles invalid localStorage data', () => { + test('expects to restore the state persisted in localStorage', () => { + localStorage.setItem(localStorageKey, JSON.stringify(true)); + + expect(new ReplenishmentWorkBooksStore().canView()).toBe(true); + }); + + test('expects to be invisible when localStorage data is invalid', () => { localStorage.setItem(localStorageKey, 'invalid-json'); - expect(replenishmentWorkBooksStore.canView()).toBeFalsy(); + + expect(new ReplenishmentWorkBooksStore().canView()).toBe(false); }); }); describe('Replenishment workbooks store in SSR', () => { beforeEach(() => { - vi.mock('$app/environment', () => ({ - browser: false, - })); + browserState.value = false; }); - test('handles SSR gracefully', () => { - expect(replenishmentWorkBooksStore.canView()).toBeFalsy(); + test('expects to ignore localStorage and be invisible', () => { + localStorage.setItem(localStorageKey, JSON.stringify(true)); + + expect(new ReplenishmentWorkBooksStore().canView()).toBe(false); + }); + + test('expects not to persist state in localStorage when toggling', () => { + const store = new ReplenishmentWorkBooksStore(); + store.toggleView(); + + expect(store.canView()).toBe(true); + expect(localStorage.getItem(localStorageKey)).toBeNull(); }); }); diff --git a/src/test/lib/stores/active_problem_list_tab.svelte.test.ts b/src/test/lib/stores/active_problem_list_tab.svelte.test.ts index 674fcb466..288edc478 100644 --- a/src/test/lib/stores/active_problem_list_tab.svelte.test.ts +++ b/src/test/lib/stores/active_problem_list_tab.svelte.test.ts @@ -1,44 +1,41 @@ // @vitest-environment jsdom -import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import { activeProblemListTabStore, ActiveProblemListTabStore, } from '$lib/stores/active_problem_list_tab.svelte'; +// WHY: a single dynamic mock is the only way to toggle `browser` per describe. Every vi.mock is +// hoisted and the last registration for a module wins, so a second vi.mock of `$app/environment` +// would silently clamp the whole file to one branch. +// Defaults to false so the singleton constructed at import time stays SSR-safe (no localStorage). +const browserState = vi.hoisted(() => ({ value: false })); + vi.mock('$app/environment', () => ({ - browser: true, + get browser() { + return browserState.value; + }, })); +const localStorageKey = 'active_problem_list_tab'; + +afterEach(() => { + localStorage.clear(); +}); + describe('ActiveProblemListTabStore', () => { let store: ActiveProblemListTabStore; - const mockLocalStorage: Storage = { - getItem: vi.fn((key) => mockStorage[key] || null), - setItem: vi.fn((key, value) => { - mockStorage[key] = value; - }), - removeItem: vi.fn(), - clear: vi.fn(), - length: 0, - key: vi.fn(), - }; - const mockStorage: Record = {}; - beforeEach(() => { - vi.clearAllMocks(); - // Setup mock for localStorage - vi.stubGlobal('localStorage', mockLocalStorage); + browserState.value = true; + localStorage.clear(); store = new ActiveProblemListTabStore(); store.reset(); }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - describe('constructor', () => { test('expects to initialize with default value', () => { expect(store.get()).toBe('contestTable'); @@ -48,6 +45,12 @@ describe('ActiveProblemListTabStore', () => { const customStore = new ActiveProblemListTabStore('listByGrade'); expect(customStore.get()).toBe('listByGrade'); }); + + test('expects to restore the value persisted in localStorage', () => { + localStorage.setItem(localStorageKey, JSON.stringify('gradeGuidelineTable')); + + expect(new ActiveProblemListTabStore().get()).toBe('gradeGuidelineTable'); + }); }); describe('get', () => { @@ -64,6 +67,11 @@ describe('ActiveProblemListTabStore', () => { store.set('gradeGuidelineTable'); expect(store.get()).toBe('gradeGuidelineTable'); }); + + test('expects to persist the active tab in localStorage', () => { + store.set('listByGrade'); + expect(localStorage.getItem(localStorageKey)).toBe(JSON.stringify('listByGrade')); + }); }); describe('isSame', () => { @@ -92,17 +100,16 @@ describe('ActiveProblemListTabStore', () => { describe('Active problem list tab store in SSR', () => { beforeEach(() => { - vi.restoreAllMocks(); - vi.mock('$app/environment', () => ({ - browser: false, - })); + browserState.value = false; }); - afterEach(() => { - vi.restoreAllMocks(); + test('expects to ignore localStorage and initialize with default value', () => { + localStorage.setItem(localStorageKey, JSON.stringify('listByGrade')); + + expect(new ActiveProblemListTabStore().get()).toBe('contestTable'); }); - test('handles SSR gracefully', () => { + test('expects the singleton constructed at import time to hold the default value', () => { expect(activeProblemListTabStore.get()).toBe('contestTable'); }); }); From d42e7d1f360230bedd010f7bbb52b5a7e953734e Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 13 Jul 2026 13:22:31 +0000 Subject: [PATCH 5/6] chore(docs): remove completed store-test-browser-mock-fix plan Implementation and verification already landed in fbd13492. Co-Authored-By: Claude Sonnet 5 --- .../store-test-browser-mock-fix/plan.md | 164 ------------------ 1 file changed, 164 deletions(-) delete mode 100644 docs/dev-notes/2026-07-13/store-test-browser-mock-fix/plan.md diff --git a/docs/dev-notes/2026-07-13/store-test-browser-mock-fix/plan.md b/docs/dev-notes/2026-07-13/store-test-browser-mock-fix/plan.md deleted file mode 100644 index 7e8ea0bfb..000000000 --- a/docs/dev-notes/2026-07-13/store-test-browser-mock-fix/plan.md +++ /dev/null @@ -1,164 +0,0 @@ -# 計画: store テストの browser mock クランプ修正(Option C) - -**ステータス: Phase 1-3 実装完了(2026-07-13)** - -## Context(背景) - -commit `19411e5b`(2026-07-13)で vitest の default 環境を node にし、DOM 参照を持つ 3 つの store テストにだけ `// @vitest-environment jsdom` を付与した。しかし調査で以下が判明した: - -- 3 ファイルはいずれも先頭に `vi.mock('$app/environment', () => ({ browser: true }))`、さらに SSR 用 describe の `beforeEach` 内で `vi.mock(..., { browser: false })` を宣言している。 -- vitest は **全ての `vi.mock` をホイストし、同一モジュールでは後勝ち**になるため、実測で全テストが `browser = false` になる(`PROBE-MAIN browser = false`)。 -- 結果、store の localStorage 分岐(`getInitialValue`/`loadInitialState` の `localStorage.getItem`、setter/`toggleView` の `localStorage.setItem`)は **jsdom でも node でも一度も実行されない**。`vi.stubGlobal('localStorage', …)` の足場も store 経由では実質未使用。 -- 副作用として `active_contest_type` の "invalid localStorage key" / "with null" テストは `mockStorage` を読まず常に default を返す **false-positive**。`replenishment` の "persists state in localStorage" は「JSDOM では localStorage をモックできない」という誤ったコメント付きで `test.skip` されている(真因は browser=false)。 - -つまり testing.md の根拠「jsdom を残せば browser 分岐カバレッジが守られる」は、この 3 ファイルでは成立していない。本計画は **browser mock を main=true / SSR=false に正しく効かせ、localStorage 分岐を実際にカバーする**。 - -## 設計方針(確定事項) - -- **環境**: 3 ファイルとも `// @vitest-environment jsdom` を維持し、jsdom の**実 localStorage** を使う(`vi.stubGlobal('localStorage', …)` のモックは廃止)。検証は状態ベース(`localStorage.getItem(key)` の値を assert)。 -- **browser 切替**: 単一ファイル内で `vi.hoisted` の可変フラグ + getter 形式の 1 個の `vi.mock` に統一。describe ごとに `beforeEach` でフラグを切替える。**同一モジュールへの 2 個目の `vi.mock` は作らない**(クランプの再発防止)。 -- import 時の singleton 構築が SSR-safe になるよう、フラグ既定値は `false`(localStorage 未参照で構築)。main describe の `beforeEach` で `true` に切替える。 - -## 共通パターン(3 ファイル共通) - -各テストファイルの mock を以下に置換する: - -```typescript -// @vitest-environment jsdom -import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; -// … store imports - -// import 時は browser=false(singleton は localStorage 非参照で構築)。 -const browserState = vi.hoisted(() => ({ value: false })); - -vi.mock('$app/environment', () => ({ - get browser() { - return browserState.value; - }, -})); -``` - -- main describe の `beforeEach`: `browserState.value = true; localStorage.clear();`(+ 必要に応じ fresh store 生成) -- SSR describe の `beforeEach`: `browserState.value = false;`(既存の `vi.restoreAllMocks()` / 2 個目の `vi.mock` は削除) -- `afterEach`: `localStorage.clear();`(旧 `vi.unstubAllGlobals()` は不要) - -## Phase 1: production の export 追加(低リスク・加算のみ) - -**対象**: `src/features/workbooks/stores/replenishment_workbook.svelte.ts` - -`ReplenishmentWorkBooksStore` クラスが未 export のため、テストで fresh インスタンスを作れず `loadInitialState` の invalid-data catch 分岐を真に検証できない。他 2 store(`ActiveContestTypeStore` / `ActiveProblemListTabStore`)と同様に**クラスも named export** する(singleton export はそのまま)。 - -```typescript -export class ReplenishmentWorkBooksStore { … } // 既存 class に export を付与 -export const replenishmentWorkBooksStore = new ReplenishmentWorkBooksStore(); -``` - -## Phase 2: 3 テストファイルの改修 - -いずれも上記「共通パターン」を適用し、localStorage を実 Storage に置換する。 - -### `src/features/tasks/stores/active_contest_type.svelte.test.ts` - -- `mockLocalStorage` / `mockStorage` / `vi.stubGlobal` / `vi.unstubAllGlobals` を削除。 -- `beforeEach`: `browserState.value = true; localStorage.clear(); store = new ActiveContestTypeStore(); store.reset();` -- "invalid localStorage key" / "with null": `mockStorage[...] = …` を `localStorage.setItem('contest_table_providers', JSON.stringify(...))` に置換 → `new ActiveContestTypeStore()` が browser=true で実際に getItem を読み、invalid → default 'abs' の分岐を真に通す。 -- 追加: `set()` が localStorage に永続化することを 1 件状態 assert(例 `set('abc319Onwards')` 後 `JSON.parse(localStorage.getItem('contest_table_providers')!) === 'abc319Onwards'`)。 -- SSR describe: `beforeEach` を `browserState.value = false;` のみに(`restoreAllMocks` と 2 個目の mock を削除)。singleton `activeContestTypeStore.get()` は import 時構築の 'abs' を返す。 - -### `src/features/workbooks/stores/replenishment_workbook.test.ts` - -- 未使用になる `type Mock` import を削除。`mockLocalStorage` / `vi.stubGlobal` を削除。 -- `beforeEach`: `browserState.value = true; localStorage.clear(); replenishmentWorkBooksStore.reset();` -- `test.skip('persists state in localStorage')` を **un-skip し状態 assert に書換え**: - ```typescript - replenishmentWorkBooksStore.toggleView(); // false -> true - expect(localStorage.getItem(localStorageKey)).toBe(JSON.stringify(true)); - replenishmentWorkBooksStore.toggleView(); // true -> false - expect(localStorage.getItem(localStorageKey)).toBe(JSON.stringify(false)); - ``` -- "handles invalid localStorage data" を Phase 1 の export を使い実分岐化: - ```typescript - localStorage.setItem(localStorageKey, 'invalid-json'); - expect(new ReplenishmentWorkBooksStore().canView()).toBe(false); // loadInitialState の catch を通す - ``` -- `toBeFalsy()` → `toBe(false)`(testing.md のアサーション規約に整合)。 -- SSR describe: `beforeEach` を `browserState.value = false;` のみに。 - -### `src/test/lib/stores/active_problem_list_tab.svelte.test.ts` - -- `mockLocalStorage` / `mockStorage` / `vi.stubGlobal` / `vi.unstubAllGlobals` を削除。 -- `beforeEach`: `browserState.value = true; localStorage.clear(); store = new ActiveProblemListTabStore(); store.reset();` -- 追加: `set()` の localStorage 永続化を 1 件状態 assert。 -- SSR describe: `beforeEach` を `browserState.value = false;` のみに。singleton `.get()` は 'contestTable'。 - -## Phase 3: testing.md のルール更新(低リスク) - -**対象**: `.claude/rules/testing.md` の "Test Environment" 節。 - -「jsdom を残せば browser 分岐が守られる」の記述を実態に合わせ、真の落とし穴を明文化する: - -- 同一ファイルで `vi.mock('$app/environment')` を **2 回登録しない**。全 `vi.mock` はホイストされ後勝ちのため、SSR describe 内の `{browser:false}` がトップの `{browser:true}` を黙って上書きし、全テストが browser=false に固定され browser 分岐カバレッジが落ちる。 -- browser を describe ごとに切替えるには **1 個の動的 mock**(`vi.hoisted` フラグ + `get browser()`)を使い、フラグを `beforeEach` でトグルする。既定値は false にして import 時 singleton 構築を SSR-safe に保つ。 -- jsdom オプトインファイルでは localStorage は `vi.stubGlobal` でモックせず jsdom の実 Storage を使い、状態ベースで検証する。 - -## 検証(Verification) - -1. `pnpm vitest run src/features/tasks/stores/active_contest_type.svelte.test.ts src/features/workbooks/stores/replenishment_workbook.test.ts src/test/lib/stores/active_problem_list_tab.svelte.test.ts` — 全 pass。旧 skip テストが実行され pass することを確認(skipped 0)。 -2. **browser 両分岐の実行確認**: main(browser=true)の永続化 assert が pass = localStorage 分岐が実際に走る。SSR(browser=false)テストも pass。 -3. **カバレッジ回帰確認**: `pnpm coverage` で 3 つの store source(`active_contest_type.svelte.ts` / `replenishment_workbook.svelte.ts` / `active_problem_list_tab.svelte.ts` と `local_storage_helper.svelte.ts`)の `localStorage.getItem`/`setItem` 行が **covered**(従来 0)になることを確認。 -4. `pnpm lint` / `pnpm check` — 未使用 import(`Mock` 等)除去後にクリーン。 -5. `pnpm test:unit` フルラン — 他テストへの回帰なし。 -6. `pnpm format`(コミット前)。 - -**フォールバック**: 万一 `import { browser }` が getter を live binding として拾わない場合(環境依存の可能性は低い)、各ファイルの SSR describe を `browser:false` 固定のトップレベル `vi.mock` を持つ別 `*.test.ts` に分離する方式に切替える(ホイスト衝突が原理的に起きない)。 - -## 実装結果(2026-07-13) - -### 実施内容 - -- **Phase 1**: `ReplenishmentWorkBooksStore` に `export` を付与(singleton export は据え置き)。 -- **Phase 2**: 3 テストファイルを共通パターン(`vi.hoisted` フラグ + getter 形式の単一 `vi.mock`)に統一。`vi.stubGlobal('localStorage', …)` / `mockStorage` / `vi.unstubAllGlobals` / `vi.restoreAllMocks` / 未使用の `type Mock` import をすべて削除し、jsdom の実 Storage による状態ベース検証に置換。`test.skip('persists state in localStorage')` を un-skip。`toBeFalsy()` → `toBe(false)`。 -- **Phase 3**: `.claude/rules/testing.md` の "Test Environment" 節に "Toggling `browser` per describe" 小節を追加(2 重 `vi.mock` 禁止 + 単一動的 mock のコード例 + `vi.stubGlobal` 不使用の方針)。 - -### 計画からの変更点 - -- **SSR describe を singleton assert → fresh インスタンス assert に変更**(ユーザー判断)。localStorage にデータを入れた状態で `new XxxStore()` を browser=false で生成し default が返ることを assert する。SSR 経路(localStorage 無視)を実際に通すうえ、singleton の残留状態=テスト実行順序への依存が消える。`activeContestTypeStore` / `activeProblemListTabStore` の singleton assert は「import 時構築が SSR-safe」の確認として SSR describe に併存させた(main describe が singleton を触らないため順序非依存)。 -- **カバレッジ穴埋めのテストを追加**(当初計画に無し)。実装後の実測でまだ 0 だった分岐: - - `local_storage_helper.svelte.ts` の `getInitialValue` catch(不正 JSON)→ active_contest_type に `'invalid-json'` ケース追加。計画の "invalid localStorage key" は**正当な JSON**(`JSON.stringify('invalidContestType')`)なので catch を通らない。 - - `browser=false` 時の書き込みガード(`toggleView` / setter の `if (browser)` false 側)→ 各 SSR describe に「set/toggle しても `getItem` が null のまま」を追加。 - - replenishment の「localStorage が空」ケース(`savedStatus ? … : false` の null 側)。 - -### 検証結果 - -- 対象 3 ファイル: **36 passed / 0 skipped**(旧 `test.skip` は実行され pass)。 -- スコープ付きカバレッジ(4 つの store source): **Statements 100% / Lines 100% / Functions 100% / Branches 92.59%**(従来 localStorage 行は 0)。残る未カバー 2 分岐は `getActiveContestTypeStore` / `getActiveProblemListTabStore` の `if (!instance)` false 側(singleton メモ化)で、追試の価値なしと判断。 -- `pnpm lint` クリーン / `pnpm check` 0 errors(30 warnings は既存の無関係な route ファイル)。 -- `pnpm test:unit` フルラン: **80 files / 2757 passed**、回帰なし。 -- フォールバック(SSR を別ファイルに分離)は**不要**だった。`import { browser }` は ESM live binding として getter を毎回評価するため、`vi.hoisted` フラグの切替えがそのまま効く。 - -### novel lessons - -- `vi.mock` の 2 重登録は**エラーにも警告にもならず**、後勝ちで静かにクランプする。テストは緑のまま分岐カバレッジだけが落ちるため、レビューでもすり抜ける。`grep -c "vi.mock('\$app/environment'" ` が 2 以上なら赤信号(実施時点で該当は本 3 ファイルのみ)。 -- 「invalid な値」テストを書くときは**どの分岐を狙っているか**を区別する。ドメイン的に不正(`'invalidContestType'`)と JSON として不正(`'invalid-json'`)は別の分岐で、前者だけでは parse の catch を通らない。 -- テストの skip 理由コメントは真因の記録として信用できない(本件の「JSDOM では localStorage をモックできない」は誤り。真因は browser クランプ)。 - -## 却下した代替案 - -- **node + モック localStorage(spy 検証)へ移行**: perf は微増するが jsdom が不要化し「C = jsdom を意味あるものにする」趣旨に反する。localStorage 分岐を実 Storage で状態検証する今回方針の方が実態に忠実。 -- **現状維持 + テスト名だけ修正**: browser クランプという真因を放置し、カバレッジ 0 のまま。 - -## QA(真因メカニズムの要約) - -**Q. `vi.mock` の「ホイスト」とは?** -Vitest はファイル変換時に全 `vi.mock(...)` を `import` より上へ機械的に巻き上げる。`import`(ESM)はコードより先に評価されるため、モック登録が手遅れにならないようにする措置。`beforeEach` や `describe` の内側に書いた `vi.mock` も同様に巻き上げられ、**「その行に来たとき」ではなく全テストより前に一度だけ実行される**(Vitest も "not at the top level ... hoisted and executed before any tests run" と警告)。 - -**Q. 「同一モジュールで後勝ち」とは?** -本 3 ファイルは `$app/environment` を 2 回モックしている(先頭 `{browser:true}`、SSR describe の `beforeEach` 内 `{browser:false}`)。両方が巻き上げられ、同一モジュールのモック登録は 1 つだけ保持される → **後に登録した `{browser:false}` が先の `{browser:true}` を上書き**。結果、SSR describe だけでなく browser=true を期待していたメイン describe も含め、**ファイル全体で `browser=false`** に固定される(実測 `PROBE-MAIN browser = false`)。 - -**Q. 「全テストが browser=false」= 実質 store をテストしていない?** -概ね正しい(テストは緑だが肝心の分岐に未到達)。store は `browser` で SSR 経路(デフォルト返却)と localStorage 経路を分けている。`browser=false` 固定のため: - -- コンストラクタの `getInitialValue` は `localStorage.getItem` を通らず即デフォルト返却 -- setter/`toggleView` の `localStorage.setItem` も未実行 - -→ **ストアの存在意義である localStorage 読み書きが一行も実行されていない**。`stubGlobal('localStorage', …)` の足場も store 経由では呼ばれず空回り。例えば "invalid localStorage key" テストは `mockStorage` を読まずデフォルトを返すだけで pass する **false-positive**。皮肉にも `19411e5b` が「browser 分岐カバレッジを守るため」に付けた jsdom も、browser=false ゆえ localStorage コードが走らず無効化している。これが本計画で直す本体。 From 1d5f3fe9d0ef3041dadb066f7c4bae0a6c37c060 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Mon, 13 Jul 2026 13:49:26 +0000 Subject: [PATCH 6/6] docs(testing): clarify singleton false-positive and split SSR guard assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to fbd13492's testing.md addition. Two more pitfalls found while applying the pattern: asserting a browser branch against the import-time singleton is the same false-positive as the double-mock issue (the singleton is always built with browser=false), and a single assertion can't cover both the localStorage read guard and write guard — they need separate cases. Co-Authored-By: Claude Sonnet 5 --- .claude/rules/testing.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index dad6022ed..ee415a835 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -49,9 +49,12 @@ vi.mock('$app/environment', () => ({ })); describe('MyStore', () => { + let store: MyStore; + beforeEach(() => { browserState.value = true; localStorage.clear(); + store = new MyStore(); // constructed after the flag flips — reads localStorage }); // … }); @@ -64,7 +67,12 @@ describe('MyStore in SSR', () => { }); ``` -In jsdom files, do **not** stub localStorage with `vi.stubGlobal` — use jsdom's real `Storage` and assert on state (`localStorage.getItem(key)`), not on spy calls. Assert SSR by constructing a fresh store with data already in localStorage: it must return the default and leave `getItem(key)` null. +**Never assert a browser branch on the import-time singleton.** The flag is `false` while the module graph is evaluated, so the exported singleton is always built in SSR mode and never touches localStorage — asserting on it under `browser = true` is the same false-positive as the double-`vi.mock` above. Construct a fresh instance inside the browser `describe` (which is why the store class, not just the singleton, needs a named export). The singleton is still worth one assertion: that import-time construction is SSR-safe. + +In jsdom files, do **not** stub localStorage with `vi.stubGlobal` — use jsdom's real `Storage` and assert on state (`localStorage.getItem(key)`), not on spy calls. Cover both SSR guards with separate cases, since one assertion cannot carry both: + +- **read guard**: pre-seed localStorage, construct a fresh store, expect the _default_ (the pre-seeded value stays in storage, so do not expect `getItem(key)` to be null here) +- **write guard**: start from empty localStorage, call the setter, expect `getItem(key)` to still be null ## Unit Testing Patterns