Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<!-- 実行したコマンドと結果を書く。実行していないものはチェックを付けず、理由を残す -->

- [ ] `uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q`
- [ ] `uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n auto`
- [ ] `python3 scripts/check-skill-frontmatter.py`
- [ ] `python3 scripts/check-doc-staleness.py`
- [ ] `python3 scripts/check-markdown-links.py --root .`
Expand Down
34 changes: 29 additions & 5 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ name: Python tests
# 「成功」ではなく「実行されていない」として扱うため、絞り込んだままだと対象外の変更で
# マージ可能にならない。push の側は絞り込みを残す(マージの可否を決めないため)
#
# push の branches に release/** を置かない。release/** から出した Pull Request では
# pull_request の側が必ず回るため、push の側も起動すると同じテストを 2 回回す(#884)。
# main / develop はマージ後の確認として残す
#
# push の絞り込みは pytest . の収集範囲に合わせる。テストの置き場所(plugins / scripts /
# tests)に加えて、収集の仕方そのものを変える根の設定ファイルも対象へ入れる
on:
Expand All @@ -13,7 +17,6 @@ on:
branches:
- main
- develop
- "release/**"
paths:
- ".github/workflows/pytest.yml"
- "plugins/**"
Expand All @@ -24,18 +27,39 @@ on:
- "pyproject.toml"

