diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fdd26324b..930b397a9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -26,6 +26,15 @@ on: - "pytest.ini" - "pyproject.toml" +# 同じ Pull Request で新しい push が来たら、古い実行を取り消す(#2154・#2291 の決定 5)。 +# push(main / develop)は実行ごとに別の組へ入れて最後まで回す。同じ組に新しい実行が入ると、 +# 待っている前の実行は cancel-in-progress が false でも取り消され、コミットごとの記録が欠けるため。 +# 取り消した実行でも集約のジョブ pytest は always() で走り、古いコミットに失敗を付ける。 +# head のコミットのチェックには新しい実行の結果だけが付く +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: # 配布の PR(版数と説明だけの差分、または develop で通ったコミットを進めるだけの main 宛の # PR)では重いジョブを省く(#1055)。判定は scripts/ci-heavy-skip.py が差分と develop の @@ -75,8 +84,11 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" - - name: Install uv - run: python3 -m pip install --upgrade uv + # uv と依存の取得を uv.lock をキーにしたキャッシュで縮める(#2291 の決定 6) + - uses: astral-sh/setup-uv@v10.3.3 + with: + enable-cache: true + cache-dependency-glob: uv.lock # 起点はリポジトリの根に置く。環境は根の pyproject.toml と uv.lock が固定する(#1142 の決定 22)。 # playwright_kit・plugins/ndf の全グループ(--all-extras)・pytest と pytest-xdist が入り、 # 包みを呼ぶ側のテストも同じプロセスで外部パッケージを import できる。 diff --git a/conftest.py b/conftest.py index 79a4edaf8..ef6f8edb1 100644 --- a/conftest.py +++ b/conftest.py @@ -16,6 +16,8 @@ シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678) 6. `SHARD_TOTAL` と `SHARD_INDEX` が与えられたら、収集した項目をファイル単位で分け、 自分の分だけを残す。継続的統合がジョブを分けて回すために使う(#882) +7. pytest が SIGTERM で止められたとき・終わったときに、テストが起こした子孫のプロセスを止める + (`scripts/pytest_child_cleanup.py`。#2291) どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は 根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。 @@ -37,9 +39,11 @@ # `pytester` は playwright-kit の pytest plugin の自己テストが使う。pytest はこの宣言を # 最上位の conftest.py でしか受け付けない。 -pytest_plugins = ["pytester"] - +# `pytest_child_cleanup` は、テストが起こした子孫のプロセスを、pytest が SIGTERM で止められたとき・ +# 終わったときに止める(#2291)。親と xdist の各 worker で働く。 ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "scripts")) +pytest_plugins = ["pytester", "pytest_child_cleanup"] # `plugins//dev.agy/skills/` は配布 Skill への symlink である。実体は # `plugins//skills/` にあり、そちらを収集する。両方をたどると同じテストが 2 度 diff --git a/docs/ndf-experiments.md b/docs/ndf-experiments.md index f39a13bd0..b5000698e 100644 --- a/docs/ndf-experiments.md +++ b/docs/ndf-experiments.md @@ -16,6 +16,7 @@ | `runner-trial.py` | #1142 の不足 i の設計の前に、プランの実行とキューを置き換える候補(LangGraph の SqliteSaver・Burr の SQLitePersister・DBOS Transact の SQLite)で、`supervise.py new impl` の雛形(ステップを sleep と決めた終了コードの偽物にしたもの)・キュー・kill -9 からの再開・進捗ログと `supervise.py wait` を保てるかを確かめる | 2026-09-26・`exp/runner-trial` | 2026-09-26: 3 候補ともサーバーを立てず(プロセスの待ち受けのポート 0)SQLite のファイルだけで動き、確かめた 5 つが成り立った(`check <候補>` の結果がすべて ok)。表せるか: `on_fail`・judge の選択・上限 20・承認ゲート(止まり、承認の無い打ち直しは同じ所で止まったまま、承認で続く)を 5 つのシナリオで supervise.py の規則どおりの経路で流した。LangGraph の `interrupt()` は続けるときノードを頭から流し直すため、止める前の副作用はノードの外に置く。`recursion_limit` は invoke ごとに数え直すため、上限は状態で数える。Burr は遷移を `when(cur=...)` で行き先ごとに並べ、承認ゲートは `halt_before` で止める。DBOS は承認ゲートで `DBOS.recv` の待ちのままプロセスを抜け(`os._exit`)、打ち直しの `DBOS.launch()` が記録から続ける(落ちた後は状態が ENQUEUED に見える)。キュー: 6 本(ステージ 4 本 → 2 本)を同時 2 本・GraphQL の枠 1 本で流し、同時は最大 2 本・GraphQL は最大 1 本(枠の待ちが 1〜8 回起きた)で、後のステージは前のステージがすべて終わってから流れ、前のステージの 1 本が止まると後のステージは流れなかった。キューを持つのは DBOS だけ(`register_queue` の `worker_concurrency`。資源のタグは別のキューの `concurrency`)。LangGraph と Burr はキューを持たないため、子プロセスと flock の枠で自前にした(約 60 行)。6 本の所要は LangGraph 6.9 秒・Burr 6.0 秒・DBOS 12.4 秒(DBOS はキューの問い合わせの間隔 0.1 秒の分だけ遅い)。再開: 3 候補とも kill -9 のときに流れていたステップ(test-limited)から流し直し、済んだステップは流し直さなかった。費用(uv 0.12.19・キャッシュの冷えた状態。LangGraph / Burr / DBOS の順): 解決 0.28 / 0.45 / 0.21 秒、導入 0.32 / 0.20 / 0.45 秒(キャッシュが温まって 0.03 / 0.01 / 0.01 秒)、大きさ 54.9 / 20.2 / 60.6 MB、依存 41 / 2 / 11 件(LangGraph は langchain-core・langsmith・pydantic-core を、DBOS は SQLite でも SQLAlchemy・psycopg-binary を入れる)、import 冷えて 0.82 / 0.06 / 0.78 秒・温まって 0.26 / 0.04 / 0.24 秒、起動し直しを含むエントリポイント(温まって)0.31 / 0.08 / 0.31 秒。進捗ログと wait: 進捗ログの行(step・attention)はライブラリでなく NDF の部品がステップの中で書き、今の `supervise.py wait` は手を入れずに attention で 20、done で 0 を返した | 本体へ移した(DBOS Transact を SQLite で `lib/durable.py` の包みから使う。#1142 のスプリント 2c: 依存と包み #1538・プランの実行 #1548・投稿キュー #1545・cross-review の drive #1540・cross-refactoring の drive #1541・キューと重なりと資源の枠 #1551)。試行は Q6 で消した | | `hook-trial.py` | #1142 の決定 20(hook を 1 本の Python のエントリポイントへまとめる)の前に、成り立ちと退行を確かめる。今の hook のテストの入力のすべてを tree-sitter-bash(py-tree-sitter)で判定して今の判定と比べる・hook 1 回の所要・環境が無いときに判定をせずに通るか・claude-agent-sdk が `supervise_lib/claude.py` の `claude_cmd`・`call_claude` の契約を満たすか・bump-my-version が `release-steps.py` の `cmd_bump` の 15 箇所の書き換えを満たすか | 2026-09-26・`exp/hook-trial` | 2026-09-26: 5 つのうち 3 つが成り立ち、2 つが成り立たなかった。判定: hook のテスト(3121 件)が渡した入力を集めた。書き込み先の推定 552 件・コマンド 546 件のうち、前景の sleep とプランを起こす副命令は全件で一致した。書き込み先は 2 件が食い違った。`! case … esac` は tree-sitter-bash が読めず(否定した複合コマンドは ERROR になる)、worktree の側と取り違える。`cp a.txt $(basename b).txt` は今の実装が `b).txt` を出し、テストがそれを期待として固定している(tree-sitter の側は展開を含む語として出さない)。構文木の癖が 5 つあり、包みの側で直した: 並びとパイプの末尾のリダイレクトが並び全体に付く・リダイレクトの後ろの語が destination として並ぶ・`<>` が ERROR になる・`time` がコマンド名になる・ヒアドキュメントの本文の `$((…))` をコマンド置換と読み、バッククォートは読まない。所要(40 回ずつの中央値。宣言のあるリポジトリ): 今の `worktree-guard.sh` は 26.8〜76.1 ms、1 本のエントリポイント(用意済みの環境の `bin/python` を直に起動)は 20.1〜21.6 ms で、どの入力でも速かった。内訳は python の起動 6.9 ms・tree-sitter の import 込み 16.9 ms。環境が無いとき: hook の command を `sh -c '[ -x "$0" ] || exit 0; exec "$0" "$1"'` の形にすれば、出力なし・終了コード 0 で通る。python を直に指す形は終了コード 127 で終わる。tree-sitter を import できない python でも終了コード 0 で通る。claude-agent-sdk 0.2.160: 3 つの形(最小構成・work の Tool と Serena・Skill を回す形と `--resume`)の引数を今の引数と同じに組めた。打ち切ると子のプロセスは残らない。結果の値(本文・使用量・modelUsage・費用・往復・セッション)の形も同じだった。ただし 2 つが成り立たない。1 つ目は、今のテストの偽物の CLI(JSON を 1 つ書く)が SDK の下で動かないこと。2 つ目は、同じ依頼(haiku)で入力が 281 → 1039 トークン、費用が 2.73 倍になり、`usage` の入力(281)が modelUsage(1039)と合わないこと。同梱の CLI でも手元の CLI(どちらも 2.1.283)でも同じだった。bump-my-version 1.5.1: `10.17.30-dev.1` → `-dev.2` と → `10.17.31` の両方で、`cmd_bump` と同じ差分(7 と 8 ファイル)を書いた。手で直す箇所の報告と `check-doc-staleness.py` の実行は NDF の側に残る。2026-09-26(T2b): SDK の 2 点はどちらも SDK の設定で揃えられた。費用: SDK は子の CLI へ `CLAUDE_AGENT_SDK_VERSION` を必ず立て、CLI はこれを見て会話のタイトルを作る呼び出し(haiku・`source=generate_session_title`)を本体の前に 1 回足す。`usage` は本体の呼び出しだけを、modelUsage と費用は両方を数えるため合わない。`claude -p` の直の起動(`CLAUDE_CODE_ENTRYPOINT=sdk-cli`)では足さない。引数と環境を 1 つずつ入れ替えて測ると、`--input-format stream-json`・`CLAUDECODE` を外すこと・`CLAUDE_CODE_SDK_READS_SESSION_STATE` は入力を変えず、`CLAUDE_AGENT_SDK_VERSION` か `CLAUDE_CODE_ENTRYPOINT=sdk-py` を立てたときだけ 275 → 1033 トークンになった。`ClaudeAgentOptions(env={"CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1"})` で呼び出しが止まり、`hook-trial.py sdk --live --runs 3` で入力 280 トークン(usage と modelUsage が一致)・費用 1.01 倍(止めない形は 1038 トークン・2.77 倍)になった。`CLAUDE_CODE_ENTRYPOINT` を `env` で上書きしても `CLAUDE_AGENT_SDK_VERSION` が残るため止まらない。偽物の CLI: SDK は起動の直後に stream-json の `control_request`(`initialize`)を書いて `control_response` を待つ。今の偽物は標準入力の EOF を待つため双方が待ち合って止まる。制御の要求へ答えて `user` を 1 つ読み `result` を 1 つ書く偽物(`cli_path`・約 15 行)と、プロセスを起こさない `query(transport=...)` の差し替えは、どちらも ResultMessage を返した | 本体へ: tree-sitter-bash と hook の 1 本化はスプリント 2b の D5、bump-my-version は D7。claude-agent-sdk は採らない(`issues/issue-1142-design-libraries.md` の決定 24) | | `training-optout.py` | LLM へ入力を渡す前に、そのアカウントが入力を学習に使わない設定かを 1 行の JSON で返す。claude は OAuth の `account/settings` の `grove_enabled`、他は `unsupported` | 2026-10-02・`exp/training-optout` | 2026-10-02: Claude Max の実物のアカウントで `training: false`(`grove_updated_at` 2026-08-13)・終了コード 0。`--runtime kiro` を足すと `unsupported` で 3 | 2026-10-02: 本体へ昇格(`plugins/ndf/scripts/training-optout.py`。`check` に codex の読み取り、`session-start` で SessionStart の書き換えを足した。#1597) | +| `whole-test-timing.py` | 全体テストを宣言の順に同じ条件(開始時の load average・junit の集計)で測り、件数・各テストの所要の合計・遅いテストの上位 30 件・ファイルの上位 10 本を出す。着手前後のテストの集合を台帳と照らし、台帳に無い消えたテストと skip に変わったテストを出す | 2026-10-31・`feat/issue-2291-l20261031-5` | 2026-10-31: #2291 の着手前後の計測と台帳の照合に使った(結果は `issues/issue-2291-test-ledger.md`) | 未定 | ## 実験版の仕組みそのものの直し diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index c693a8a39..205cfd2e4 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -294,6 +294,29 @@ uv run --with pytest --with pytest-xdist pytest scripts/tests -q -n auto 対象プロジェクト(`pyproject.toml`)が無く、`pytest` が環境にも入っていないためです。 `plugins/ndf/skills/*/tests/` の既存のテストも同じ形で実行します。 +### テストを削る基準 + +全体テスト(`.ndf/project.json` の `test.suites` の全部)は手元で 180 秒以内に終わる大きさに保つ。テストの数がそのまま全体テストの長さと保守の費用になるため、守る振る舞いを別のテストが確かめているテストは残さない。次のどれかに当たるテストは消すか、1 件にまとめる。 + +| # | 基準 | 例 | +| --- | --- | --- | +| 1 | 重複: 同じ経路を、別の名前で同じ入力から確かめている | 同じ CLI を同じ引数で起動し、同じ終了コードを見るテストが 2 件ある | +| 2 | 消えた機能の残骸: 廃止した機能・オプションを確かめている | 外した副命令が「無い」ことだけを確かめる | +| 3 | `.md` の文言の固定 | Skill の本文に決まった文があるかを照合する | +| 4 | 実装の細部の固定: 内部の関数名・呼び出しの回数・中間の値を固定し、利用者に見える振る舞いを確かめていない | 判定は NDF の `tdd-cycle` の `references/test-quality.md` の「判定の基準」に従う | +| 5 | 総当たり: 1 つの振る舞いを、全テンプレート・全組み合わせで確かめている | 代表は「分岐ごとに 1 つ・境界の両側」で選び、残りを消す | +| 6 | suite・CI のジョブが既に走らせる検査を、テストの中でもう一度走らせる | `script-structure` の suite と同じ検査を、テストがリポジトリ全体へ流す | + +消す・まとめるときは、1 件ごとに、当たった基準の番号と、守っていた振る舞いを今後どのテストが守るか(守らなくてよいならその理由)を PR から追える形で残す。落ちるから消すのは基準に当たらない。 + +遅いテストを速くするときは、確かめる振る舞いを弱めずに次の 3 つの手段を使う。テストを速くするためにテストの対象のコードを変えない。 + +- 待ちの値を、対象のコードが既に持つ環境変数で縮める +- 1 つの CLI の配線(引数・終了コード・結果 JSON)は代表の数件の起動で確かめ、分岐はプロセスの中で関数を呼んで確かめる。スクリプトの起動だけで 1 件 0.2〜0.4 秒かかる +- git のリポジトリのような重い準備は、プロセスごとに 1 度作って複製する + +テストが起こした子孫のプロセスは、pytest が SIGTERM で止められたときと終わったときに、根の `conftest.py` が読み込む `scripts/pytest_child_cleanup.py` が止める。テストの中で止め忘れても次のテストへ持ち越さないが、テストは自分で起こしたプロセスを自分で止める。 + ローカル hook は任意で導入できます。 ```bash diff --git a/issues/issue-2291-test-ledger.md b/issues/issue-2291-test-ledger.md new file mode 100644 index 000000000..6cdd39db2 --- /dev/null +++ b/issues/issue-2291-test-ledger.md @@ -0,0 +1,94 @@ +# 全体テストの台帳(#2291・#2204) + +削る基準の番号は `docs/plugin-development-guide.md` の「テストを削る基準」の表の番号である。計測は実験版の +`plugins/ndf/scripts/experimental/whole-test-timing.py`(`measure` と `diff`)で取った。 + +## 着手前後の実測 + +計測のマシンはこの開発コンテナ(CPU 8 個・メモリ 30 GB)、並列の数は宣言のまま(pytest の `-n 4`)。 + +| 計測 | 日時(JST) | 開始時の load average | 壁時計 | suite ごとの秒 | 件数(skip) | 各テストの所要の合計 | +| --- | --- | --- | --- | --- | --- | --- | +| 着手前 | 2026-10-31 23:21 | 7.63 | 962.9 秒 | pytest 932.5 秒・factory 15.8 秒・script-structure 8.9 秒・lint 5.7 秒(pytest は 1 件が落ちた。根の conftest の文字列を固定していた `scripts/tests/test_root_conftest.py::test_the_root_conftest_declares_the_plugin` で、後始末の部品を根の conftest に足した後に測ったため。下の表で消した) | 10777(4) | 3454.6 秒 | +| 着手後 | 2026-11-01 01:46 | 1.33 | 635.8 秒 | pytest 607.5 秒・factory 14.8 秒・script-structure 8.4 秒・lint 5.1 秒 | 10781(4) | 2273.8 秒 | + +- 着手前と着手後で開始時の load average が違う(7.63 と 1.33。どちらも CPU の数 8 以下)。所要の差には負荷の差も入っている。後始末の部品とテストの書き直しの前半を入れた後の途中の計測(load average 1.31)は壁時計 623.0 秒・合計 2,295.0 秒で、着手後とほぼ同じだった +- 180 秒に届いていない。着手後の壁時計 635.8 秒のうち pytest が 607.5 秒で、残りの 3 つの suite は計 28.3 秒である。180 秒に入れるには pytest を約 150 秒(各テストの所要の合計で約 550 秒)にする必要があり、今の 2,273.8 秒から約 1,700 秒を減らす差が残る +- 0.3 秒未満のテスト(途中の計測で 9,028 件)だけで合計 481 秒ある。残りの時間は、本物のスクリプトと子のプロセスを起こして待つ結合テスト(中継・supervise の queue と再開・cross-review と cross-refactoring の drive)に集まっている +- 照合(`whole-test-timing.py diff`): 台帳に無い消えたテスト 0 件・skip に変わったテスト 0 件。着手後の件数が増えたのは、後始末の部品と計測の実験版のテストを足したため + +## 遅いテストの表 + +「速くした」は、ファイルを書き換え、着手後の秒が着手前の 8 割未満になったもの。「そのまま」は書き換えていないか、縮みが 2 割に届かないもの(負荷の差による縮みを含む)。 + +### 1 本あたりの秒の長い順の上位 30 件(着手前) + +| # | テスト | 着手前の秒 | 処置 | 着手後の秒 | +| --- | --- | --- | --- | --- | +| 1 | `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_template_plan_gets_through_each_fix_loop_three_times` | 78.52 | まとめた | 14.39 | +| 2 | `scripts/tests/test_root_conftest.py::test_a_bundle_outside_the_table_is_not_checked` | 41.70 | 速くした | 0.48 | +| 3 | `plugins/ndf/skills/cross-refactoring/tests/test_fix_rules_git.py::test_items_failing_the_same_structure_check_are_dropped_after_one_fix` | 18.72 | そのまま | 12.20 | +| 4 | `plugins/ndf/scripts/tests/test_supervise_contract.py::test_results_keep_their_keys_and_exit_codes` | 16.82 | 速くした | 7.49 | +| 5 | `plugins/ndf/scripts/tests/test_drive_refactor.py::test_refactor_drive_workflow_step_finishes_with_counts` | 12.91 | そのまま | 5.04 | +| 6 | `scripts/tests/test_check_script_structure.py::test_repository_matches_its_allow_list` | 12.76 | 消した | — | +| 7 | `plugins/ndf/scripts/tests/test_relay.py::test_run_waits_for_user_input_quiet` | 11.45 | 速くした | 6.51 | +| 8 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_the_release_without_a_gap_says_nothing_more` | 10.66 | そのまま | 5.78 | +| 9 | `plugins/ndf/scripts/tests/test_drive_refactor.py::test_refactor_drive_pauses_for_cross_review_then_finalizes` | 9.99 | そのまま | 4.95 | +| 10 | `plugins/ndf/scripts/tests/test_relay.py::test_slow_usage_endpoint_does_not_stall_relay` | 9.88 | 速くした | 4.82 | +| 11 | `plugins/ndf/scripts/tests/test_supervise_contract.py::test_subcommands_keep_their_arguments` | 9.13 | 速くした | 0.10 | +| 12 | `plugins/ndf/scripts/tests/test_base_sync.py::test_impl_route_goes_back_three_times_and_merges` | 8.42 | そのまま | 6.84 | +| 13 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py::test_the_same_head_returns_the_previous_result` | 7.77 | そのまま | 3.12 | +| 14 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_a_conditional_stage_without_a_record_is_shown_apart` | 7.76 | そのまま | 4.95 | +| 15 | `plugins/ndf/scripts/tests/test_supervise_queue.py::test_a_needed_plan_of_an_earlier_stage_or_with_a_gate_record` | 7.52 | そのまま | 5.48 | +| 16 | `plugins/ndf/scripts/tests/test_base_sync.py::test_check_route_goes_back_and_reaches_record` | 7.52 | 速くした | 5.99 | +| 17 | `scripts/tests/test_agy_distribution.py::test_generated_links_are_not_collected_twice` | 7.50 | そのまま | 5.75 | +| 18 | `plugins/ndf/scripts/tests/test_relay.py::test_run_spin_broken_by_long_section` | 7.21 | そのまま | 6.25 | +| 19 | `plugins/ndf/scripts/tests/test_relay.py::test_guard_question_blocks_exit` | 6.88 | 速くした | 3.05 | +| 20 | `plugins/ndf/scripts/tests/test_supervise.py::test_queue_tells_only_the_files_of_plans_running_when_the_worker_starts` | 6.50 | そのまま | 6.05 | +| 21 | `plugins/ndf/skills/cross-refactoring/tests/test_whole_test_triage_git.py::test_a_failure_caused_by_the_change_goes_to_fix_and_is_kept_when_fixed` | 6.34 | そのまま | 4.51 | +| 22 | `plugins/ndf/scripts/tests/test_supervise_queue.py::test_tagged_steps_do_not_exceed_the_resource_limit` | 6.13 | そのまま | 5.81 | +| 23 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py::test_a_record_without_head_returns_the_previous_result` | 6.10 | そのまま | 2.98 | +| 24 | `plugins/ndf/scripts/tests/test_supervise_queue_continue.py::test_resume_continues_from_the_stopped_step_and_runs_the_next_stage` | 6.09 | そのまま | 5.58 | +| 25 | `scripts/tests/test_lock_common.py::test_the_timeout_override_reaches_only_the_stage_state` | 5.96 | そのまま | 5.94 | +| 26 | `plugins/ndf/skills/cross-refactoring/tests/test_stop_revert_git.py::test_plan_a_reverts_only_the_culprit_and_the_rechecked_gate_passes` | 5.90 | そのまま | 3.11 | +| 27 | `plugins/ndf/skills/cross-refactoring/tests/test_whole_test_triage_git.py::test_without_time_to_fix_the_newest_flagged_items_are_reverted_until_it_passes` | 5.83 | そのまま | 5.09 | +| 28 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_the_record_stage_of_supervise_is_recorded` | 5.78 | そのまま | 2.10 | +| 29 | `plugins/ndf/scripts/tests/test_drive_review.py::test_review_drive_pauses_for_fix_then_sweep_then_finishes` | 5.72 | そのまま | 4.45 | +| 30 | `plugins/ndf/skills/cross-refactoring/tests/test_stop_revert_git.py::test_every_child_limit_from_the_cut_off_to_the_recheck_ends_by_the_end` | 5.61 | そのまま | 3.07 | + +### ファイルごとの合計の上位 10 本(着手前) + +| # | ファイル | 着手前(件・秒) | 処置 | 着手後(件・秒) | +| --- | --- | --- | --- | --- | +| 1 | `plugins/ndf/scripts/tests/test_relay.py` | 423 件・303.0 | 速くした | 423 件・205.5 | +| 2 | `plugins/ndf/scripts/tests/test_supervise.py` | 184 件・181.6 | 速くした | 184 件・143.1 | +| 3 | `plugins/ndf/scripts/tests/test_supervise_limits.py` | 15 件・103.2 | 速くした | 15 件・31.4 | +| 4 | `plugins/ndf/scripts/tests/test_supervise_queue.py` | 35 件・99.4 | そのまま | 35 件・85.7 | +| 5 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py` | 25 件・71.7 | そのまま | 25 件・48.7 | +| 6 | `plugins/ndf/scripts/tests/test_glossary.py` | 105 件・71.0 | 速くした | 105 件・7.9 | +| 7 | `plugins/ndf/scripts/tests/test_progress_record_stages.py` | 36 件・65.4 | そのまま | 36 件・32.2 | +| 8 | `plugins/ndf/scripts/tests/test_supervise_new.py` | 70 件・52.8 | 速くした | 70 件・11.1 | +| 9 | `plugins/ndf/scripts/tests/test_relay_account.py` | 54 件・51.4 | そのまま | 54 件・38.8 | +| 10 | `scripts/tests/test_root_conftest.py` | 18 件・48.9 | 速くした・消した | 15 件・5.2 | + + +## 消した・まとめたテスト + +| テスト | 処置 | 基準 | 守るテスト・守らなくてよい理由 | +| --- | --- | --- | --- | +| `scripts/tests/test_root_conftest.py::test_the_root_conftest_declares_the_plugin` | 消した | 4 | 根の conftest の文字列を固定していた。`pytester` が読まれなければ `plugins/playwright-kit/skills/playwright-kit-ops/tests` の `pytester` を使うテストが落ちる | +| `scripts/tests/test_root_conftest.py::test_the_failure_names_the_bundle_and_the_command` | まとめた | 1 | 同じ起動を `scripts/tests/test_root_conftest.py::test_a_missing_command_fails_the_collection` が行い、束とコマンドの名前も確かめる | +| `scripts/tests/test_root_conftest.py::test_the_prefix_is_declared_once` | 消した | 4 | 根の conftest の文字列を固定していた。接頭辞の除去は `scripts/tests/test_root_conftest.py::test_no_monitor_variable_survives_into_a_test` が守る | +| `scripts/tests/test_check_script_structure.py::test_repository_matches_its_allow_list` | 消した | 6 | suite `script-structure`(`python3 scripts/check-script-structure.py`)と CI の `.github/workflows/script-structure.yml` が同じ検査をリポジトリ全体へ流す | +| `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_template_plan_gets_through_each_fix_loop_three_times` | まとめた | 5 | 17 本の雛形のプランのうち、修正のループの入口の組が同じものは同じ遷移を通る。組ごとに 1 本(impl・check・release・release-prod の形)を流す `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_fix_loop_shape_gets_through_each_fix_loop_three_times` が守る。修正のループの無いプランは上限に届く経路が無い | + +## 打ち切りの確かめ(受け入れ条件 6・7) + +2026-11-01 02:00 JST ごろ に手元で確かめた。数えたのは、作業場所のパスをコマンドラインに含むプロセスのうち、打ち切る前から動いていた作業者のセッションと言語サーバーを除いたもの。 + +| 打ち切り方 | 打ち切る前 | 打ち切りから 10 秒後 | +| --- | --- | --- | +| `test_triage.run_command` に宣言の pytest のコマンドを上限 30 秒で渡す(cross-refactoring の着手前のテストと同じ打ち切る側。30.2 秒で打ち切った) | — | 0 件 | +| `pytest -n 4`(`test_relay.py`・`test_supervise_queue.py`)を 25 秒流し、親だけへ `kill -TERM` | 11 件 | 0 件 | + +`python3 plugins/ndf/scripts/test-run.py whole --limit 30` は、PR の無いこのブランチでは CI のチェックの結論を待つ側へ進み、手元のテストを流さなかったため、上の 1 行目で打ち切る側を直に呼んで確かめた。 diff --git a/plugins/ndf/scripts/experimental/whole-test-timing.py b/plugins/ndf/scripts/experimental/whole-test-timing.py new file mode 100644 index 000000000..75ee0f1cf --- /dev/null +++ b/plugins/ndf/scripts/experimental/whole-test-timing.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""whole-test-timing.py(試行): 全体テストを同じ条件で測り、着手前後のテストの集合を照らす(#2291)。 + + python3 whole-test-timing.py measure --root --out + python3 whole-test-timing.py diff --before --after --ledger + +measure は `.ndf/project.json` の `test.suites` の `command` を宣言の順に 1 本ずつ流し、最初の起動から最後の +終了までを壁時計とする。開始時の 1 分の load average が CPU の数を超えたら `reference`(判定に使わない参考の値)を +立てる。junit を宣言した suite は、junit の `time`(準備・本体・後始末の和)から件数・合計・遅いテストの上位 30 件・ +ファイルごとの合計の上位 10 本を出し、`--out` へ nodeid ごとの結果と秒を書く。 + +diff は着手前にあって着手後に無い nodeid のうち台帳に書かれていないものと、着手前に走っていて着手後に skip に +なった nodeid を出す。nodeid はパラメータの部分まで含めて文字どおりに照らす。 + +結果は lib/step_result.py の形の 1 行の JSON(`tool: "whole-test-timing"`)。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) +from step_result import EXIT_UNREADABLE, StepError, emit, main_with, result # noqa: E402 + +TOOL = "whole-test-timing" +TOP_TESTS = 30 +TOP_FILES = 10 + + +def load_suites(root: Path) -> list[dict]: + try: + data = json.loads((root / ".ndf" / "project.json").read_text(encoding="utf-8")) + suites = data["test"]["suites"] + except (OSError, ValueError, KeyError, TypeError) as e: + raise StepError(f".ndf/project.json の test.suites を読めない: {e}", EXIT_UNREADABLE) + if not isinstance(suites, list) or not all(isinstance(s, dict) and s.get("command") for s in suites): + raise StepError("test.suites の各要素に command が要る", EXIT_UNREADABLE) + return suites + + +def _nodeid(case: ET.Element) -> str: + """xunit1 の testcase から pytest の nodeid を組む(file と classname の差がクラスの部分)。""" + file = case.get("file") or "" + name = case.get("name") or "" + classname = case.get("classname") or "" + module = file[:-3].replace("/", ".") if file.endswith(".py") else "" + rest = classname[len(module) :].lstrip(".") if module and classname.startswith(module) else "" + parts = [file or classname, *(p for p in rest.split(".") if p), name] + return "::".join(parts) + + +def _outcome(case: ET.Element) -> str: + for tag in ("failure", "error", "skipped"): + if case.find(tag) is not None: + return {"failure": "failed", "error": "error", "skipped": "skipped"}[tag] + return "passed" + + +def read_junit(path: Path) -> dict[str, dict]: + """nodeid → {"outcome", "seconds"}。同じ nodeid が 2 度出たら(準備と後始末の誤り)秒を足し、悪い方の結果を残す。""" + try: + tree = ET.parse(path) + except (OSError, ET.ParseError) as e: + raise StepError(f"junit を読めない: {path}: {e}", EXIT_UNREADABLE) + tests: dict[str, dict] = {} + for case in tree.iter("testcase"): + nid = _nodeid(case) + sec = float(case.get("time") or 0.0) + out = _outcome(case) + if nid in tests: + prev = tests[nid] + prev["seconds"] = round(prev["seconds"] + sec, 3) + if out in ("failed", "error"): + prev["outcome"] = out + continue + tests[nid] = {"outcome": out, "seconds": round(sec, 3)} + return tests + + +def summarize_tests(tests: dict[str, dict]) -> tuple[dict, list[dict]]: + ran = {k: v for k, v in tests.items() if v["outcome"] != "skipped"} + metrics = { + "tests": len(ran), + "skipped": len(tests) - len(ran), + "sum_seconds": round(sum(v["seconds"] for v in tests.values()), 1), + } + slow = sorted(tests.items(), key=lambda kv: (-kv[1]["seconds"], kv[0]))[:TOP_TESTS] + files: dict[str, list] = {} + for nid, v in tests.items(): + f = files.setdefault(nid.split("::", 1)[0], [0.0, 0]) + f[0] += v["seconds"] + f[1] += 1 + top_files = sorted(files.items(), key=lambda kv: (-kv[1][0], kv[0]))[:TOP_FILES] + items = [{"kind": "test", "name": nid, "seconds": v["seconds"], "result": v["outcome"]} for nid, v in slow] + items += [{"kind": "file", "name": f, "seconds": round(s, 1), "count": n, "result": "sum"} for f, (s, n) in top_files] + return metrics, items + + +def run_suites(root: Path, suites: list[dict], log_dir: Path) -> tuple[list[dict], float]: + log_dir.mkdir(parents=True, exist_ok=True) + runs = [] + first = time.monotonic() + for s in suites: + name = str(s.get("name") or len(runs)) + start = time.monotonic() + with (log_dir / f"{name}.log").open("w", encoding="utf-8") as log: + p = subprocess.run(["bash", "-c", s["command"]], cwd=root, stdout=log, stderr=subprocess.STDOUT) + runs.append({"name": name, "seconds": round(time.monotonic() - start, 1), "exit": p.returncode}) + return runs, round(time.monotonic() - first, 1) + + +def cmd_measure(a) -> None: + root = Path(a.root or ".").resolve() + suites = load_suites(root) + load1 = os.getloadavg()[0] + cpus = os.cpu_count() or 1 + out = Path(a.out).resolve() + runs, wall = run_suites(root, suites, out.with_suffix(".logs")) + tests: dict[str, dict] = {} + for s in suites: + if s.get("junit"): + tests.update(read_junit(root / s["junit"])) + counted, items = summarize_tests(tests) + metrics = { + "load_average_1m": round(load1, 2), + "cpus": cpus, + "reference": load1 > cpus, + "wall_seconds": wall, + "suites": runs, + **counted, + } + out.write_text(json.dumps({"metrics": metrics, "tests": tests}, ensure_ascii=False, indent=1), encoding="utf-8") + failed = [r["name"] for r in runs if r["exit"] != 0] + status = "stopped" if failed else "ok" + summary = f"壁時計 {wall} 秒・{counted['tests']} 件・合計 {counted['sum_seconds']} 秒・load average {load1:.2f}" + if failed: + summary += f"。落ちた suite: {', '.join(failed)}" + emit(result(TOOL, status, summary, items, metrics)) + + +def ledger_nodeids(text: str) -> set[str]: + """台帳の表の行にバッククォートで書かれた nodeid(`::` を含むもの)。""" + ids = set() + for line in text.splitlines(): + if line.lstrip().startswith("|"): + ids.update(m for m in re.findall(r"`([^`]+)`", line) if "::" in m) + return ids + + +def _read_out(path: str) -> dict: + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return data["tests"] + except (OSError, ValueError, KeyError, TypeError) as e: + raise StepError(f"計測の記録を読めない: {path}: {e}", EXIT_UNREADABLE) + + +def compare_sets(before: dict, after: dict, ledger: set[str]) -> dict: + ran_before = {k for k, v in before.items() if v["outcome"] != "skipped"} + missing = sorted(k for k in ran_before if k not in after and k not in ledger) + skipped = sorted(k for k in ran_before if k in after and after[k]["outcome"] == "skipped") + return {"missing": missing, "now_skipped": skipped} + + +def cmd_diff(a) -> None: + before, after = _read_out(a.before), _read_out(a.after) + try: + ledger = ledger_nodeids(Path(a.ledger).read_text(encoding="utf-8")) + except OSError as e: + raise StepError(f"台帳を読めない: {a.ledger}: {e}", EXIT_UNREADABLE) + found = compare_sets(before, after, ledger) + b, f = summarize_tests(before)[0], summarize_tests(after)[0] + metrics = {"before": b, "after": f, "missing": len(found["missing"]), "now_skipped": len(found["now_skipped"])} + items = [{"kind": "missing", "name": n, "result": "not-in-ledger"} for n in found["missing"]] + items += [{"kind": "now_skipped", "name": n, "result": "skipped"} for n in found["now_skipped"]] + status = "stopped" if items else "ok" + summary = ( + f"着手前 {b['tests']} 件・{b['sum_seconds']} 秒 → 着手後 {f['tests']} 件・{f['sum_seconds']} 秒。" + f"台帳に無い消えたテスト {len(found['missing'])} 件・skip に変わったテスト {len(found['now_skipped'])} 件" + ) + emit(result(TOOL, status, summary, items, metrics)) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + m = sub.add_parser("measure") + m.add_argument("--root", default=".") + m.add_argument("--out", required=True) + m.set_defaults(func=cmd_measure) + d = sub.add_parser("diff") + d.add_argument("--before", required=True) + d.add_argument("--after", required=True) + d.add_argument("--ledger", required=True) + d.set_defaults(func=cmd_diff) + return main_with(ap, lambda a: TOOL, argv) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/tests/inproc.py b/plugins/ndf/scripts/tests/inproc.py new file mode 100644 index 000000000..48ca67430 --- /dev/null +++ b/plugins/ndf/scripts/tests/inproc.py @@ -0,0 +1,67 @@ +"""スクリプトの `main(argv)` を、テストのプロセスの中で呼ぶ(#2291 の決定 3 の (2))。 + +スクリプトを子のプロセスで起動すると、起動と import だけで 1 回 0.2〜0.4 秒かかる。CLI の配線(引数・終了コード・ +結果 JSON)は代表の数件の起動で確かめ、分岐はこの関数で確かめる。引数を取らない `main()` は `sys.argv` から読む。返す値は `subprocess.run` と同じ形 +(`returncode`・`stdout`・`stderr`)で、起動を置き換えても読み手を変えずに済む。 + +作業ディレクトリ・環境変数・`sys.argv`・標準入出力は呼ぶ間だけ差し替え、戻す。モジュールは 1 度だけ読み込む。 +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import inspect +import io +import os +import subprocess +import sys +from pathlib import Path + +_modules: dict[str, object] = {} + + +def load(script: Path | str): + key = str(Path(script).resolve()) + if key not in _modules: + name = "inproc_" + Path(key).stem.replace("-", "_") + spec = importlib.util.spec_from_file_location(name, key) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod # dataclass などがモジュールを sys.modules から引く + spec.loader.exec_module(mod) + _modules[key] = mod + return _modules[key] + + +def _code(e: SystemExit, err: io.StringIO) -> int: + if e.code is None: + return 0 + if isinstance(e.code, int): + return e.code + err.write(f"{e.code}\n") + return 1 + + +def run(script: Path | str, *args: str, cwd: Path | str | None = None, env: dict | None = None, input: str | None = None): + """`[python, script, *args]` を子のプロセスで起動したのと同じ形の結果を返す。""" + mod = load(script) + argv = [str(a) for a in args] + out, err = io.StringIO(), io.StringIO() + saved = (dict(os.environ), sys.argv, sys.stdin) + try: + if env is not None: + os.environ.clear() + os.environ.update({k: str(v) for k, v in env.items()}) + sys.argv = [str(script), *argv] + sys.stdin = io.StringIO(input or "") + with contextlib.chdir(cwd or os.getcwd()), contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + r = mod.main(argv) if inspect.signature(mod.main).parameters else mod.main() + code = r if isinstance(r, int) else 0 + except SystemExit as e: + code = _code(e, err) + finally: + os.environ.clear() + os.environ.update(saved[0]) + sys.argv, sys.stdin = saved[1], saved[2] + return subprocess.CompletedProcess([sys.executable, str(script), *argv], code, out.getvalue(), err.getvalue()) diff --git a/plugins/ndf/scripts/tests/test_base_sync.py b/plugins/ndf/scripts/tests/test_base_sync.py index c1d758a82..05c49b2a0 100644 --- a/plugins/ndf/scripts/tests/test_base_sync.py +++ b/plugins/ndf/scripts/tests/test_base_sync.py @@ -15,6 +15,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + sys.path.insert(0, str(SCRIPTS)) sys.path.insert(0, str(SCRIPTS / "lib")) from step_result import validate_result # noqa: E402 @@ -99,7 +102,7 @@ def merging(self): def merged(args, cwd, env=None): - p = subprocess.run([PY, str(MERGED), *args], capture_output=True, text=True, cwd=cwd, env=env) + p = inproc.run(MERGED, *args, cwd=cwd, env=env) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None if out is not None: diff --git a/plugins/ndf/scripts/tests/test_check_trigger.py b/plugins/ndf/scripts/tests/test_check_trigger.py index 51bc230c0..b110bd4d6 100644 --- a/plugins/ndf/scripts/tests/test_check_trigger.py +++ b/plugins/ndf/scripts/tests/test_check_trigger.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SCRIPT = SCRIPTS / "check-trigger.py" sys.path.insert(0, str(SCRIPTS / "lib")) from step_result import validate_result # noqa: E402 @@ -132,7 +135,7 @@ def merge_pr(root: Path, n: int, branch: str, files: dict[str, int]) -> str: def call(root: Path, env: dict, *args: str) -> tuple[int, dict | None, str]: - p = subprocess.run([sys.executable, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, env=env, cwd=root) + p = inproc.run(SCRIPT, *args, "--root", str(root), env=env, cwd=root) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines and lines[-1].startswith("{") else None if out is not None: diff --git a/plugins/ndf/scripts/tests/test_glossary.py b/plugins/ndf/scripts/tests/test_glossary.py index cb81479a1..31fa8f27d 100644 --- a/plugins/ndf/scripts/tests/test_glossary.py +++ b/plugins/ndf/scripts/tests/test_glossary.py @@ -14,6 +14,9 @@ import pytest +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SCRIPTS = Path(__file__).resolve().parents[1] SCRIPT = SCRIPTS / "glossary.py" PY = sys.executable @@ -37,12 +40,20 @@ def write_json(root, rel, obj): return write(root, rel, json.dumps(obj, ensure_ascii=False, indent=2) + "\n") -def run(root, *args): - p = subprocess.run([PY, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, cwd=root) +def _parsed(p): lines = p.stdout.strip().splitlines() return p.returncode, (json.loads(lines[-1]) if lines else None), p.stderr +def run(root, *args): + """分岐はプロセスの中で main を呼んで確かめる(起動の配線は `run_cli` の代表の数件)。""" + return _parsed(inproc.run(SCRIPT, *args, "--root", str(root), cwd=root)) + + +def run_cli(root, *args): + return _parsed(subprocess.run([PY, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, cwd=root)) + + def commit(root, msg="c"): git(root, "add", "-A") git(root, "commit", "-q", "-m", msg) @@ -816,7 +827,7 @@ def test_init_places_outside_root_are_2(bare, tmp_path, idx): def test_command_lines_are_step_results(repo): for args in (["gate", "--mode", "standard"], ["render", "--check"], ["check"], ["diff", "--base", "develop"]): - code, out, _ = run(repo, *args) + code, out, _ = run_cli(repo, *args) assert code == 0 and out["tool"] == "glossary" and out["status"] == "ok" assert os.access(SCRIPT, os.X_OK) diff --git a/plugins/ndf/scripts/tests/test_instructions_check.py b/plugins/ndf/scripts/tests/test_instructions_check.py index 48cd2cfd1..e3d524ec9 100644 --- a/plugins/ndf/scripts/tests/test_instructions_check.py +++ b/plugins/ndf/scripts/tests/test_instructions_check.py @@ -18,6 +18,8 @@ import pytest SCRIPT = Path(__file__).resolve().parents[1] / "instructions-check.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 # --- 一時リポジトリの組み立て ------------------------------------------------ @@ -60,12 +62,8 @@ def declare(root: Path, decl: dict) -> None: def run(root: Path, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess: - return subprocess.run( - [sys.executable, str(SCRIPT), "--root", str(root), *args], - capture_output=True, - text=True, - cwd=str(cwd) if cwd else None, - ) + """分岐はプロセスの中で main を呼ぶ(起動の配線は `test_entry_point_runs_from_a_copy_without_lib_on_sys_path` などの起動のテスト)。""" + return inproc.run(SCRIPT, "--root", str(root), *args, cwd=cwd) def errors(proc: subprocess.CompletedProcess) -> list[str]: diff --git a/plugins/ndf/scripts/tests/test_pr_steps.py b/plugins/ndf/scripts/tests/test_pr_steps.py index 352f64705..eca922d8c 100644 --- a/plugins/ndf/scripts/tests/test_pr_steps.py +++ b/plugins/ndf/scripts/tests/test_pr_steps.py @@ -17,6 +17,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable FAKE_GH = """#!{py} @@ -123,7 +126,7 @@ def repo(tmp_path): def call(args, env, cwd): - p = subprocess.run([PY, str(SCRIPTS / "pr-steps.py"), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / "pr-steps.py", *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None return p.returncode, out, p.stderr diff --git a/plugins/ndf/scripts/tests/test_project_decl.py b/plugins/ndf/scripts/tests/test_project_decl.py index 1662f51e0..93a1e1431 100644 --- a/plugins/ndf/scripts/tests/test_project_decl.py +++ b/plugins/ndf/scripts/tests/test_project_decl.py @@ -18,6 +18,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable SECRET = "AKIAABCDEFGHIJKLMNOP" DB_URL = "mysql://app:hunter2secret@db.internal:3306/app" @@ -142,7 +145,7 @@ def plain(tmp_path): def run(env, *args, cwd=None): - p = subprocess.run([PY, str(SCRIPTS / "project-decl.py"), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / "project-decl.py", *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() try: out = json.loads(lines[-1]) if lines else None diff --git a/plugins/ndf/scripts/tests/test_project_mvv.py b/plugins/ndf/scripts/tests/test_project_mvv.py index 6891bc24d..99adbf097 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv.py +++ b/plugins/ndf/scripts/tests/test_project_mvv.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + REPO = SCRIPTS.parents[2] MVV_PY = SCRIPTS / "project-mvv.py" GATE_PY = SCRIPTS / "mvv-gate.py" @@ -111,7 +114,7 @@ def calls(env: dict, what: str = "") -> int: def run(env: dict, *args: str, script: Path = MVV_PY) -> tuple[int, dict, str]: - p = subprocess.run([PY, str(script), *args], capture_output=True, text=True, env=env["env"], cwd=env["root"], timeout=60) + p = inproc.run(script, *args, env=env["env"], cwd=env["root"]) lines = [ln for ln in p.stdout.splitlines() if ln.startswith("{")] out = json.loads(lines[-1]) if lines else {} if out: diff --git a/plugins/ndf/scripts/tests/test_relay.py b/plugins/ndf/scripts/tests/test_relay.py index 9abe1a696..aec663950 100644 --- a/plugins/ndf/scripts/tests/test_relay.py +++ b/plugins/ndf/scripts/tests/test_relay.py @@ -486,8 +486,10 @@ def child_input(self, n): def wait_start(self, n, timeout=15): self.wait(lambda: len(self.starts()) >= n, timeout, f"{n} 回目の起動") - # 子が端末を raw にするまで少し待つ(raw の前の入力は行の編集に掛かる) - time.sleep(0.3) + # 子が端末を raw にするまで待つ(raw の前の入力は行の編集に掛かる)。偽の claude は raw にした直後に + # 入力の記録のファイルを作る + raw = self.fake_dir / f"input-{self.starts()[n - 1]['pid']}" + self.wait(raw.exists, timeout, f"{n} 回目の子が端末を raw にする") def finish(self, timeout=20): try: @@ -643,13 +645,16 @@ def test_run_chain_three_sections(term): def test_run_waits_for_user_input_quiet(term): - """利用者の入力から静止の秒数がたつまで /exit を送らない(AC6)。""" - t = term(env={"NDF_RELAY_QUIET": "1.5"}) + """利用者の入力から静止の秒数がたつまで /exit を送らない(AC6)。 + + 打ちかけの文字の後ろに /exit が付くため子は終わらず、終わりの待ちの後に止められる。待ちは 1 秒に縮める。""" + t = term(env={"NDF_RELAY_QUIET": "1.5", "NDF_RELAY_EXIT_WAIT": "1"}) t.wait_start(1) t.type("mark x\r") for _ in range(6): time.sleep(0.4) t.type("a") # 打っている途中 + assert len(t.starts()) == 1 assert b"/exit" not in t.child_input(0) t.wait_start(2) @@ -2136,7 +2141,7 @@ def question_rows(t, n=0): def test_guard_question_blocks_exit(term): """G1: 合図の後に質問が出たら /exit を書かない。答えた後の Stop がブロックを出せば切り替わる。""" - t = term(env={"NDF_RELAY_QUIET": "1.5"}) + t = term(env={"NDF_RELAY_QUIET": "0.5"}) t.wait_start(1) t.type("mark 次\r") # 合図の後に応答が再開して質問が出た形 t.type("q open\r") @@ -2144,7 +2149,7 @@ def test_guard_question_blocks_exit(term): assert question_rows(t)[0] == {"action": "open", "stdout": "", "code": 0} d = pathlib.Path(t.starts()[0]["relay_dir"]) assert (d / "question").exists() and (d / "next.json").exists() - time.sleep(3) + time.sleep(1.2) # 静止の待ちの 2 倍を超えても書かない assert len(t.starts()) == 1 and b"/exit" not in t.child_input(0) t.type("q close\r") t.wait(lambda: not (d / "question").exists(), what="合図が消える") @@ -2222,10 +2227,10 @@ def test_guard_lock_held_then_question_appears(term): fcntl.flock(f, fcntl.LOCK_EX) t.type("mark 次\r") t.wait(lambda: (d / "next.json").exists(), what="合図") - time.sleep(1.5) + time.sleep(0.8) # 静止の待ち(0.3 秒)の 2 倍を超えても書かない assert b"/exit" not in t.child_input(0) (d / "question").write_text("") - time.sleep(1.5) + time.sleep(0.8) assert len(t.starts()) == 1 and b"/exit" not in t.child_input(0) t.type("q close\r") t.type("mark 次\r") @@ -3515,7 +3520,7 @@ def test_slow_usage_endpoint_does_not_stall_relay(term, accounts): accounts.add("b", util5=30) t = account_term(term, accounts) t.wait_start(1) - accounts.fake.delay = 8 # 定期の確認(1 秒ごと)の取得が待たされる + accounts.fake.delay = 3 # 定期の確認(1 秒ごと)の取得が、下で打つ時点(1.5 秒後)にまだ待たされている time.sleep(1.5) pid = t.starts()[0]["pid"] sent = time.time() diff --git a/plugins/ndf/scripts/tests/test_release_steps.py b/plugins/ndf/scripts/tests/test_release_steps.py index 515ddc6cf..d1f6b5a99 100644 --- a/plugins/ndf/scripts/tests/test_release_steps.py +++ b/plugins/ndf/scripts/tests/test_release_steps.py @@ -14,6 +14,9 @@ import pytest SCRIPT = Path(__file__).resolve().parents[1] / "release-steps.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable @@ -497,7 +500,7 @@ def test_notes_and_changelog_stop_when_no_pr_is_merged(repo, tmp_path): before = [f.read_bytes() for f in files] base = ["--root", str(repo), "--version", "1.2.3-dev.1", "--prs", "13"] for args in (["notes", *base], ["notes", *base, "--approval", "issues/approval.md"], ["changelog", *base]): - p = subprocess.run([PY, str(SCRIPT), *args], capture_output=True, text=True, env=env) + p = inproc.run(SCRIPT, *args, env=env) assert p.returncode == 3, args assert [f.read_bytes() for f in files] == before, args @@ -767,7 +770,7 @@ def m744_repo(root: Path) -> tuple[Path, str]: def others_run(root: Path, env: dict, *args: str) -> tuple[int, dict]: - p = subprocess.run([PY, str(SCRIPT), "changed-plugins", "--root", str(root), *args], capture_output=True, text=True, env=env) + p = inproc.run(SCRIPT, "changed-plugins", "--root", str(root), *args, env=env) return p.returncode, json.loads(p.stdout.strip().splitlines()[-1]) diff --git a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py index 07a52ce24..3e9992a14 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py @@ -18,6 +18,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + sys.path.insert(0, str(SCRIPTS / "lib")) import approved_commit # noqa: E402 from step_result import validate_result # noqa: E402 @@ -177,7 +180,7 @@ def get(self): def call(script, args, env, cwd): - p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / script, *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None if out is not None: diff --git a/plugins/ndf/scripts/tests/test_sprint_procedures.py b/plugins/ndf/scripts/tests/test_sprint_procedures.py index 16a557711..78fecdfa0 100644 --- a/plugins/ndf/scripts/tests/test_sprint_procedures.py +++ b/plugins/ndf/scripts/tests/test_sprint_procedures.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" SPRINT_STATE = SCRIPTS / "sprint-state.py" REPO = SCRIPTS.parents[2] @@ -70,7 +73,7 @@ def new_sprint(tmp_path: Path, pace: str, design: bool = True) -> dict: args += ["--design", "11"] if design else [] if pace != "normal": args += ["--pace", pace, "--state", str(sprint_state(tmp_path, pace))] - p = subprocess.run([PY, str(SUPERVISE), *args, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, "--out", str(out), cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return {w["name"]: w for w in load(out / "sprint.json")["ステージ"]} @@ -87,7 +90,7 @@ def test_sprint_waves_does_not_rebuild_the_shared_procedures(): def new_single(tmp_path: Path, kind: str, *extra: str) -> dict: repo = tmp_path / "repo" if (tmp_path / "repo").is_dir() else make_repo(tmp_path) out = tmp_path / f"{kind}-{len(list(tmp_path.glob('*.json')))}.json" - p = subprocess.run([PY, str(SUPERVISE), "new", kind, *extra, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, "new", kind, *extra, "--out", str(out), cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return load(out) diff --git a/plugins/ndf/scripts/tests/test_step_scripts.py b/plugins/ndf/scripts/tests/test_step_scripts.py index e2d6281b8..7edb4e4b4 100644 --- a/plugins/ndf/scripts/tests/test_step_scripts.py +++ b/plugins/ndf/scripts/tests/test_step_scripts.py @@ -14,6 +14,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable FAKE_GH = """#!{py} @@ -72,8 +75,12 @@ def repo(tmp_path): return root -def call(script, args, env, cwd=None): - p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) +def call(script, args, env, cwd=None, proc=False): + """分岐はプロセスの中で main を呼ぶ。子のプロセスへ渡す入口(phase-steps.py)は `proc=True` で起動して確かめる。""" + if proc: + p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) + else: + p = inproc.run(SCRIPTS / script, *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None return p.returncode, out, p.stderr @@ -518,13 +525,13 @@ def test_phase_steps_forwards_with_root_before_or_after(repo, env, root_first): spec_repo(repo) sub = ["spec-finalize", "--spec", "docs/specifications/x.md", "--design", "docs/design/x-design.md"] args = ["--root", str(repo), *sub] if root_first else [*sub, "--root", str(repo)] - code, out, err = call("phase-steps.py", args, env, cwd=str(repo.parent)) + code, out, err = call("phase-steps.py", args, env, cwd=str(repo.parent), proc=True) assert code == 0, err assert out["tool"] == "plan-to-spec" and out["status"] == "ok" def test_phase_steps_forwards_exit_code(repo, env): - code, out, _ = call("phase-steps.py", [f"--root={repo}", "bump", "--plugin", "nope", "--to", "1.1.0"], env) + code, out, _ = call("phase-steps.py", [f"--root={repo}", "bump", "--plugin", "nope", "--to", "1.1.0"], env, proc=True) assert code == 3 and out["tool"] == "release" diff --git a/plugins/ndf/scripts/tests/test_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index 0c7beec43..6e7b408a2 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -16,6 +16,9 @@ SCRIPTS = Path(__file__).resolve().parents[1] SUPERVISE = SCRIPTS / "supervise.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable sys.path.insert(0, str(SCRIPTS / "lib")) @@ -510,7 +513,7 @@ def test_queue_creates_worktrees_in_order_before_run(tmp_path, monkeypatch): def cli(*args, cwd=None): """既定ではこのリポジトリの根で打つ(作業場所に宣言が無いとき、今のディレクトリの .ndf/ を読む)。""" - return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd or REPO) + return inproc.run(SUPERVISE, *args, cwd=cwd or REPO) AI_PLUGINS_WORDS = ("plugins/", "playwright-kit", "build-runtime-plugins", "claude,codex,kiro", "develop", "scripts/check-") diff --git a/plugins/ndf/scripts/tests/test_supervise_contract.py b/plugins/ndf/scripts/tests/test_supervise_contract.py index cf12e44e5..4478b05ab 100644 --- a/plugins/ndf/scripts/tests/test_supervise_contract.py +++ b/plugins/ndf/scripts/tests/test_supervise_contract.py @@ -26,6 +26,9 @@ PLUGIN = SCRIPTS.parent SUPERVISE = SCRIPTS / "supervise.py" PY = sys.executable +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + FIXTURES = Path(__file__).resolve().parent / "fixtures" / "supervise-contract" SUBCOMMANDS = ["run", "history", "expected", "example", "new", "queue", "wait", "design-glossary", "note", "sync-check"] KINDS = ["sprint", "impl", "fix", "check", "release", "close"] @@ -37,6 +40,11 @@ def cli(*args: str, cwd: Path, timeout: float = 120) -> subprocess.CompletedProcess: + """分岐と結果の形はプロセスの中で main を呼んで確かめる(起動の配線は `cli_proc` の代表の 1 件)。""" + return inproc.run(SUPERVISE, *args, cwd=cwd, env=dict(os.environ, COLUMNS="200")) + + +def cli_proc(*args: str, cwd: Path, timeout: float = 120) -> subprocess.CompletedProcess: env = dict(os.environ, COLUMNS="200") return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd, env=env, timeout=timeout) @@ -214,6 +222,6 @@ def test_the_fixture_plans_run_without_claude(tmp_path, monkeypatch): stub.write_text("#!/bin/sh\necho called >&2\nexit 99\n") stub.chmod(0o755) monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}") - p = cli("run", str(runnable(tmp_path, "ok")), cwd=tmp_path) + p = cli_proc("run", str(runnable(tmp_path, "ok")), cwd=tmp_path) assert p.returncode == 0 and "called" not in p.stderr, p.stderr assert shutil.which("claude") == str(bindir / "claude") diff --git a/plugins/ndf/scripts/tests/test_supervise_limits.py b/plugins/ndf/scripts/tests/test_supervise_limits.py index 6a0c24c2e..e3661a2a3 100644 --- a/plugins/ndf/scripts/tests/test_supervise_limits.py +++ b/plugins/ndf/scripts/tests/test_supervise_limits.py @@ -120,11 +120,22 @@ def reason_of(text: str) -> str: # --- L1・I1: 雛形ごとに、修正のループのそれぞれを 3 回ずつ通しても上限で止まらない --- -def test_every_template_plan_gets_through_each_fix_loop_three_times(plans, tmp_path): +def _failing_sets(plans: dict[str, dict]) -> dict[frozenset, tuple[str, dict]]: + """修正のループの入口の組ごとに、最初のプランを 1 つ(総当たりを代表の組に絞る。修正のループの無いプランは除く)。""" + reps: dict[frozenset, tuple[str, dict]] = {} for name, plan in plans.items(): fixes, _ = limits.loops(plan) failing = {k.split("→")[0] for k in fixes} - failing = {c for c in failing if any(s["id"] == c and s.get("on_fail") for s in plan["steps"])} + failing = frozenset(c for c in failing if any(s["id"] == c and s.get("on_fail") for s in plan["steps"])) + if failing: + reps.setdefault(failing, (name, plan)) + return reps + + +def test_every_fix_loop_shape_gets_through_each_fix_loop_three_times(plans, tmp_path): + reps = _failing_sets(plans) + assert len(reps) >= 4, sorted(n for n, _ in reps.values()) # impl・check・release・release-prod の形 + for failing, (name, plan) in reps.items(): visits: dict[str, int] = {} def decide(sid, step, failing=failing, visits=visits): diff --git a/plugins/ndf/scripts/tests/test_supervise_new.py b/plugins/ndf/scripts/tests/test_supervise_new.py index 8668ebbf1..108b3317b 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -11,6 +11,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" PY = sys.executable @@ -23,7 +26,7 @@ def cli(*args, cwd): - return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd) + return inproc.run(SUPERVISE, *args, cwd=cwd) def git(cwd, *args): diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_auto.py b/plugins/ndf/scripts/tests/test_supervise_pace_auto.py index bfee70a33..46dc36e01 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_auto.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_auto.py @@ -15,6 +15,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" SPRINT_STATE = SCRIPTS / "sprint-state.py" REPO = SCRIPTS.parents[2] @@ -61,7 +64,7 @@ def new_sprint(tmp_path, repo, state, *extra, pace="auto", design=True): args = ["new", "sprint", "--name", "m27", "--worktree", str(repo), "--issue", "11", "12"] args += ["--design", "11"] if design else [] args += ["--version", "10.18.0-dev.1", "--pace", pace, "--state", str(state), "--out", str(out), *extra] - p = subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, cwd=repo) return p, out diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index 90de1fe16..678f7cc7a 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -14,6 +14,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" STEPS = SCRIPTS / "release-steps.py" PY = sys.executable @@ -53,7 +56,7 @@ def new_sprint(tmp_path, repo, pace: str | None = "auto"): args = ["new", "sprint", "--name", "m2", "--worktree", str(repo), "--issue", "11", "--out", str(out)] if pace: args += ["--pace", pace, "--state", str(sprint_state(tmp_path, "auto"))] - p = subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return load(out / "sprint.json")["ステージ"] @@ -61,7 +64,7 @@ def new_sprint(tmp_path, repo, pace: str | None = "auto"): def new_close(tmp_path, repo): out = tmp_path / "c" args = ["new", "close", "--name", "m2", "--worktree", str(repo), "--issue", "11", "--state", str(sprint_state(tmp_path, "auto"))] - p = subprocess.run([PY, str(SUPERVISE), *args, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, "--out", str(out), cwd=repo) return p, out diff --git a/plugins/ndf/scripts/tests/test_whole_test_timing.py b/plugins/ndf/scripts/tests/test_whole_test_timing.py new file mode 100644 index 000000000..af428c357 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_whole_test_timing.py @@ -0,0 +1,102 @@ +"""実験版 `whole-test-timing.py` の計測と照合(#2291 の決定 7)。 + +- measure は宣言の順に suite を流し、最初の起動から最後の終了までを壁時計にする。負荷が CPU の数を超えたら参考の値にする +- junit の skip を件数から外し、遅いテストを秒の長い順に、ファイルを秒の合計の順に並べる +- diff は台帳に無い消えたテストと、skip に変わったテストを、nodeid を丸めずに出す +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "experimental" / "whole-test-timing.py" +_spec = importlib.util.spec_from_file_location("whole_test_timing", SCRIPT) +wtt = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(wtt) + +JUNIT = """ + + + + +""" + + +def _repo(tmp_path: Path, suites: list[dict]) -> Path: + (tmp_path / ".ndf").mkdir() + (tmp_path / ".ndf" / "project.json").write_text(json.dumps({"test": {"suites": suites}})) + return tmp_path + + +def _measure(monkeypatch, tmp_path, suites, load): + monkeypatch.setattr(wtt.os, "getloadavg", lambda: (load, 0.0, 0.0)) + monkeypatch.setattr(wtt.os, "cpu_count", lambda: 8) + root = _repo(tmp_path, suites) + out = tmp_path / "out.json" + with pytest.raises(SystemExit) as e: + wtt.main(["measure", "--root", str(root), "--out", str(out)]) + return e.value.code, json.loads(out.read_text()) + + +def test_measure_runs_suites_in_order_and_reads_junit(monkeypatch, tmp_path, capsys): + (tmp_path / "j.xml").write_text(JUNIT) + suites = [ + {"name": "first", "command": "echo 1 >> order", "junit": "j.xml"}, + {"name": "second", "command": "sleep 0.2; echo 2 >> order"}, + ] + code, out = _measure(monkeypatch, tmp_path, suites, load=3.0) + assert code == 0 + assert (tmp_path / "order").read_text().split() == ["1", "2"] + m = out["metrics"] + assert [s["name"] for s in m["suites"]] == ["first", "second"] + assert m["wall_seconds"] >= 0.2 and m["reference"] is False + assert (m["tests"], m["skipped"], m["sum_seconds"]) == (3, 1, 4.1) + assert out["tests"]["pkg/tests/test_a.py::TestK::test_slow[x-1]"] == {"outcome": "passed", "seconds": 3.0} + items = json.loads(capsys.readouterr().out)["items"] + tests = [i["name"] for i in items if i["kind"] == "test"] + assert tests[:2] == ["pkg/tests/test_a.py::TestK::test_slow[x-1]", "pkg/tests/test_b.py::test_mid"] + assert [(i["name"], i["seconds"]) for i in items if i["kind"] == "file"] == [ + ("pkg/tests/test_a.py", 3.1), + ("pkg/tests/test_b.py", 1.0), + ] + + +def test_measure_marks_high_load_as_reference_and_fails_on_a_failed_suite(monkeypatch, tmp_path): + code, out = _measure(monkeypatch, tmp_path, [{"name": "bad", "command": "exit 3"}], load=9.5) + assert code == 1 + assert out["metrics"]["reference"] is True + assert out["metrics"]["suites"][0]["exit"] == 3 + + +def _diff(tmp_path, before, after, ledger): + for name, tests in (("b.json", before), ("a.json", after)): + (tmp_path / name).write_text(json.dumps({"tests": tests})) + (tmp_path / "ledger.md").write_text(ledger) + with pytest.raises(SystemExit) as e: + wtt.main( + ["diff", "--before", str(tmp_path / "b.json"), "--after", str(tmp_path / "a.json"), "--ledger", str(tmp_path / "ledger.md")] + ) + return e.value.code + + +def test_diff_lists_removed_tests_missing_from_the_ledger_without_rounding_params(tmp_path, capsys): + ran = {"outcome": "passed", "seconds": 1.0} + before = {"t.py::a[1]": ran, "t.py::a[2]": ran, "t.py::b": ran} + after = {"t.py::b": ran} + code = _diff(tmp_path, before, after, "| `t.py::a[1]` | 消した | 1 | `t.py::b` |\n| `t.py::a` | 消した |\n") + assert code == 1 + out = json.loads(capsys.readouterr().out) + assert [i["name"] for i in out["items"]] == ["t.py::a[2]"] + + +def test_diff_lists_tests_that_became_skipped(tmp_path, capsys): + before = {"t.py::a": {"outcome": "passed", "seconds": 1.0}, "t.py::s": {"outcome": "skipped", "seconds": 0.0}} + after = {"t.py::a": {"outcome": "skipped", "seconds": 0.0}} + assert _diff(tmp_path, before, after, "") == 1 + out = json.loads(capsys.readouterr().out) + assert [(i["kind"], i["name"]) for i in out["items"]] == [("now_skipped", "t.py::a")] + assert _diff(tmp_path, before, before, "") == 0 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index 318d70deb..d123e5814 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -10,6 +10,7 @@ import json import os import shlex +import shutil import subprocess import types @@ -25,15 +26,10 @@ def _git(*args, cwd): return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True) -@pytest.fixture -def origin_repo(tmp_path): - """origin にだけ head ブランチがあるリポジトリ。 - - `git worktree add ` はローカルにブランチが無いと失敗するため、 - その経路を踏ませる形で用意する。 - """ - origin = tmp_path / "origin.git" - repo = tmp_path / "repo" +def _build_origin_repo(base): + """origin にだけ head ブランチがあるリポジトリを `base` の下に作る。""" + origin = base / "origin.git" + repo = base / "repo" subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True) subprocess.run(["git", "clone", "-q", str(origin), str(repo)], check=True, capture_output=True) _git("config", "user.email", "t@e.st", cwd=repo) @@ -52,6 +48,26 @@ def origin_repo(tmp_path): # ローカルの head ブランチを消し、origin にだけある状態にする _git("checkout", "-q", "main", cwd=repo) _git("branch", "-qD", HEAD_BRANCH, cwd=repo) + + +@pytest.fixture(scope="session") +def _origin_template(tmp_path_factory): + base = tmp_path_factory.mktemp("origin-template") + _build_origin_repo(base) + return base + + +@pytest.fixture +def origin_repo(tmp_path, _origin_template): + """origin にだけ head ブランチがあるリポジトリ。 + + `git worktree add ` はローカルにブランチが無いと失敗するため、 + その経路を踏ませる形で用意する。git の組み立てはプロセスごとに 1 度だけ行い、テストごとに複製する。 + """ + for name in ("origin.git", "repo"): + shutil.copytree(_origin_template / name, tmp_path / name, symlinks=True) + repo = tmp_path / "repo" + _git("remote", "set-url", "origin", str(tmp_path / "origin.git"), cwd=repo) return repo diff --git a/scripts/pytest_child_cleanup.py b/scripts/pytest_child_cleanup.py new file mode 100644 index 000000000..02e64dea0 --- /dev/null +++ b/scripts/pytest_child_cleanup.py @@ -0,0 +1,195 @@ +"""テストが起こした子孫のプロセスを、pytest が止められたとき・終わったときに残さない(#2291 の決定 4)。 + +根の `conftest.py` が `pytest_plugins` で読み込む。pytest の親と xdist の各 worker で、次の 3 つを行う。 + +1. 子の回収役(Linux の `PR_SET_CHILD_SUBREAPER`)になる。テストが自分の session で起こした子の親が先に + 終わっても、孫は init ではなく最も近い pytest のプロセスへ付け替わり、止める対象から漏れない +2. SIGCHLD を受けるたびに、自分が起こした覚えの無い直接の子のゾンビだけを回収する。付け替わった孫の + ゾンビが残ると、打ち切る側のグループの存否の判定が成功し続け、猶予を毎回待ち切る。 + 自分が起こした pid は `subprocess.Popen` と `os.fork` を包んで記録し、その終了コードは横取りしない。 + 起動から記録までの間(起動中の数が 1 以上の間)は回収を見送り、数が 0 に戻った時点でやり直す +3. SIGTERM を受けたとき・pytest が終わるとき(`pytest_unconfigure`)に、直接の子の木を止める。 + 止めるのは `lib/procs.py` の `stop_tree` で、自分の pid には向けない + +子の回収役にできない環境(Linux 以外・psutil が無い)では何もしない。テストの結果は変えない。 +""" + +from __future__ import annotations + +import ctypes +import os +import signal +import subprocess +import sys +import threading +from pathlib import Path + +PR_SET_CHILD_SUBREAPER = 36 +GRACE = 2.0 + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugins" / "ndf" / "scripts" / "lib")) +try: + import procs +except ImportError: # psutil が無い環境では何もしない + procs = None + +_lock = threading.Lock() +_own: set[int] = set() +_state = {"starting": 0, "deferred": False, "config": None} +_saved: dict = {} + + +def _become_subreaper() -> bool: + if procs is None or not sys.platform.startswith("linux"): + return False + try: + libc = ctypes.CDLL(None, use_errno=True) + return libc.prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 + except (OSError, AttributeError): + return False + + +def _reap_unknown() -> None: + """直接の子のうち、ゾンビで、自分が起こした記録に無いものだけを 1 つずつ回収する(ロックを持って呼ぶ)。""" + for pid in procs.child_pids(os.getpid(), recursive=False): + if pid in _own or not procs.pid_is_zombie(pid): + continue + try: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + pass + + +def _reap_while_deferred() -> None: + """見送った回収を、起動中の数が 0 の間にやり直す。""" + while _state["deferred"]: + with _lock: + if _state["starting"]: + return + _state["deferred"] = False + _reap_unknown() + + +def _begin() -> None: + with _lock: + _state["starting"] += 1 + + +def _end(pid: int | None) -> None: + with _lock: + if pid: + _own.add(pid) + _state["starting"] -= 1 + _reap_while_deferred() + + +def _reset_in_forked_child() -> None: + global _lock + _lock = threading.Lock() + _own.clear() + _state["starting"] = 0 + _state["deferred"] = False + + +def _on_sigchld(signum, frame) -> None: + # 主スレッドで走り、主スレッドの包んだ呼び出しの途中にも割り込むため、ロックを待たない + if not _lock.acquire(blocking=False): + _state["deferred"] = True + return + try: + if _state["starting"]: + _state["deferred"] = True + else: + _state["deferred"] = False + _reap_unknown() + finally: + _lock.release() + + +def stop_children() -> None: + """直接の子の各 pid へ `stop_tree` を並べてかけ、全部の終わりをまとめて待つ。自分の pid には送らない。""" + me = os.getpid() + pids = [p for p in procs.child_pids(me, recursive=False) if p != me] + threads = [threading.Thread(target=procs.stop_tree, args=(p, GRACE), daemon=True) for p in pids] + for t in threads: + t.start() + for t in threads: + t.join() + + +def _on_sigterm(signum, frame) -> None: + stop_children() + signal.signal(signal.SIGTERM, signal.SIG_DFL) + os.kill(os.getpid(), signal.SIGTERM) + + +def _wrap_popen_init(orig): + def __init__(self, *args, **kwargs): + _begin() + pid = None + try: + orig(self, *args, **kwargs) + pid = getattr(self, "pid", None) + finally: + _end(pid) + + return __init__ + + +def _wrap_fork(orig): + def fork(): + _begin() + pid = None + try: + pid = orig() + finally: + if pid == 0: + _reset_in_forked_child() + else: + _end(pid) + return pid + + return fork + + +def install() -> bool: + """子の回収役になり、包みとシグナルの扱いを置く。置けたら真。""" + if _saved or not _become_subreaper(): + return False + try: + _saved["sigchld"] = signal.signal(signal.SIGCHLD, _on_sigchld) + signal.siginterrupt(signal.SIGCHLD, False) + _saved["sigterm"] = signal.signal(signal.SIGTERM, _on_sigterm) + except ValueError: # 主スレッドでないとシグナルの扱いを置けない + if "sigchld" in _saved: + signal.signal(signal.SIGCHLD, _saved.pop("sigchld")) + return False + _saved["popen_init"] = subprocess.Popen.__init__ + _saved["fork"] = os.fork + subprocess.Popen.__init__ = _wrap_popen_init(_saved["popen_init"]) + os.fork = _wrap_fork(_saved["fork"]) + return True + + +def uninstall() -> None: + """残った子孫を止め、記録に無いゾンビを回収し、包みとシグナルの扱いを元へ戻す。""" + if not _saved: + return + stop_children() + with _lock: + _reap_unknown() + subprocess.Popen.__init__ = _saved.pop("popen_init") + os.fork = _saved.pop("fork") + signal.signal(signal.SIGCHLD, _saved.pop("sigchld")) + signal.signal(signal.SIGTERM, _saved.pop("sigterm")) + + +def pytest_configure(config) -> None: + if _state["config"] is None and install(): + _state["config"] = config + + +def pytest_unconfigure(config) -> None: + if _state["config"] is config: + uninstall() + _state["config"] = None diff --git a/scripts/tests/test_check_script_structure.py b/scripts/tests/test_check_script_structure.py index 1b4c60e8f..00dfccae4 100644 --- a/scripts/tests/test_check_script_structure.py +++ b/scripts/tests/test_check_script_structure.py @@ -227,12 +227,6 @@ def run_repo(repo: Path) -> tuple[int, dict]: return p.returncode, json.loads(p.stdout.strip().splitlines()[-1]) -def test_repository_matches_its_allow_list(): - """実物の木は例外リストと合う。直した移行ステップは、同じ PR で例外リストの行を消す。""" - p = subprocess.run([sys.executable, str(CHECK)], capture_output=True, text=True, cwd=REPO) - assert p.returncode == 0, p.stdout[-2000:] + p.stderr[-2000:] - - def test_structure_baseline_runs_on_a_given_root(tmp_path: Path): put(tmp_path, "scripts/a.py", "def f():\n return 1\n") put(tmp_path, "scripts/b.py", "def f():\n return 1\n") diff --git a/scripts/tests/test_pytest_child_cleanup.py b/scripts/tests/test_pytest_child_cleanup.py new file mode 100644 index 000000000..b21a3ee8d --- /dev/null +++ b/scripts/tests/test_pytest_child_cleanup.py @@ -0,0 +1,145 @@ +"""テストが起こした子孫のプロセスを、pytest を止めたときに残さない(#2291 の受け入れ条件 6・7、決定 4)。 + +- 打ち切る側(`test_triage.stop_group`)がグループを止めても、自分の session の子と、親が先に終わった孫が残らない +- 親だけに SIGTERM を送っても、xdist の worker とその子孫が残らない +- 子の回収役にしても、`subprocess` と `os.fork` の呼び手が受け取る終了コードは変わらない +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import textwrap +import threading +import time +from pathlib import Path + +import psutil +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) +sys.path.insert(0, str(ROOT / "plugins" / "ndf" / "scripts" / "lib")) + +import pytest_child_cleanup # noqa: E402 +import test_triage # noqa: E402 + +pytestmark = pytest.mark.skipif(not sys.platform.startswith("linux"), reason="子の回収役は Linux だけ") + + +def _bundle(tmp_path: Path) -> Path: + """自分の session の子と、親が先に終わる孫を起こして待つテスト 2 件の束。""" + b = tmp_path / "bundle" + b.mkdir() + (b / "pytest.ini").write_text("[pytest]\n") + (b / "conftest.py").write_text(f"import sys\nsys.path.insert(0, {str(SCRIPTS)!r})\npytest_plugins = ['pytest_child_cleanup']\n") + sleeper = f"{sys.executable} -c 'import time; time.sleep(60)' {b}" + (b / "test_spawn.py").write_text( + textwrap.dedent(f""" + import subprocess, sys, time, pytest + @pytest.mark.parametrize("n", [0, 1]) + def test_spawn(n): + subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)", {str(b)!r}], start_new_session=True) + subprocess.Popen(["setsid", "sh", "-c", {sleeper + " &"!r}]) + open({str(b)!r} + f"/ready-{{n}}", "w").close() + time.sleep(60) + """) + ) + return b + + +def _start(b: Path) -> subprocess.Popen: + p = subprocess.Popen( + [sys.executable, "-m", "pytest", str(b), "-q", "-n", "2", "-p", "no:cacheprovider"], + cwd=b, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + deadline = time.monotonic() + 60 + while not all((b / f"ready-{n}").exists() for n in (0, 1)): + assert time.monotonic() < deadline, "束のテストが子を起こすところまで進まない" + time.sleep(0.1) + time.sleep(0.5) # setsid の親が終わり、孫が付け替わるのを待つ + return p + + +def _left(b: Path) -> list[int]: + found = [] + for p in psutil.process_iter(["cmdline", "status"]): + try: + if p.info["status"] != psutil.STATUS_ZOMBIE and str(b) in " ".join(p.info["cmdline"] or []): + found.append(p.pid) + except psutil.Error: + pass + return found + + +def _assert_none_left(b: Path) -> None: + deadline = time.monotonic() + 10 + while (left := _left(b)) and time.monotonic() < deadline: + time.sleep(0.2) + for pid in left: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + assert left == [], f"止めた後も残っている: {left}" + + +def test_stopping_the_group_leaves_no_test_children(tmp_path): + b = _bundle(tmp_path) + p = _start(b) + test_triage.stop_group(p) + _assert_none_left(b) + + +def test_sigterm_to_the_parent_alone_stops_workers_and_their_children(tmp_path): + b = _bundle(tmp_path) + p = _start(b) + os.kill(p.pid, signal.SIGTERM) + p.wait(timeout=10) + _assert_none_left(b) + + +@pytest.mark.filterwarnings("ignore:This process:DeprecationWarning") +def test_exit_codes_reach_their_callers_under_the_reaper(): + assert pytest_child_cleanup._saved, "根の conftest が後始末の部品を置いていない" + codes: list[int] = [] + + def run(): + codes.extend(subprocess.run(["false"]).returncode for _ in range(200)) + + threads = [threading.Thread(target=run) for _ in range(4)] + for t in threads: + t.start() + run() + for t in threads: + t.join() + assert codes == [1] * 1000 + + pid = os.fork() + if pid == 0: + os._exit(7) + assert os.waitstatus_to_exitcode(os.waitpid(pid, 0)[1]) == 7 + + +def test_stopping_children_spares_itself_and_waits_for_them_together(tmp_path): + script = textwrap.dedent(f""" + import subprocess, sys, time + sys.path.insert(0, {str(SCRIPTS)!r}) + import pytest_child_cleanup as c + kids = [subprocess.Popen(["bash", "-c", "trap '' TERM; sleep 60"]) for _ in range(2)] + time.sleep(0.3) + start = time.monotonic() + c.stop_children() + print(round(time.monotonic() - start, 1), [k.poll() is not None for k in kids]) + """) + out = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + seconds, stopped = out.stdout.split(" ", 1) + assert float(seconds) < 5 + assert stopped.strip() == "[True, True]" diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 817fa5a7a..8fa10ed91 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -37,14 +37,14 @@ def _read_root_conftest() -> str: return ROOT_CONFTEST.read_text(encoding="utf-8") -def _run_pytest(target: str, *, path: str | None = None, env_extra: dict | None = None): +def _run_pytest(target: str, *args: str, path: str | None = None, env_extra: dict | None = None): env = dict(os.environ) env.pop("NDF_TESTS_ALLOW_MISSING_COMMANDS", None) if path is not None: env["PATH"] = path env.update(env_extra or {}) return subprocess.run( - [sys.executable, "-m", "pytest", target, "-q", "--no-header", "-p", "no:cacheprovider"], + [sys.executable, "-m", "pytest", target, *args, "-q", "--no-header", "-p", "no:cacheprovider"], cwd=str(REPO_ROOT), capture_output=True, text=True, @@ -71,11 +71,6 @@ def _path_without(tmp_path: Path, *names: str) -> str: return str(bin_dir) -def test_the_root_conftest_declares_the_plugin() -> None: - """`pytest_plugins` は最上位の conftest.py でしか宣言できない。""" - assert 'pytest_plugins = ["pytester"]' in _read_root_conftest() - - def test_the_skill_conftest_no_longer_declares_the_plugin() -> None: body = (REPO_ROOT / "plugins/playwright-kit/skills/playwright-kit-ops/tests/conftest.py").read_text(encoding="utf-8") assert 'pytest_plugins = ["pytester"]' not in body @@ -98,18 +93,11 @@ def test_no_bundle_skips_itself_when_a_command_is_missing() -> None: @pytest.mark.skipif(shutil.which("jq") is None, reason="jq を外した PATH を組み立てられない") def test_a_missing_command_fails_the_collection(tmp_path: Path) -> None: - """前提のコマンドが無ければ、読み飛ばさずに 0 以外の終了コードで終わる。""" - result = _run_pytest(BUNDLE, path=_path_without(tmp_path, "jq")) - - assert result.returncode != 0 - assert "jq" in result.stdout + result.stderr - - -@pytest.mark.skipif(shutil.which("jq") is None, reason="jq を外した PATH を組み立てられない") -def test_the_failure_names_the_bundle_and_the_command(tmp_path: Path) -> None: + """前提のコマンドが無ければ、読み飛ばさずに 0 以外の終了コードで終わり、束とコマンドの名前を出す。""" result = _run_pytest(BUNDLE, path=_path_without(tmp_path, "jq")) out = result.stdout + result.stderr + assert result.returncode != 0 assert BUNDLE in out assert "jq" in out @@ -138,8 +126,8 @@ def test_the_outside_bundle_is_really_outside_the_table() -> None: def test_a_bundle_outside_the_table_is_not_checked(tmp_path: Path) -> None: - """一覧に無い束だけを収集したときは、前提を確かめない。""" - result = _run_pytest(OUTSIDE_BUNDLE, path=_path_without(tmp_path, "jq")) + """一覧に無い束だけを収集したときは、前提を確かめない(確かめるのは収集の時点なので、収集だけで足りる)。""" + result = _run_pytest(OUTSIDE_BUNDLE, "--collect-only", path=_path_without(tmp_path, "jq")) assert result.returncode == 0, result.stdout + result.stderr @@ -269,14 +257,6 @@ def test_the_isolation_holds_from_a_bundle_directory() -> None: assert result.returncode == 0, result.stdout + result.stderr -def test_the_prefix_is_declared_once() -> None: - """接頭辞は根の設定だけが持つ。テストの側へ書き戻すと、同じ除去がまた散る。""" - body = _read_root_conftest() - - assert 'MONITOR_ENV_PREFIX = "MONITOR_"' in body - assert "def pytest_unconfigure" in body - - # ジョブの分割(#882)。**分けても項目が欠けず、重ならず、ファイルが割れないこと**を、 # 実際の収集で確かめる。分割が項目を落としても各ジョブと集約のジョブは成功し得るため、 # 継続的統合の結果だけでは退行に気づけない。