Skip to content

fix: SearchPanel test の full-suite 限定 timeout を実描画量の削減で解消する - #507

Open
ymnao wants to merge 4 commits into
mainfrom
fix/searchpanel-fullsuite-timeout
Open

fix: SearchPanel test の full-suite 限定 timeout を実描画量の削減で解消する#507
ymnao wants to merge 4 commits into
mainfrom
fix/searchpanel-fullsuite-timeout

Conversation

@ymnao

@ymnao ymnao commented Aug 17, 2026

Copy link
Copy Markdown
Owner

概要

SearchPanel.test.tsx の段階表示 test がフルスイート実行時のみ間欠的に testTimeout する問題 (#501) を、timeout を伸ばさず実描画量を落とす方向で解消した。

実測で主因を特定した結果、単体実行でも当該 file は tests 6.91s かかっており、内訳は次のとおりだった(performance.now() の probe で計測、probe は削除済み)。

区間 実測
空 panel の render 12ms
input change 48ms
debounce 300ms + 500 行描画(waitFor 込み) 390ms
「さらに表示」click + 700 行再描画 530ms

支配項は 描画件数そのもの(CPU バウンド)、次いで fake timer 非使用による実時間 debounce 300ms。フルスイートの並列実行で CPU が競合すると、この CPU バウンド分が数倍に伸びて既定の testTimeout (5s) に届いていた。

関連 Issue

closes #501

移行 Stage

  • Stage 0: 雛形 + フロント表示確認
  • Stage 1: ファイル I/O
  • Stage 2: ワークスペース・ファイルツリー・ファイル監視
  • Stage 3: 全文検索(ripgrep sidecar)
  • Stage 4: Git Sync
  • Stage 5: OGP / PDF / アップデート
  • Stage 6: 仕上げ・配布・切り替え

変更内容

  • MATCH_DISPLAY_STEPsrc/types/search.ts へ移設した。使用元の SearchPanel.tsx に置いたままではテストが定数だけを差し替えられない。理由は実測で確認した: 定数とそれを読む関数を持つ module を vi.mock + importOriginal で partial mock すると、export は差し替わるが module 内部の参照は元の値を読み続ける(関数自身はモックされない)。production の値は 500 のまま、外形 API も不変
  • テストは vi.hoistedSTEP(=5) を 注入値と入力件数の単一の出所にした。件数は STEP + 2 / STEP + 1 / STEP * 3 と関係式で表し、手動同期の余地を無くしている
  • 兄弟 panel (BacklinkPanel / UnresolvedLinksPanel) の idiom に合わせて fake timer 化waitFor を撤去した。.then 経路があるので advanceTimersByTimeAsync を使う
  • 注入で隠れる 実値 500 は vi.importActual でモックを迂回して 1 本 pin した(ユニット総数 3165 → 3166)
  • リセット test の必要条件は「2 回目の総件数 > STEP」だけであることを実測で確認し、コメントを実際の必要条件に合わせた(当初「初期値と stale 値の狭間」と書いたが、上限は判別性に寄与しない)

動作確認

  • 当該テスト: 1307ms → 31ms、file 全体: tests 6.91s → 156ms
  • フルスイート 10 回反復で SearchPanel の fail 0(うち 7 回は CPU 競合で duration が 60〜125s まで伸びた高負荷条件)
  • mutation 4 種すべて KILL(下記エビデンス参照)
  • production 挙動は不変(SearchPanel.tsx の差分は import 1 行のみ、定数値も 500 のまま)
検証エビデンス

リスク分類

tier: medium — reasons: (classify-risk.sh の出力は {"tier": "medium", "reasons": []} で、理由列挙は空)

実行した検証

種別 コマンド 結果
テスト(単体) vitest run --project renderer src/components/search/SearchPanel.test.tsx PASS 10 passed / tests 156ms(修正前 9 passed / tests 6.91s)
テスト(フルスイート) env -u GIT_CONFIG_* vitest run PASS 139 files passed / 1 skipped、3166 passed / 16 skipped
テスト(反復) 上記フルスイートを計 10 回 SearchPanel の fail 0 回(別 file の既存 flaky は下記「追跡先」参照)
Lint biome check src/ electron/ PASS 331 files / No fixes applied
型チェック tsc --noEmit -p tsconfig.node.json / tsconfig.web.json / tsconfig.e2e.json PASS(3 本すべて)
ビルド electron-vite build PASS(✓ built in 469ms、警告は既存の INEFFECTIVE_DYNAMIC_IMPORT のみ)
レビュー codex-review security PASS / 0 findings
レビュー /simplify 4 観点(reuse / simplification / efficiency / altitude)× 2 周 1 周目 3 件 fix、2 周目 1 件 fix
レビュー code-reviewer サブエージェント(Fable 系統)× 2 周 1 周目 Warning 1 + Suggestion 3、2 周目 Suggestion 1(Critical 0)
e2e ローカル未実施(sandbox 制約)→ CI で確認 PASS(e2e 3m21s / electron-e2e 1m2s)

CI は全 9 job pass(lint 25s / typecheck 28s / test 1m24s / e2e 3m21s / electron-e2e 1m2s / build 25s / dependency-review 4s / win32-fs-probe 49s / Analyze (javascript-typescript) 1m2s)。

mutation 検証

mutant 期待 結果
types/search.ts の 500 → 499 実値 pin のみ落ちる KILL(1 failed / 9 passed)
SearchPanel.tsx.thensetVisibleCount(MATCH_DISPLAY_STEP) を削除 リセット test が落ちる KILL(1 failed / 9 passed)
mock factory から MATCH_DISPLAY_STEP の注入を外す render 4 本が落ちる KILL(4 failed / 6 passed)
注入値を actual.MATCH_DISPLAY_STEP に差し替え(注入無効化) render 4 本が落ちる KILL(4 failed / 6 passed)

3・4 番目が「注入が効かなくなったら必ず落ちる(silent pass しない)」性質を pin している。

レビュー指摘と対応

指摘 対応
[Warning] リセット test のコメント「初期値と stale 値の狭間に置く」が上側について偽 fix436f6c0)。総件数 15 でもリセット欠落時は 10 件描画で toHaveLength(STEP) は落ちるため、必要条件は「STEP より大きい」だけ
[Suggestion] MATCH_DISPLAY_STEP が IPC 共有値と誤読されうる(Altitude レビューと独立に収束) fix436f6c0)。「IPC 境界をまたがない renderer 専用値」であることと配置理由を注記
[Suggestion] 件数表示の期待文字列だけ手動同期が残っていた fix436f6c0)。STEP から導出
[Suggestion] 配置理由の機構説明「component 自体がモックされる」が不正確(2 周目、2 レビューが独立に収束) fixaa1ba51)。probe で実測し(export は 5 に差し替わるが module 内部は 500 を読み、関数はモックされない)、実測した機構に書き直した
[Simplification] 件数 literal が STEP との関係を隠していた / 冒頭コメントが 2 つの Why not を同居 / 1 テストのための describe fix75ca6ee
[Suggestion] truncated: true + 少件数 fixture が production では実現不能 Tier3 と判定し不採用(notice が flag 駆動であることを利用した意図的な fixture、実害小)

