okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffix(test): reject empty test selectionsf6dbf0b

main
6.3 KiB178 linesraw
1"""Run all E2E unittest suites, one file, or one test by dotted identifier.
2
3Tests share one concurrent daemon; each ``@exclusive`` test gets a daemon of its
4own, discarded when it ends, and a few of them run at a time beside the shared
5ones. Both queues start with the tests that took longest last time, so the
6slowest never start last.
7"""
8
9import argparse
10from concurrent.futures import ThreadPoolExecutor
11import fcntl
12import io
13import json
14import os
15from pathlib import Path
16import resource
17import sys
18import time
19import unittest
20
21import harness
22import scratch
23
24SUITE_DIR = Path(__file__).parent
25sys.path.insert(0, str(SUITE_DIR))
26sys.path.insert(0, str(SUITE_DIR.parent))
27from python_test_runner import suite_for  # noqa: E402
28
29DURATIONS = scratch.ROOT / "e2e-durations.json"
30
31
32def cases(suite):
33    for item in suite:
34        if isinstance(item, unittest.TestSuite):
35            yield from cases(item)
36        else:
37            yield item
38
39
40def marked(test, mark):
41    method = getattr(test, test._testMethodName)
42    return getattr(method, mark, False) or getattr(type(test), mark, False)
43
44
45def execute(test, daemon):
46    log = daemon.home / "daemon.log"
47    offset = log.stat().st_size if log.exists() else 0
48    stream = io.StringIO()
49    started = time.monotonic()
50    with harness.on_daemon(daemon):
51        result = unittest.TextTestRunner(stream=stream, verbosity=2).run(test)
52    duration = time.monotonic() - started
53    if not result.wasSuccessful() and log.exists():
54        # The daemon's side of a failure; the shared daemon's log interleaves
55        # the tests that ran alongside this one.
56        with log.open("rb") as daemon_log:
57            daemon_log.seek(offset)
58            setattr(
59                result, "daemon_log", daemon_log.read()[-8000:].decode(errors="replace")
60            )
61    return test, result, duration
62
63
64def execute_alone(test):
65    daemon = harness.Daemon("exclusive")
66    try:
67        return execute(test, daemon)
68    finally:
69        daemon.discard()
70
71
72def run_suite(suite, jobs, exclusive_jobs):
73    if suite.countTestCases() == 0:
74        raise ValueError("no E2E tests collected")
75    try:
76        durations = json.loads(DURATIONS.read_text())
77    except (OSError, ValueError):
78        durations = {}
79    selected = sorted(cases(suite), key=lambda test: -durations.get(test.id(), 0))
80    exclusive = [test for test in selected if marked(test, "_e2e_exclusive")]
81    shared = [test for test in selected if test not in exclusive]
82    daemon = harness.Daemon("shared", concurrent=True) if shared else None
83    if daemon:
84        daemon.boot()
85        daemon.install_shared_features()
86    with (
87        ThreadPoolExecutor(max_workers=exclusive_jobs) as exclusive_pool,
88        ThreadPoolExecutor(max_workers=jobs) as shared_pool,
89    ):
90        alone = [exclusive_pool.submit(execute_alone, test) for test in exclusive]
91        together = [shared_pool.submit(execute, test, daemon) for test in shared]
92        outcomes = [future.result() for future in together + alone]
93    report(outcomes)
94    durations.update({test.id(): duration for test, _, duration in outcomes})
95    DURATIONS.write_text(json.dumps(durations, indent=1, sort_keys=True))
96    return all(result.wasSuccessful() for _, result, _ in outcomes)
97
98
99def report(outcomes):
100    for test, result, duration in outcomes:
101        status = "ok" if result.wasSuccessful() else "FAILED"
102        print(f"{status:6} {test.id()} ({duration:.1f}s)", file=sys.stderr)
103    for test, result, _duration in outcomes:
104        for _case, traceback in result.failures + result.errors:
105            print(f"\n{test.id()}:\n{traceback}", file=sys.stderr)
106        if getattr(result, "daemon_log", ""):
107            print(
108                f"daemon log during {test.id()}:\n{result.daemon_log}", file=sys.stderr
109            )
110    print("slowest tests:", file=sys.stderr)
111    for test, _result, duration in sorted(outcomes, key=lambda value: -value[2])[:10]:
112        print(f"  {duration:.1f}s {test.id()}", file=sys.stderr)
113    failing = sum(not result.wasSuccessful() for _, result, _ in outcomes)
114    print(f"Ran {len(outcomes)} tests; {failing} failing", file=sys.stderr)
115
116
117def main():
118    # Concurrent tests hold hundreds of connections between them, past the 256
119    # open files a macOS shell starts with. Like Go, lift the soft limit, to at
120    # most macOS's OPEN_MAX. Daemons boot on it unless a test pins their own.
121    _, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
122    resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 10240), hard))
123    cpus = os.cpu_count() or 1
124    parser = argparse.ArgumentParser()
125    parser.add_argument(
126        "target", nargs="?", help="file.py, file:TestCase.test_method, or area"
127    )
128    parser.add_argument(
129        "-j",
130        "--jobs",
131        type=int,
132        default=max(1, min(4, cpus // 4)),
133        help="concurrent tests on the shared lane (default: %(default)s)",
134    )
135    parser.add_argument(
136        "-x",
137        "--exclusive-jobs",
138        type=int,
139        default=max(1, min(8, cpus // 2)),
140        help="concurrent exclusive daemons (default: %(default)s)",
141    )
142    args = parser.parse_args()
143    if args.target:
144        path = SUITE_DIR / args.target
145        if path.is_file():
146            suite = suite_for(path)
147        else:
148            file_part, _, test_name = args.target.partition(":")
149            path = SUITE_DIR / (
150                file_part if file_part.endswith(".py") else file_part + "_test.py"
151            )
152            suite = suite_for(path, test_name or None)
153    else:
154        suite = unittest.TestSuite(
155            suite_for(path) for path in sorted(SUITE_DIR.glob("*_test.py"))
156        )
157    if suite.countTestCases() == 0:
158        parser.error("no E2E tests collected")
159    # Concurrent E2E runs compete for the daemons and kernels on this laptop.
160    with open(scratch.ROOT / "e2e.lock", "w") as lock:
161        fcntl.flock(lock, fcntl.LOCK_EX)
162        started = time.monotonic()
163        try:
164            harness.snapshot_daemon()
165            successful = run_suite(
166                suite, max(1, args.jobs), max(1, args.exclusive_jobs)
167            )
168        finally:
169            harness.shutdown()
170            print(
171                f"daemon boots: {harness.daemon_boots}; restart time: {harness.restart_seconds:.1f}s; wall time: {time.monotonic() - started:.1f}s",
172                flush=True,
173            )
174    return 0 if successful else 1
175
176
177if __name__ == "__main__":
178    sys.exit(main())