|
1 | 1 | """ |
2 | | -Run the workspace smoke test suite. |
3 | | -
|
4 | | -Reads `smoke_tests.txt` from the workspace root and `config/build/profile_smoke.yaml` |
5 | | -for per-script env var overrides, then runs each listed script with the |
6 | | -appropriate environment. Continues through failures and exits non-zero |
7 | | -if any script failed. |
8 | | -
|
9 | | -The env resolution itself is NOT implemented here: it is PyAutoHands's |
10 | | -`autohands/env_config.py`, imported below. This file used to carry a copy, and |
11 | | -the copy had already drifted (its `load_env_config` hardcoded |
12 | | -`config/build/profile_smoke.yaml`, so the PR gate was structurally unable to read |
13 | | -the release profile — the seed incident's failure mode 4/7). One resolver |
14 | | -means the PR gate and the release runner cannot disagree about what a script's |
15 | | -environment is. See PyAutoHands docs/env_profile_redesign.md §5 (#161 step 2). |
16 | | -
|
17 | | -Mirrors the logic of the `/smoke-test` skill so CI and local runs stay |
18 | | -in sync. |
| 2 | +Run the workspace smoke test suite: every script under `scripts/`, minus the |
| 3 | +exclusions in `config/build/no_run.yaml`. |
| 4 | +
|
| 5 | +Coverage is **opt-out**. A new tutorial is smoke-tested the moment it is added; |
| 6 | +excluding one is a deliberate, documented entry in `config/build/no_run.yaml` — |
| 7 | +the same file the notebook runner already honours, so scripts and notebooks can |
| 8 | +no longer disagree about what is skipped. |
| 9 | +
|
| 10 | +This replaces the former `smoke_tests.txt` allowlist, under which a script was |
| 11 | +tested only if someone remembered to add it. That design left HowToGalaxy |
| 12 | +testing 4 of its 26 scripts, and a public teaching notebook stayed broken in |
| 13 | +three places because no job had ever executed it (HowToGalaxy #58). |
| 14 | +
|
| 15 | +Nothing about discovery, exclusion or environment resolution is implemented |
| 16 | +here. This is a thin shim over PyAutoHands' `autohands/run_python.py` — the same |
| 17 | +entry point PyAutoHeart's workspace-validation uses for its `run_scripts` job — |
| 18 | +so the PR gate and the validation runner cannot drift apart. That runner |
| 19 | +provides: |
| 20 | +
|
| 21 | + * recursive discovery, ordering `simulator*` first and then `start_here.py`, |
| 22 | + which is what tutorials depending on simulated datasets need |
| 23 | + * `should_skip()` against `config/build/no_run.yaml` |
| 24 | + * per-script env from `config/build/profile_smoke.yaml` |
| 25 | +
|
| 26 | +Mirrors the `/smoke-test` skill so CI and local runs stay in sync. |
19 | 27 | """ |
20 | 28 |
|
21 | 29 | from __future__ import annotations |
22 | 30 |
|
| 31 | +import os |
23 | 32 | import subprocess |
24 | 33 | import sys |
25 | | -import time |
26 | 34 | from pathlib import Path |
27 | 35 |
|
28 | | - |
29 | 36 | WORKSPACE = Path(__file__).resolve().parents[2] |
30 | | -SMOKE_FILE = WORKSPACE / "smoke_tests.txt" |
31 | | -ENV_VARS_FILE = WORKSPACE / "config" / "build" / "profile_smoke.yaml" |
32 | | -SCRIPTS_DIR = WORKSPACE / "scripts" |
| 37 | +PROJECT = "howtofit" |
33 | 38 |
|
34 | 39 | # CI puts PyAutoHands/autohands on PYTHONPATH (PyAutoHeart's reusable |
35 | 40 | # smoke-tests.yml clones it alongside the dependency chain); for local runs, |
36 | 41 | # fall back to the sibling checkout. |
37 | 42 | try: |
38 | | - from env_config import build_env_for_script, load_env_config |
| 43 | + import build_util |
39 | 44 | except ImportError: # pragma: no cover - local-run fallback |
40 | 45 | sys.path.insert(0, str(WORKSPACE.parent / "PyAutoHands" / "autohands")) |
41 | | - from env_config import build_env_for_script, load_env_config |
42 | | - |
43 | | - |
44 | | -def load_smoke_scripts() -> list[str]: |
45 | | - scripts: list[str] = [] |
46 | | - for line in SMOKE_FILE.read_text().splitlines(): |
47 | | - line = line.strip() |
48 | | - if not line or line.startswith("#"): |
49 | | - continue |
50 | | - scripts.append(line) |
51 | | - return scripts |
52 | | - |
| 46 | + import build_util |
53 | 47 |
|
54 | | -def load_cfg() -> dict | None: |
55 | | - """Parsed env profile, or None when the workspace has none. |
56 | | -
|
57 | | - None flows through build_env_for_script -> None -> subprocess inherits the |
58 | | - parent environment, which is what the old local copy's empty-config path |
59 | | - did by hand. |
60 | | - """ |
61 | | - if not ENV_VARS_FILE.exists(): |
62 | | - return None |
63 | | - return load_env_config(ENV_VARS_FILE) |
64 | | - |
65 | | - |
66 | | -def run_one(script_rel: str, cfg: dict | None) -> tuple[str, int, float, str]: |
67 | | - env = build_env_for_script(Path(script_rel), cfg) |
68 | | - script_path = SCRIPTS_DIR / script_rel |
69 | | - t0 = time.time() |
70 | | - result = subprocess.run( |
71 | | - [sys.executable, str(script_path)], |
72 | | - cwd=str(WORKSPACE), |
73 | | - env=env, |
74 | | - capture_output=True, |
75 | | - text=True, |
76 | | - ) |
77 | | - elapsed = time.time() - t0 |
78 | | - output = result.stdout + result.stderr |
79 | | - return script_rel, result.returncode, elapsed, output |
| 48 | +AUTOHANDS = Path(build_util.__file__).resolve().parent |
80 | 49 |
|
81 | 50 |
|
82 | 51 | def main() -> int: |
83 | | - if not SMOKE_FILE.exists(): |
84 | | - print(f"ERROR: no smoke_tests.txt at {SMOKE_FILE}", file=sys.stderr) |
85 | | - return 1 |
86 | | - scripts = load_smoke_scripts() |
87 | | - if not scripts: |
88 | | - print("No smoke test scripts listed.") |
89 | | - return 0 |
90 | | - cfg = load_cfg() |
91 | | - |
92 | | - print(f"Running {len(scripts)} smoke test script(s) from {SMOKE_FILE.name}\n") |
93 | | - failures: list[tuple[str, int, str]] = [] |
94 | | - for script_rel in scripts: |
95 | | - print(f"::group::{script_rel}") |
96 | | - name, rc, elapsed, output = run_one(script_rel, cfg) |
97 | | - print(output, end="") |
98 | | - status = "PASS" if rc == 0 else f"FAIL (exit {rc})" |
99 | | - print(f"\n[{status}] {name} — {elapsed:.1f}s") |
100 | | - print("::endgroup::") |
101 | | - if rc != 0: |
102 | | - failures.append((name, rc, output)) |
| 52 | + env = os.environ.copy() |
| 53 | + env["PYTHONPATH"] = os.pathsep.join( |
| 54 | + p for p in (str(AUTOHANDS), env.get("PYTHONPATH", "")) if p |
| 55 | + ) |
103 | 56 |
|
104 | | - total = len(scripts) |
105 | | - passed = total - len(failures) |
106 | | - print(f"\n=== Smoke test summary: {passed}/{total} passed ===") |
107 | | - for name, rc, _ in failures: |
108 | | - print(f" FAIL {name} (exit {rc})") |
109 | | - return 0 if not failures else 1 |
| 57 | + # --report-dir is REQUIRED, not cosmetic. run_python.py only propagates |
| 58 | + # failures (`sys.exit(1)`) when a report was built; without it the suite |
| 59 | + # runs to completion and always exits 0 — a vacuously green gate. It also |
| 60 | + # switches execute_script from "abort on the first failure" to "record and |
| 61 | + # continue", which is the behaviour the old runner had. |
| 62 | + cmd = [ |
| 63 | + sys.executable, |
| 64 | + str(AUTOHANDS / "run_python.py"), |
| 65 | + PROJECT, |
| 66 | + "scripts", |
| 67 | + "--report-dir", |
| 68 | + str(WORKSPACE / "test-results"), |
| 69 | + ] |
| 70 | + # run_python.py resolves config/build/ relative to the cwd. |
| 71 | + return subprocess.run(cmd, cwd=str(WORKSPACE), env=env).returncode |
110 | 72 |
|
111 | 73 |
|
112 | 74 | if __name__ == "__main__": |
|
0 commit comments