追跡先

backlog delta: 起票 0 件 / 本 PR で close 1 件(#501)/ 現在 open 13 件

Finding (file:line — summary) 行き先 URL / 記録
src/components/search/SearchPanel.tsx:117 — 「さらに表示」で表示済み行も再描画 (c) 対応しない 追跡しない (user 指示: 分類表を承認)。修正には match 行の memo 化と onNavigate の identity 安定化が要り、「production 挙動は不変」という本 PR の柱を崩してレビュー範囲を広げるため見送り。実測値 530ms は commit 13ef95a に残る
EmojiInputDialog / SlideView / MarkdownEditor / slide-render — 高負荷下で testTimeout (b) 既存 issue へ追記 #419 (comment)

Draft 判定

ymnao added 4 commits August 17, 2026 00:43
- 実測で主因を特定した。単体実行でも当該 file は tests 6.91s あり、内訳は
  「さらに表示」click + 700 行再描画が 530ms、debounce 300ms + 500 行描画が
  390ms。フルスイートの CPU 競合下でこの CPU バウンド分が数倍に伸びて
  testTimeout (5s) に届いていた
- MATCH_DISPLAY_STEP を types/search.ts へ移した。SearchPanel.tsx 内に置いた
  ままではテストが定数だけを差し替えられない (component file を vi.mock すると
  SearchPanel 自体がモックされる)。依存の末端である types へ動かして部分モック
  可能にした。置き場所も MAX_SEARCH_RESULTS と揃う
- テストは step=5 を注入し、件数を 7 / 6 / 15 系へ。リセット test の
  「初期値 5 < 新検索 7 <= stale 値 10」の狭間関係は保存している
- 期待値は変数ではなく literal にした。変数だと注入が効かなくなったとき
  500 に追随して黙って pass し、再発が観測に出ない
- 兄弟 panel の idiom に合わせて fake timer 化し waitFor を撤去した。
  .then 経路があるので advanceTimersByTimeAsync を使う
- 実値 500 は vi.importActual でモックを迂回して 1 本 pin した
- 件数を裸の literal (7 / 6 / 15) にしたことで、STEP との関係が式から消えて
  いた。特にリセット test の「2 回目の総件数を初期値と stale 値の狭間に置く」
  という要は、15 / 10 / 7 の並びからは読み取れなくなっていた
- vi.hoisted で test 固有の STEP を 1 箇所に置き、mock 注入と入力件数の
  両方をそこから導く。手動同期の余地を無くす
- STEP は test 固有なので production の 500 に追随しない。よって期待値にも
  STEP を使ってよく、literal に固定する必要が無くなった。根拠が変わったので
  コメントも「import しない理由」へ書き換えた
- 1 テストだけの describe を外して top-level it にした
- 「初期値と stale 値の狭間に置く」と書いたが、上限は判別性に寄与しない。
  総件数 15 でもリセット欠落時は 10 件描画で toHaveLength(STEP) は落ちる。
  区別できなくなるのは総件数が STEP 以下のときだけなので、必要条件は
  「STEP より大きい」だけ。設計判断の根拠として引用されると誤るので直す
- MATCH_DISPLAY_STEP に「IPC 境界をまたがない renderer 専用値」であることと
  「なぜ SearchPanel.tsx ではなくここに居るか」を書いた。隣の
  MAX_SEARCH_RESULTS が IPC 共有値の置き場としてこの module を枠付けて
  いるため、注記が無いと main 側から流用される余地が残る
- 件数表示の期待文字列も STEP から導出し、手動同期の残りを無くした
- 「component file を vi.mock するとコンポーネント自体がモックされる」と
  書いたが、これは factory 無しの auto-mock でしか真でない。importOriginal を
  併用する実際の書き方ではコンポーネントはモックされない
- probe で実測した (定数と、それを読む関数を持つ module を partial mock):
  export は 5 に差し替わる一方、module 内部の関数が読む値は 500 のまま、
  関数自身はモックされていない。差し替えが効かない真因はこちら
- 誤った理由を残すと「partial mock なら定数だけ差し替えられる」という
  代替案の再提案を招くので、実測した機構に置き換えた
- 独立した 2 つの Why not の間に空行を入れた
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SearchPanel.test.tsx の段階表示 test が full-suite 実行時のみ timeout する

1 participant