diff --git a/conftest.py b/conftest.py index 0b80e99e..ccf49f53 100644 --- a/conftest.py +++ b/conftest.py @@ -10,6 +10,11 @@ テストは、実行した人の設定に関わらずその場で落ちる 4. テストの実行中だけ実行の要約の置き場所(`NDF_METRICS_DIR`)を一時ディレクトリへ向ける。 状態を保存するテストが、実行した人の状態ディレクトリへ要約を書かない(#662 の AC72) +5. テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)を外す。上限を延ばした + シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678) + +どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は +根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。 `playwright-kit-ops` のディレクトリを起点にした実行では、このファイルは読まれない。 `pytester` はそのディレクトリの `pyproject.toml` の `addopts` が読み込む。 @@ -82,6 +87,41 @@ def _missing(bundles: set[str]) -> dict[str, list[str]]: return found +# 監視の上限を指す環境変数の接頭辞(#678)。担当ごとの指定・共通の指定のどちらもこの +# 接頭辞を持つため、接頭辞だけで一致させる。名前を並べると、上限の種類が増えるたびに +# ここへ足し忘れる。 +MONITOR_ENV_PREFIX = "MONITOR_" + +# `pytest_configure` で外した値の控え。実行が終わったときに戻す。 +_saved_monitor_env: dict[str, str] = {} + + +def _strip_monitor_env() -> dict[str, str]: + """接頭辞の環境変数を外し、外した値を返す。""" + return {k: os.environ.pop(k) for k in list(os.environ) if k.startswith(MONITOR_ENV_PREFIX)} + + +def pytest_configure(config) -> None: + """テストの実行中だけ、監視の上限を指す環境変数を外す(#678)。 + + 無進捗の許容と打ち切りの上限は環境変数で延ばせる。運用で延ばしたシェルから起動すると、 + 表の既定値を前提にするテストが既定値ではなくその値を読み、変更の中身と関係なく落ちる。 + 収束ループの初期化は着手前のテストの通過を条件にするため、そこで止まる。 + + **収集より前に外す。** テストの本体を読み込む時点で上限を決めてしまう実装があり、 + セッションの前提(fixture)では間に合わない。子プロセスは環境変数を受け継ぐため、 + テストが起動する別プロセスにも同じ切り離しが効く。**個別に設定するテストは打ち消さない。** + `monkeypatch` も、別プロセスへ渡す上書きも、この後に効く。 + """ + _saved_monitor_env.update(_strip_monitor_env()) + + +def pytest_unconfigure(config) -> None: + """実行が終わったら、外した環境変数を戻す。""" + os.environ.update(_saved_monitor_env) + _saved_monitor_env.clear() + + def pytest_collection_modifyitems(config, items) -> None: bundles = {b for item in items if (b := _bundle_of(Path(str(item.fspath)))) is not None} missing = _missing(bundles) diff --git a/issues/issue-678-requirements.md b/issues/issue-678-requirements.md new file mode 100644 index 00000000..b91ae13b --- /dev/null +++ b/issues/issue-678-requirements.md @@ -0,0 +1,89 @@ +# テストの前提: 監視の上限を環境変数で延ばしたシェルでは既定値を前提にするテストが落ち、収束ループの初期化が中断する → テストの実行中は監視の環境変数を共通の前提で外す(#678) + +## 目的 + +無進捗の許容と打ち切りの上限は、環境変数で延ばせる。運用でこれを延ばしたシェルから全体の +テストを起動すると、既定値を前提にするテストが落ちる。落ちた原因はテストの実行環境にあり、 +変更の中身にはない。 + +収束ループ(`cross-refactoring`)の初期化は着手前のテストの通過を条件にするため、上限を +延ばしたシェルでは初期化がそこで止まる。 + +**テストの実行中だけ、監視の環境変数を利用者の環境から切り離す。** 切り離しの置き場所は +リポジトリ直下の共通の前提(`conftest.py`)とし、テストごとに散った除去をそこへ寄せる。 + +## 対象範囲 + +**含む** + +- リポジトリ直下の共通の前提へ、監視の環境変数(接頭辞 `MONITOR_`)を外す仕組みを足す +- テストごとに散った同じ除去を取り除く(1 変数ずつ外す箇所と、接頭辞でまとめて外す箇所) +- 共通の前提が働いていることを確かめるテストを足す + +**含まない** + +- 上限の解決順(担当ごとの指定 → 共通の指定 → 表の既定)の変更 +- 上限の表の値の変更 +- 監視の本体・起動スクリプト・Skill 本文の変更 + +## 前提 + +- 本番の振る舞いも本番コードの構造も変えない。変えるのはテストの前提だけである +- 子プロセスは実行中の環境変数を受け継ぐため、共通の前提で外せば、テストが起動する + 別プロセスにも同じ切り離しが効く +- テストの中で監視の環境変数を設定する箇所(`monkeypatch.setenv`・別プロセスへ渡す + 上書き)は、共通の前提より後に効くため、そのまま働く + +## 着手前の実測(2026-09-21) + +同じコマンドを、監視の環境変数を設定したシェルと、していないシェルで実行した。 + +```console +$ MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q +FAILED plugins/ndf/skills/cross-review/tests/test_launch_agy.py::test_the_print_timeout_defaults_to_the_longest_phase +FAILED plugins/ndf/skills/cross-review/tests/test_monitor_agy.py::test_the_new_name_has_a_stall_default +FAILED plugins/ndf/skills/cross-review/tests/test_monitor_import_safety.py::test_import_succeeds_with_non_numeric_monitor_stall +3 failed, 4600 passed in 182.10s + +$ uv run --with pytest pytest scripts/tests plugins/ndf -q +4603 passed in 179.67s +``` + +| 観測 | 値 | +| --- | --- | +| 収集した件数 | 4603(どちらのシェルでも同じ) | +| 設定したシェルで落ちる件数 | 3 | +| 設定していないシェルで落ちる件数 | 0 | + +課題の本文が記録した 2026-09-15 の観測では落ちるのが 2 件、その後の追記で 3 件だった。 +件数は着手時点の実測で 3 件のまま変わらない。 + +## 受け入れ条件 + +- [x] 受け入れ条件 1: 監視の環境変数(`MONITOR_TIMEOUT_AGY` と `MONITOR_STALL_AGY`)を設定した + シェルで全体のテストを実行すると、失敗が 0 件になる +- [x] 受け入れ条件 2: 設定したシェルと設定していないシェルで、通過した件数と失敗した件数が + 一致する +- [x] 受け入れ条件 3: 共通の前提が接頭辞 `MONITOR_` の環境変数を外していることを、テストが + 直接確かめる(実行中に該当する環境変数が 1 つも残らない) +- [x] 受け入れ条件 4: テストの中で監視の環境変数を設定する箇所は、共通の前提を足した後も + 同じ値を観測できる(共通の前提が個別の設定を打ち消さない) +- [x] 受け入れ条件 5: 接頭辞でまとめて外していた箇所と、1 変数ずつ外していた箇所が、 + 共通の前提へ寄る(対象のファイルに同じ除去が残らない) +- [x] 受け入れ条件 6: 共通の前提は、テストの実行が終わった後に元の環境変数を戻す + +## 検証手段 + +| 条件 | 確かめ方 | +| --- | --- | +| 1 / 2 | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q` と、設定しない同じコマンドの 2 回を実行し、件数を突き合わせる | +| 3 / 4 / 6 | 共通の前提を確かめるテスト(`scripts/tests/test_root_conftest.py`)を実行する | +| 5 | `grep -rn "MONITOR_" --include="*.py" <テストのディレクトリ>` の結果に、接頭辞での除去と 1 変数ずつの除去が残らないことを確かめる | + +## 境界 + +```text +常に行う … 共通の前提の追加、散った除去の削除、両方のシェルでの全体テスト +確認してから行う … 上限の解決順・表の既定値に触れる変更(この変更では行わない) +行わない … 監視の本体・起動スクリプト・Skill 本文の変更、依頼範囲外の整形 +``` diff --git a/plugins/ndf/scripts/tests/test_limits.py b/plugins/ndf/scripts/tests/test_limits.py index 6b89b410..ca3a76da 100644 --- a/plugins/ndf/scripts/tests/test_limits.py +++ b/plugins/ndf/scripts/tests/test_limits.py @@ -26,26 +26,19 @@ @pytest.fixture() -def limits(monkeypatch): - # **表の既定値を読むテストである。** 実行した人の環境の `MONITOR_*` を外す(#678)。 - for key in [k for k in os.environ if k.startswith("MONITOR_")]: - monkeypatch.delenv(key) +def limits(): + # 表の既定値を読むテストである。実行した人の環境の `MONITOR_*` は、根の + # `conftest.py` が実行中だけ外す(#678)。 spec = importlib.util.spec_from_file_location("ndf_lib_limits", LIMITS) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod -def _clean_env(**over: str) -> dict[str, str]: - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} - env.update(over) - return env - - def _run(*args: str, **env: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, str(LIMITS), *args], - env=_clean_env(**env), capture_output=True, text=True, + env={**os.environ, **env}, capture_output=True, text=True, ) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py b/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py index 595f7c1a..6ca8462f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py @@ -59,8 +59,7 @@ def _launch(tmp_path: pathlib.Path, phase: str) -> tuple[list[str], pathlib.Path subprocess.run( [str(LAUNCH), RUNTIME, phase, "130", "1"], env={ - # 実行した人の `MONITOR_*` で上限が変わらないよう外す(#678)。 - **{k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")}, + **os.environ, "CROSS_REFACTORING_TMP_DIR": str(state_path.parent), "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "NDF_TEST_ARGS_FILE": str(args_file), diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py b/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py index 0bd49631..64bd0a7b 100644 --- a/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py +++ b/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py @@ -34,8 +34,7 @@ def _env(tmp_path: pathlib.Path, **over: str) -> dict[str, str]: stub = bin_dir / "agy" stub.write_text(STUB, encoding="utf-8") stub.chmod(0o755) - # 実行した人の `MONITOR_*` で値が変わらないよう外す(#678)。 - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} + env = dict(os.environ) env.pop("NDF_CRITIQUE_PRINT_TIMEOUT", None) env.update({ "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py b/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py index 625cebcd..45c030ee 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py @@ -181,11 +181,8 @@ def test_claude_stdout_scan_ignores_missing_file(monitor_mod, tmp_path): # ---------- 5. 追加ランタイムの stall 既定 ---------- -def test_stall_defaults_cover_claude_and_kiro(monitor_mod, monkeypatch): +def test_stall_defaults_cover_claude_and_kiro(monitor_mod): """`claude -p` は完了まで無出力なので、最も長い既定を持つこと。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CLAUDE", raising=False) - monkeypatch.delenv("MONITOR_STALL_KIRO", raising=False) assert monitor_mod._agent_stall_default("claude") == 900 assert monitor_mod._agent_stall_default("kiro") == 480 diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py b/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py index 56b0b6d9..ebf1b0ee 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py @@ -60,11 +60,10 @@ def _dead_pid() -> int: def _run_monitor(tmp_dir: pathlib.Path, *extra: str, script: pathlib.Path = _MONITOR_LIB, pr: int = 7, agents: str = "codex") -> subprocess.CompletedProcess: - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} return subprocess.run( [sys.executable, str(script), str(pr), "--agents", agents, "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], - capture_output=True, text=True, env=env, timeout=60, + capture_output=True, text=True, timeout=60, ) diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py b/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py index 12414c80..b329c0d2 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py @@ -32,11 +32,10 @@ def _run(tmp_dir: pathlib.Path, *extra: str, agents: str = "agy", for agent in agents.split(","): (tmp_dir / f"{agent}-review-pr7.pid").write_text(str(_dead_pid())) (tmp_dir / f"{agent}-review-pr7-result.json").write_text('{"event": "APPROVE"}') - base = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} return subprocess.run( [sys.executable, str(_MONITOR_LIB), "7", "--agents", agents, "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], - capture_output=True, text=True, env={**base, **(env or {})}, timeout=60, + capture_output=True, text=True, env={**os.environ, **(env or {})}, timeout=60, ) diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py index 71f6f245..62d8c31d 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py @@ -7,31 +7,26 @@ agy は err.log にほぼ進捗を出さないため、ビルトイン既定を 480s と大きめに 取って 1 度目の STALLED 誤検知を避ける。codex は従来通り 180s で変更なし。 + +実行した人の `MONITOR_*` は、根の `conftest.py` が実行中だけ外す(#678)。 """ from __future__ import annotations import pytest -def test_builtin_default_codex(monkeypatch, monitor_mod): +def test_builtin_default_codex(monitor_mod): """codex のビルトイン既定は 180s。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) assert monitor_mod._agent_stall_default("codex") == 180 -def test_builtin_default_agy(monkeypatch, monitor_mod): +def test_builtin_default_agy(monitor_mod): """agy のビルトイン既定は 480s (codex より大きい)。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) assert monitor_mod._agent_stall_default("agy") == 480 def test_per_agent_env_overrides_builtin(monkeypatch, monitor_mod): """env `MONITOR_STALL_AGY` 設定で agy 既定が上書きされる。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "600") assert monitor_mod._agent_stall_default("agy") == 600 # codex は影響を受けない @@ -46,8 +41,6 @@ def test_shared_env_applies_to_both(monkeypatch, monitor_mod): 本テストは monkeypatch で `MONITOR_STALL=240` に書き換え、両 agent が 240 を 返すことを確認する (= 共通 env が実際に反映されることの検証)。 """ - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) monkeypatch.setenv("MONITOR_STALL", "240") # 共通 env が両 agent に効く (per-agent 上書きなしの場合) assert monitor_mod._agent_stall_default("codex") == 240 @@ -58,16 +51,13 @@ def test_per_agent_env_takes_precedence_over_shared(monkeypatch, monitor_mod): """per-agent env > 共通 env の優先順位を確認する。""" monkeypatch.setenv("MONITOR_STALL", "240") monkeypatch.setenv("MONITOR_STALL_AGY", "777") - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) assert monitor_mod._agent_stall_default("agy") == 777 # codex 側は per-agent env が無いので 共通 env (= 240) にフォールバック assert monitor_mod._agent_stall_default("codex") == 240 -def test_unknown_agent_falls_back_to_default_stall(monkeypatch, monitor_mod): +def test_unknown_agent_falls_back_to_default_stall(monitor_mod): """ビルトインに無い agent 名は `DEFAULT_STALL` にフォールバックする。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_UNKNOWN", raising=False) assert monitor_mod._agent_stall_default("unknown") == monitor_mod.DEFAULT_STALL @@ -80,8 +70,6 @@ def test_shared_env_non_numeric_falls_back_to_builtin(monkeypatch, monitor_mod, gemini round 4 指摘: `int(os.environ[...])` は非数値で ValueError を出す。 監視プロセスを env 設定ミスでクラッシュさせないため、try/except で builtin に戻す。 """ - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) monkeypatch.setenv("MONITOR_STALL", "abc") # codex / agy とも builtin 既定 (180 / 480) に戻る assert monitor_mod._agent_stall_default("codex") == 180 @@ -96,9 +84,7 @@ def test_per_agent_env_non_numeric_falls_back_to_builtin( monkeypatch, monitor_mod, capsys ): """env `MONITOR_STALL_` が非数値なら builtin にフォールバック。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "not-a-number") - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) # agy は builtin (480) にフォールバック assert monitor_mod._agent_stall_default("agy") == 480 # codex は env 未設定なので builtin (180) @@ -112,7 +98,6 @@ def test_per_agent_env_non_numeric_does_not_affect_other_agent( monkeypatch, monitor_mod ): """non-numeric な per-agent env は対象 agent だけに影響する。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "xxx") monkeypatch.setenv("MONITOR_STALL_CODEX", "200") # codex 側は正常 assert monitor_mod._agent_stall_default("codex") == 200 diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py index 490bda45..4a04ff71 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py @@ -17,7 +17,6 @@ from __future__ import annotations import json -import os import pathlib import subprocess import sys @@ -170,11 +169,10 @@ def _dead_pid() -> int: def _run_monitor(tmp_dir: pathlib.Path, agent: str, *extra: str) -> subprocess.CompletedProcess: - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} return subprocess.run( [sys.executable, str(_MONITOR_LIB), "7", "--agents", agent, "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], - capture_output=True, text=True, env=env, timeout=60, + capture_output=True, text=True, timeout=60, ) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..47b2c6e3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +# 起点のディレクトリに関わらず、テストの基準のディレクトリ(rootdir)をリポジトリの根へ +# 解決させるために置く。pytest は起点から上へ設定ファイルを探し、見つかったところで止まる。 +# 根に設定ファイルが 1 つも無いと、テストの束のディレクトリを起点にした実行では基準が +# そこで止まり、根の共通の前提(`conftest.py`)が読み込まれない。監視の上限を指す環境変数 +# (接頭辞 `MONITOR_`)の除去のように、どの起点でも効かなければならない前提がここに載る。 +# +# **設定値は足さない。** `testpaths` などを書くと、既存の実行が対象にする範囲が変わる。 +[pytest] diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 9306fb20..f575da6d 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -1,10 +1,11 @@ """リポジトリの根の設定が持つ前提を固定する(#232 / #233 / #235)。 -3 つのことを確かめる。 +4 つのことを確かめる。 1. 起点をリポジトリの根に置いても収集が中断しない(`pytest_plugins` の宣言の位置) 2. 前提の外部コマンドが無いとき、読み飛ばさずに 0 以外の終了コードで終わる 3. テストの実行中は git の全体設定と system の設定を読まない +4. テストの実行中は監視の上限を指す環境変数を読まない(#678) 前提の不足は、`PATH` を絞った子プロセスとして pytest を起動して確かめる。実行環境の `PATH` は書き換えない。 @@ -165,3 +166,116 @@ def test_metrics_dir_points_to_a_temporary_directory_during_tests() -> None: assert metrics, "NDF_METRICS_DIR が設定されていない" assert Path(metrics).resolve().is_relative_to(Path(tempfile.gettempdir()).resolve()) assert "NDF_METRICS" not in os.environ + + +# ---------- 監視の上限を指す環境変数の切り離し(#678) ---------- + + +def _root_conftest_module(): + """根の設定を別名で読み込む。控えの辞書を汚さずに、外す側と戻す側を直接呼ぶ。""" + import importlib.util + + spec = importlib.util.spec_from_file_location("ndf_root_conftest", ROOT_CONFTEST) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_no_monitor_variable_survives_into_a_test(monkeypatch: pytest.MonkeyPatch) -> None: + """外す側を呼んだ後は、監視の上限を指す環境変数が 1 つも残らない。 + + **確かめる前に自分で 1 つ差し込む。** 周りのシェルが上限を持たないと、外す仕組みを + 壊しても素通りする。差し込んでおけば、起動したシェルが何を持っていても同じことを + 確かめられる。控えを汚さないよう、根の設定は別名で読み込む。 + """ + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + try: + mod.pytest_configure(None) + + remaining = [k for k in os.environ if k.startswith("MONITOR_")] + assert remaining == [], remaining + finally: + mod.pytest_unconfigure(None) + + +def test_a_test_can_still_set_its_own_monitor_variable(monkeypatch: pytest.MonkeyPatch) -> None: + """個別に設定した値は打ち消されない。切り離しは実行の前に 1 度だけ効く。""" + monkeypatch.setenv("MONITOR_STALL_AGY", "600") + assert os.environ["MONITOR_STALL_AGY"] == "600" + + +def test_the_child_process_does_not_inherit_a_monitor_variable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """外した後に起動した子プロセスは、監視の上限を指す環境変数を受け継がない。 + + 起動する側で外し直さなくてよいことを確かめる。**確かめる前に自分で 1 つ差し込む。** + 周りのシェルが上限を持たないと、外す仕組みを壊しても素通りする。控えを汚さないよう、 + 根の設定は別名で読み込む。 + """ + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + try: + mod.pytest_configure(None) + + out = subprocess.run( + [sys.executable, "-c", + "import os; print([k for k in os.environ if k.startswith('MONITOR_')])"], + capture_output=True, text=True, + ) + assert out.stdout.strip() == "[]", out.stdout + finally: + mod.pytest_unconfigure(None) + + +def test_the_values_are_put_back_after_the_run(monkeypatch: pytest.MonkeyPatch) -> None: + """外した値は、実行が終わったときに戻る。""" + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + mod.pytest_configure(None) + + assert "MONITOR_STALL_AGY" not in os.environ + assert mod._saved_monitor_env == {"MONITOR_STALL_AGY": "1800"} + + mod.pytest_unconfigure(None) + + assert os.environ["MONITOR_STALL_AGY"] == "1800" + assert mod._saved_monitor_env == {} + + +def test_the_isolation_holds_from_a_bundle_directory() -> None: + """束のディレクトリを起点にしても切り離しが効く。 + + テストの基準のディレクトリ(rootdir)が起点で止まると、根の設定が読み込まれず、 + 上限を延ばしたシェルから起動したときだけ落ちる。根の設定ファイル(`pytest.ini`)が + 基準をリポジトリの根へ固定していることを、実際の起動で確かめる。 + **監視の環境変数は明示的に足す。** 実行中は根の設定が外した後のため、渡す環境へ + 足さないと再現しない。 + """ + bundle = REPO_ROOT / "plugins/ndf/skills/cross-review/tests" + env = dict(os.environ) + env.pop("NDF_TESTS_ALLOW_MISSING_COMMANDS", None) + env.update({"MONITOR_STALL_AGY": "1800", "MONITOR_TIMEOUT": "1800"}) + + result = subprocess.run( + [sys.executable, "-m", "pytest", "test_monitor_stall_default.py", "-q", + "--no-header", "-p", "no:cacheprovider"], + cwd=str(bundle), + capture_output=True, + text=True, + env=env, + ) + + 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