jobs:
pytest:
# ファイル単位で 2 つのジョブへ分けて回す(#882)。割り当ては根の conftest.py が
# SHARD_TOTAL / SHARD_INDEX から決める。各ジョブの中は -n auto で runner のコア数だけ
# 並列に回す(pytest-xdist は --project が指す pyproject.toml の依存に入っている)
pytest-shard:
name: pytest (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [0, 1]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install uv
run: python3 -m pip install --upgrade uv
# 起点はリポジトリの根に置き、1 回の起動で全件を回す。playwright-kit のテストは
# その skill の pyproject.toml が宣言する依存を使うため、--project でそこを指す。
# 起点はリポジトリの根に置く。playwright-kit のテストはその skill の pyproject.toml が
# 宣言する依存を使うため、--project でそこを指す。
#
# 前提のコマンドの確認と git の身元の設定は、根の conftest.py が行う。前提を
# ワークフローの側にも置くと、増えたときに片方だけが更新される
- run: uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q
- run: uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n auto
env:
SHARD_TOTAL: 2
SHARD_INDEX: ${{ matrix.shard }}

# ruleset の必須の検査は `pytest` という名前の結果を 1 つ待つ。分けたジョブの結果を
# ここで 1 つにまとめる。名前を変えると ruleset の変更が要る。
# always() で分けたジョブが落ちても走らせ、失敗・取り消しを失敗として返す
pytest:
needs: pytest-shard
if: always()
runs-on: ubuntu-latest
steps:
- run: test "${{ needs.pytest-shard.result }}" = success
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ cd .worktrees/feature/<名前>
検査です。リポジトリの根から実行します。

```bash
# 1. テスト(継続的統合と同じ範囲を 1 回の起動で回す)
uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q
# 1. テスト(継続的統合と同じ範囲を 1 回の起動で回す。-n auto でコア数だけ並列に回す)
uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n auto

# 2. Skill の frontmatter の規約
python3 scripts/check-skill-frontmatter.py
Expand Down
40 changes: 40 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
状態を保存するテストが、実行した人の状態ディレクトリへ要約を書かない(#662 の AC72)
5. テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)を外す。上限を延ばした
シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678)
6. `SHARD_TOTAL` と `SHARD_INDEX` が与えられたら、収集した項目をファイル単位で分け、
自分の分だけを残す。継続的統合がジョブを分けて回すために使う(#882)

どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は
根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。
Expand All @@ -24,6 +26,7 @@
import os
import shutil
import tempfile
import zlib
from pathlib import Path

import pytest
Expand Down Expand Up @@ -122,7 +125,44 @@ def pytest_unconfigure(config) -> None:
_saved_monitor_env.clear()


# ジョブの分割(#882)。**分ける単位はファイルで、割り当てはパスの剰余だけで決める。**
# 同じファイルのテストは同じジョブへ入るため、ファイル単位の前提(モジュールの読み込み・
# 一時ディレクトリ)が割れない。偏りが出たら実測時間での割り当てへ進む。
SHARD_TOTAL_ENV = "SHARD_TOTAL"
SHARD_INDEX_ENV = "SHARD_INDEX"


def _shard() -> tuple[int, int] | None:
"""`(index, total)` を返す。指定が無ければ `None`(分けない)。"""
Comment thread
takemi-ohama marked this conversation as resolved.
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:
Expand Down
6 changes: 3 additions & 3 deletions docs/plugin-development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ bash scripts/build-runtime-plugins.sh --check
bash scripts/validate-runtime-plugins.sh
python3 scripts/check-doc-staleness.py --root .
python3 scripts/check-skill-frontmatter.py
uv run --with pytest pytest scripts/tests -q
uv run --with pytest --with pytest-xdist pytest scripts/tests -q -n auto
```

## 既存プラグインの削除
Expand Down Expand Up @@ -275,10 +275,10 @@ bash scripts/validate-runtime-plugins.sh
`scripts/` 自体を変更した場合は、その検査のテストも実行します。

```bash
uv run --with pytest pytest scripts/tests -q
uv run --with pytest --with pytest-xdist pytest scripts/tests -q -n auto
```

`--with pytest` を省くと `Failed to spawn: pytest` で終わります。リポジトリの根に uv の
`--with pytest` を省くと `Failed to spawn: pytest` で終わります。`-n auto` は `pytest-xdist` の指定で、コア数だけ並列に回します(`--with pytest-xdist` を省くと `-n` を解釈できずに終わります)。リポジトリの根に uv の
対象プロジェクト(`pyproject.toml`)が無く、`pytest` が環境にも入っていないためです。
`plugins/ndf/skills/*/tests/` の既存のテストも同じ形で実行します。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ clone した全員で同じ値のまま残る。
| 区切り・プロセス置換・引用符の中の `<` で出力が変わらない | 同上 |
| 手順 0 が `init` の失敗で止まることが本文に書かれている | 同 `test_setup.py`(本文の抜き出し) |

実行は `uv run --with pytest pytest scripts/tests plugins/ndf -q`。配布物の同期は
実行は `uv run --with pytest --with pytest-xdist pytest scripts/tests plugins/ndf -q -n auto`。配布物の同期は
`bash scripts/build-runtime-plugins.sh --check`、定義の検査は `claude plugin validate .` で見る。

## 運用
Expand Down
2 changes: 1 addition & 1 deletion docs/specifications/test-monitor-env-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@

| 観点 | 確かめ方 |
| --- | --- |
| 監視の環境変数を設定したシェルでも、全体のテストの失敗が 0 件で、設定しないシェルと件数が一致すること | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q` と、設定しない同じコマンドを比べる |
| 監視の環境変数を設定したシェルでも、全体のテストの失敗が 0 件で、設定しないシェルと件数が一致すること | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest --with pytest-xdist pytest scripts/tests plugins/ndf -q -n auto` と、設定しない同じコマンドを比べる |
| 実行中のテストに接頭辞 `MONITOR_` の環境変数が 1 つも残らないこと | `scripts/tests/test_root_conftest.py` |
| テストが自分で設定した値は観測できること | 同上 |
| 別プロセスへ受け継がれないこと | 同上 |
Expand Down
32 changes: 32 additions & 0 deletions issues/issue-882-884-test-speedup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# #882 #884 テストの高速化(要求と受け入れ条件)

モード: `light`(テストと継続的統合の設定、テストを案内する文書だけを触る。本番のスクリプトは変えない)

## 要求

全体テストを回すたびの待ちを縮める。手元・継続的統合・`cross-refactoring` の `--baseline-test` の
どれでも、全体テストの所要がそのまま待ちになっている(#880)。

## 範囲

| 課題 | この変更で行う | 行わない(理由と行き先) |
| --- | --- | --- |
| #882 | 継続的統合の全体テストを `-n auto` で並列に回す。案内するコマンド(`CONTRIBUTING.md`・PR テンプレート・`docs/plugin-development-guide.md`・確定仕様 2 件)を並列の形にする。並列で壊れるテストを直す。並列にした後の継続的統合のジョブが 2 分 6 秒(テスト 1 分 53 秒)で 2 分を超えたため、ファイル単位の 2 分割(根の `conftest.py` の `SHARD_TOTAL` / `SHARD_INDEX`)とまとめジョブ `pytest` を置く(段 2) | — |
| #884 | 競合試験の重複を共通実装への 1 通りへ寄せる。繰り返しの回数を目的に必要な数まで減らす(`test_records_at_once_never_skip_a_stage` 8 → 4 回、`test_lock_held_passes` 4 → 3 回)。`release/**` の push 契機を外し、継続的統合の 2 重実行をやめる | `bg-wait.sh` のポーリング間隔(本番のスクリプトの変更)、ロックの上限 5 秒の注入(本番のスクリプトの変更。#293 の決定に関わる)、`test_git_facts.py` の猶予待ち(#883) |

## 受け入れ条件

| # | 条件 | 確かめ方 |
| --- | --- | --- |
| AC1 | 継続的統合の pytest ジョブが `-n auto` で走り、全件合格する | Pull Request の `pytest` の検査 |
| AC2 | 全体テストを案内するコマンドがすべて並列の形になっている | `grep -rn "pytest scripts/tests plugins/ndf -q\|pytest \. -q"`(`issues/` と記録の文書を除く) |
| AC3 | 手元の並列実行(`-n 4`)で全件合格し、変更前の順の実行より速い | 前後の所要を実測して PR 本文へ載せる |
| AC4 | 継続的統合の所要が変更前(テスト 5 分 30 秒)の半分以下 | Pull Request の実行時間 |
| AC5 | 寄せた競合試験が、臨界区間の重なりを変更後も失敗として検出する | `ndf_lock_acquire` の排他を外した状態で `test_many_at_once_never_share_the_critical_section` が落ちることを 1 度確かめる |
| AC6 | 減らした繰り返しのテストが、見ている不具合を変更後も検出する | 同上の手順で、対象の排他を外すと落ちることを確かめる |
| AC7 | `release/**` からの Pull Request で `Python tests` が 1 回だけ起動する | `on.push.branches` に `release/**` が無い |
| AC8 | 分割しても ruleset の必須の検査 `pytest` が 1 つの結果として返り、ruleset を変えずにマージできる | Pull Request の検査の一覧で `pytest` が合格し、マージ可能になる |

#884 の受け入れ条件「0.5 秒以上のテストの合計が 40 秒以下」は、この変更だけでは満たさない。
残る大口(`test_git_facts.py` 21 秒・`test_bg_wait.py` 19 秒・ロックの上限を待つテスト)は
本番のスクリプトの変更を要するため、#884 は開いたまま残し、残りを本文へ記録する。
4 changes: 3 additions & 1 deletion plugins/ndf/scripts/tests/test_token_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,9 @@ def test_lock_held_passes(tmp_path, state):
(lock / "held").write_text("")
(lock / "pid").write_text(str(os.getpid()))
(lock / "token").write_text("t")
for _ in range(4):
# 3 回目は排他を取れれば拒否される回数である(test_repeat_read_denied_on_third)。
# 1 回ごとに排他の上限(1 秒)を待つため、それを越えて回さない(#884)
for _ in range(3):
assert denied(run(read(f), state)) is None


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,15 +283,16 @@ def test_records_at_once_never_skip_a_stage(repo: Path, state: Path) -> None:
"""#308-2 / 3: 同時の記録を繰り返しても工程が欠けず、飛ばした記録も出ない。

1 回の実行では取りこぼしがあっても通ることがあるため、繰り返して件数で見る。
受け入れ条件が求める 60 回は完了判定として手元で回す。
受け入れ条件が求める 60 回は完了判定として手元で回す。ここでの試行は 4 回にする
(#884。8 回から減らした。4 つの記録が同時に走る試行を繰り返す目的は変わらない)。
"""
stages = ("設計", "計画", "実装", "実装レビュー")
env = base_env(state, {"NDF_STAGE_LOCK_TIMEOUT": "20"})
script = str(Path(__file__).resolve().parents[1] / "scripts/stage-check.sh")

short = []
skipped = 0
for trial in range(8):
for trial in range(4):
issue = 3080 + trial
procs = [
subprocess.Popen(
Expand Down
20 changes: 11 additions & 9 deletions plugins/ndf/skills/worktree/tests/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ def test_broken_registry_is_treated_as_empty(main_repo: Path) -> None:
WF_LIB = Path(__file__).resolve().parents[3] / "skills/development-workflow/scripts/lib/workflow-common.sh"

# 実装は `scripts/lib/lock-common.sh` の 1 箇所にあり、2 つの読み込む側が既存の名前へ
# 結んでいる(#293)。**両方の読み込む側へ同じ検査をかける。**
# 結んでいる(#293)。**両方の読み込む側へ同じ検査をかける。** 例外は競合試験で、
# 共通実装に対して 1 通りだけ回す(下の `test_many_at_once_never_share_the_critical_section`)。
LOCK_LIBS = [
pytest.param(LIB, "wt_lock_acquire", "wt_lock_release", id="worktree"),
pytest.param(WF_LIB, "wf_lock_acquire", "wf_lock_release", id="workflow"),
Expand Down Expand Up @@ -248,17 +249,18 @@ def _run_lock_race(
return result


@pytest.mark.parametrize(("parallel", "trials"), [(6, 7), (12, 3)])
@pytest.mark.parametrize(("lib", "acquire", "release"), LOCK_LIBS)
def test_many_at_once_never_share_the_critical_section(
tmp_path: Path, lib: Path, acquire: str, release: str, parallel: int, trials: int
) -> None:
def test_many_at_once_never_share_the_critical_section(tmp_path: Path) -> None:
"""#297-1 / 2 / 3 と #308-5 を 1 つの測定で見る。

並列数を 6 と 12 で変えても結果が変わらないことが、持ち主の決定が時間に依らない
ことの担保になる。
**競合試験は共通実装に対して 1 通りだけ回す**(#884)。2 つの入口はどちらも
`ndf_lock_acquire` を 1 行で呼ぶだけで、入口が共通実装へ届くことは
`scripts/tests/test_lock_common.py::test_the_existing_names_take_and_release_the_lock`
が見ている。入口 × 並列数の 4 通りで同じ臨界区間を試しても、検出できる不具合は増えない。
並列数は多い側(12)を残す。重なりは同時に取りに行く数が多いほど出やすい。
"""
got = _run_lock_race(tmp_path, lib, acquire, release, parallel=parallel, trials=trials)
got = _run_lock_race(
tmp_path, LIB, "wt_lock_acquire", "wt_lock_release", parallel=12, trials=3
)

assert got["overlap"] == 0, f"臨界区間が重なった試行 {got['overlap']} 件"
assert got["miss"] == 0, f"上限に達して取れなかった回数 {got['miss']} 回"
Expand Down
53 changes: 4 additions & 49 deletions scripts/tests/test_lock_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,55 +241,10 @@ def test_a_missing_common_file_stops_the_write_but_not_the_step(tmp_path: Path)


# --- A7: 同時に走らせても持ち主は 1 つ --------------------------------------

LOCK_WORKER = """#!/usr/bin/env bash
# $1 共通ファイル / $2 置き場所
set -uo pipefail
. "$1"
while [ ! -e "$2/go" ]; do :; done
if ndf_lock_acquire "$2/lock" 6; then
: >"$2/in.$$"
if [ "$(ls "$2"/in.* 2>/dev/null | wc -l)" -gt 1 ]; then : >"$2/over.$$"; fi
sleep 0.02
rm -f "$2/in.$$"
ndf_lock_release "$2/lock"
else
: >"$2/miss.$$"
fi
exit 0
"""


def test_six_at_once_leave_one_owner(tmp_path: Path) -> None:
"""A7: 6 つが同時に取りに行っても、臨界区間は重ならず取りこぼしも出ない。

`mkdir` コマンドは同じ名前の作成に複数を通す。1 回の実行では取りこぼしがあっても
通ることがあるため、繰り返したうえで件数で見る。
"""
worker = tmp_path / "worker.sh"
worker.write_text(LOCK_WORKER, encoding="utf-8")
base = tmp_path / "race"
base.mkdir()
overlap = miss = 0

for _ in range(7):
for stray in base.iterdir():
shutil.rmtree(stray, ignore_errors=True) if stray.is_dir() else stray.unlink()
procs = [
subprocess.Popen(
["bash", str(worker), str(LOCK_LIB), str(base)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
for _ in range(6)
]
(base / "go").touch()
for proc in procs:
proc.wait()
overlap += 1 if any(base.glob("over.*")) else 0
miss += len(list(base.glob("miss.*")))

assert overlap == 0, f"臨界区間が重なった試行 {overlap} 件"
assert miss == 0, f"上限に達して取れなかった回数 {miss} 回"
#
# 競合試験は `worktree/tests/test_registry.py::test_many_at_once_never_share_the_critical_section`
# が共通実装に対して 1 通りだけ回す(#884)。同じ臨界区間をここでも試しても、検出できる
# 不具合は増えない。入口が共通実装へ届くことは上の A2 / A3 が見ている。


# --- A8: 呼び出し側のシェルの状態を変えない ---------------------------------
Expand Down
Loading
Loading