Repository navigation
Expand file tree
/
Copy pathconftest.py
More file actions
247 lines (202 loc) · 12.6 KB
/
Copy pathconftest.py
File metadata and controls
247 lines (202 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
"""リポジトリ全体のテストに共通する前提を、1 か所で用意する。
3 つのことを行う。
1. `pytest_plugins` の宣言をここへ置く。pytest は最上位以外の `conftest.py` での宣言を
受け付けないため、起点をリポジトリの根に置くと `playwright-kit-ops` の収集が中断する
2. 束ごとに要る外部コマンドを確かめ、無ければ収集の時点で失敗させる。読み飛ばしを既定に
すると、テストの 9 割以上が消えても終了コード 0 で終わる
3. テストの実行中だけ git の全体設定と system の設定を空へ向ける。身元を用意していない
テストは、実行した人の設定に関わらずその場で落ちる
4. テストの実行中だけ実行の要約の置き場所(`NDF_METRICS_DIR`)を一時ディレクトリへ向ける。
状態を保存するテストが、実行した人の状態ディレクトリへ要約を書かない(#662 の AC72)。
使用量の帳簿・プランの状態の実体・耐久の記録の置き場所は `plugins/ndf/conftest.py` が
テストごとに向ける(プラグインのキャッシュでも効くよう、プラグインの根に置く。#1953)
5. テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)を外す。上限を延ばした
シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678)
6. `SHARD_TOTAL` と `SHARD_INDEX` が与えられたら、収集した項目をファイル単位で分け、
自分の分だけを残す。継続的統合がジョブを分けて回すために使う(#882)
7. pytest が SIGTERM で止められたとき・終わったときに、テストが起こした子孫のプロセスを止める
(`scripts/pytest_child_cleanup.py`。#2291)
どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は
根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。
`playwright-kit-ops` のディレクトリを起点にした実行では、このファイルは読まれない。
`pytester` はそのディレクトリの `pyproject.toml` の `addopts` が読み込む。
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
import zlib
from pathlib import Path
import pytest
# `pytester` は playwright-kit の pytest plugin の自己テストが使う。pytest はこの宣言を
# 最上位の conftest.py でしか受け付けない。
# `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/<family>/dev.agy/skills/` は配布 Skill への symlink である。実体は
# `plugins/<family>/skills/` にあり、そちらを収集する。両方をたどると同じテストが 2 度
# 数えられ、件数が実際の 2 倍近くになる(agy を配布先へ加えた時点で 1569 件が 2974 件へ
# 増えた)。**生成物は収集の対象から外す。**
collect_ignore_glob = ["plugins/*/dev.agy"]
# 束ごとに要る外部コマンド。**束によって前提が違う**ため、一覧は 1 か所に置きつつ
# 収集した束にだけ適用する。全体へ課すと、必要のないコマンドを求めることになる。
REQUIRED_COMMANDS: dict[str, tuple[str, ...]] = {
"scripts/tests": ("bash", "jq", "git"),
"plugins/ndf/skills/worktree/tests": ("bash", "jq", "git"),
"plugins/ndf/skills/development-workflow/tests": ("bash", "jq", "git"),
# `test_prepare_worktrees.py` が `prepare-worktrees.sh` を bash で起動し、
# そのスクリプトが `jq` を要求する。残りのテストは `git` だけを使う。
"plugins/ndf/skills/cross-refactoring/tests": ("bash", "jq", "git"),
# `launch-reviewer.sh` と `rotate-pr.sh` を bash で起動する束がある。
# どちらも内部で `jq` を呼ぶ。
"plugins/ndf/skills/cross-review/tests": ("bash", "jq", "git"),
# `assert-characterization.sh` を bash で起動し、チェックの本体が `jq` と `python3` を呼ぶ。
"tests/runtime-smoke": ("bash", "jq", "python3"),
# スライドの生成スクリプト(pptxgenjs)を node で実行し、依存が無ければ `npm ci` で入れる(#2065)
"plugins/ndf/skills/release/tests/test_slide_template.py": ("node", "npm"),
"tests/test_presentation_decks.py": ("node", "npm"),
}
# 読み飛ばしを選ぶ指定。**既定は失敗**である。読み飛ばしたい実行環境のために残すが、
# 選ぶのは実行する側とする。
ALLOW_MISSING_ENV = "NDF_TESTS_ALLOW_MISSING_COMMANDS"
def _allow_missing() -> bool:
return os.environ.get(ALLOW_MISSING_ENV, "") not in ("", "0")
def _bundle_of(path: Path) -> str | None:
"""その項目が属する束を返す。一覧に無ければ `None`。"""
try:
rel = path.resolve().relative_to(ROOT).as_posix()
except ValueError:
return None
for bundle in REQUIRED_COMMANDS:
if rel == bundle or rel.startswith(bundle + "/"):
return bundle
return None
def _missing(bundles: set[str]) -> dict[str, list[str]]:
"""収集した束ごとに、不足している外部コマンドを返す。"""
found: dict[str, list[str]] = {}
for bundle in sorted(bundles):
lacking = [c for c in REQUIRED_COMMANDS[bundle] if shutil.which(c) is None]
if lacking:
found[bundle] = lacking
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_sessionfinish(session, exitstatus) -> None:
"""テストのセッションの終わりに、寿命の窓を過ぎた `ndf-tests-*`(止められたセッションの残り)を片付ける(#2229)。"""
sys.path.insert(0, str(Path(__file__).resolve().parent / "plugins" / "ndf" / "scripts" / "lib"))
import run_state
run_state.sweep_states(None, kinds=("test-tmp",), tmp_only=True)
# ジョブの分割(#882)。**分ける単位はファイルで、割り当てはパスの剰余だけで決める。**
# 同じファイルのテストは同じジョブへ入るため、ファイル単位の前提(モジュールの読み込み・
# 一時ディレクトリ)が割れない。偏りが出たら実測時間での割り当てへ進む。
SHARD_TOTAL_ENV = "SHARD_TOTAL"
SHARD_INDEX_ENV = "SHARD_INDEX"
def _shard() -> tuple[int, int] | None:
"""`(index, total)` を返す。指定が無ければ `None`(分けない)。"""
total = os.environ.get(SHARD_TOTAL_ENV, "")
if total == "":
return None
index = os.environ.get(SHARD_INDEX_ENV, "")
if not (total.isdigit() and index.isdigit() and int(index) < int(total)):
raise pytest.UsageError(
f"{SHARD_TOTAL_ENV}={total!r} / {SHARD_INDEX_ENV}={index!r}: "
f"0 <= {SHARD_INDEX_ENV} < {SHARD_TOTAL_ENV} の整数を指定してください"
)
return int(index), int(total)
def _select_shard(config, items) -> None:
shard = _shard()
if shard is None:
return
index, total = shard
kept, dropped = [], []
for item in items:
path = Path(str(item.fspath)).resolve()
rel = path.relative_to(ROOT).as_posix() if path.is_relative_to(ROOT) else path.as_posix()
(kept if zlib.crc32(rel.encode()) % total == index else dropped).append(item)
if dropped:
config.hook.pytest_deselected(items=dropped)
items[:] = kept
def pytest_collection_modifyitems(config, items) -> None:
_select_shard(config, items)
bundles = {b for item in items if (b := _bundle_of(Path(str(item.fspath)))) is not None}
missing = _missing(bundles)
if not missing:
return
lines = [f"{bundle}: {' '.join(commands)}" for bundle, commands in missing.items()]
if _allow_missing():
mark = pytest.mark.skip(reason=f"外部コマンドが無い({ALLOW_MISSING_ENV} の指定により読み飛ばす)")
for item in items:
if _bundle_of(Path(str(item.fspath))) in missing:
item.add_marker(mark)
return
raise pytest.UsageError(
"テストの実行に要る外部コマンドが見つかりません:\n "
+ "\n ".join(lines)
+ f"\n読み飛ばして実行する場合は {ALLOW_MISSING_ENV}=1 を指定してください。"
)
@pytest.fixture(scope="session", autouse=True)
def _isolated_git_identity() -> object:
"""テストの実行中だけ、git の全体設定と system の設定を空へ向ける。
身元を用意し直しても、実行した人の全体設定が残っていれば、次に同じ依存が入ったときに
気づけない。空へ向けておけば、用意していないテストはその場で落ちる。**実行した人の
設定は書き換えない。** 向け先は一時ディレクトリの空のファイルで、実行が終われば消える。
"""
with tempfile.TemporaryDirectory(prefix="ndf-tests-git-") as tmp:
empty = Path(tmp) / "gitconfig"
empty.write_text("", encoding="utf-8")
saved = {k: os.environ.get(k) for k in ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM")}
os.environ["GIT_CONFIG_GLOBAL"] = str(empty)
os.environ["GIT_CONFIG_SYSTEM"] = os.devnull
try:
yield empty
finally:
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
@pytest.fixture(scope="session", autouse=True)
def _isolated_metrics_dir() -> object:
"""テストの実行中だけ、実行の要約の置き場所を一時ディレクトリへ向ける(#662)。
状態を保存する副コマンドは、保存のたびに要約を書く。向けないと、テストを回すたびに
実行した人の `~/.local/state/ndf/metrics` へ偽の実行が積もり、集計を汚す。
**`NDF_METRICS=0` にはしない。** 書き出しの経路そのものをテストで通すためである。
子プロセスは環境変数を受け継ぐため、同じ向け先へ書く。
"""
with tempfile.TemporaryDirectory(prefix="ndf-tests-metrics-") as tmp:
saved = {k: os.environ.get(k) for k in ("NDF_METRICS_DIR", "NDF_METRICS")}
os.environ["NDF_METRICS_DIR"] = tmp
os.environ.pop("NDF_METRICS", None)
try:
yield Path(tmp)
finally:
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value