Create a script to run the tests in a loop and capture logs + output on failure (#40665)

* Create a script to run the tests in a loop and capture logs + output on failure * Apply PR feedback

Blue committed May 29, 2026 at 12:10 UTC 84c1baa20bd50302cfdc3d413c62491b81c27f44
2 files changed +430
.gitignore
+1
@@ -72,3 +72,4 @@ test-storage/
72 *.tar
73 *.etl
74 *.lscache
75 +__pycache__
\ No newline at end of file
tools/test/loop-tests.py new
+429
@@ -0,0 +1,429 @@
1 +"""Run the WSL TAEF tests in a loop, capturing per-iteration output and an ETL trace.
2 +
3 +Usage:
4 + python loop-tests.py <iterations> [-- <test.bat args>...]
5 +"""
6 +
7 +import atexit
8 +import re
9 +import shutil
10 +import subprocess
11 +import sys
12 +import threading
13 +import time
14 +from contextlib import contextmanager
15 +from datetime import datetime, timedelta
16 +from pathlib import Path
17 +
18 +import click
19 +
20 +REPO_ROOT = Path(__file__).resolve().parents[2]
21 +DEFAULT_WPRP = REPO_ROOT / "diagnostics" / "wsl.wprp"
22 +
23 +START_GROUP_RE = re.compile(r"StartGroup:\s+(\S+)")
24 +END_GROUP_RE = re.compile(r"EndGroup:\s+(\S+)\s+\[(?P<verdict>\w+)\]")
25 +PASS_VERDICTS = {"passed", "skipped"}
26 +
27 +# Terminal control sequences (CSI = Control Sequence Introducer, "ESC [").
28 +_CSI = "\x1b["
29 +_ERASE_LINE = f"{_CSI}2K" # EL - erase the entire current line
30 +_CURSOR_COL_1 = f"{_CSI}G" # CHA - move cursor to column 1 (1-based)
31 +_HIDE_CURSOR = f"{_CSI}?25l" # DECTCEM - hide the cursor
32 +_SHOW_CURSOR = f"{_CSI}?25h" # DECTCEM - show the cursor
33 +_SGR_RESET = f"{_CSI}0m" # SGR - reset all attributes (colors, bold, ...)
34 +# Reset the current line: move cursor home then erase, so the next write
35 +# starts from a clean slate regardless of what was there before.
36 +_RESET_LINE = _CURSOR_COL_1 + _ERASE_LINE
37 +
38 +
39 +class StatusLine:
40 + """Single-line status display reused across iterations and refreshed once per second."""
41 +
42 + def __init__(self, total: int):
43 + self._total = total
44 + self._iteration = 0
45 + self._iter_start = time.monotonic()
46 + self._test = "(initializing)"
47 + self._passed = 0
48 + self._failed = 0
49 + self._lock = threading.RLock()
50 + self._stop_event = threading.Event()
51 + # Only emit terminal control sequences when stdout is an interactive
52 + # terminal; otherwise we'd garble redirected output (logs, CI, etc.).
53 + self._enabled = sys.stdout.isatty()
54 + self._cursor_hidden = False
55 + self._thread = threading.Thread(target=self._loop, daemon=True)
56 + atexit.register(self._safe_restore)
57 +
58 + def start(self) -> None:
59 + self._thread.start()
60 +
61 + def stop(self) -> None:
62 + self._stop_event.set()
63 + self._thread.join()
64 + with self._lock:
65 + self._clear_locked()
66 + self._restore_cursor_locked()
67 + self._safe_restore()
68 +
69 + def begin_iteration(self, iteration: int) -> None:
70 + with self._lock:
71 + self._iteration = iteration
72 + self._iter_start = time.monotonic()
73 + self._test = "(initializing)"
74 + self._render_locked()
75 +
76 + def update_test(self, name: str) -> None:
77 + with self._lock:
78 + self._test = name
79 +
80 + def record_result(self, passed: bool) -> None:
81 + with self._lock:
82 + if passed:
83 + self._passed += 1
84 + else:
85 + self._failed += 1
86 +
87 + def iter_elapsed(self) -> timedelta:
88 + with self._lock:
89 + return timedelta(seconds=int(time.monotonic() - self._iter_start))
90 +
91 + @contextmanager
92 + def pause(self):
93 + """Clear the status line and hold the refresh lock while the caller prints."""
94 + with self._lock:
95 + self._clear_locked()
96 + self._restore_cursor_locked()
97 + yield
98 + # Do not re-render here; the next 1-second tick will redraw below
99 + # whatever the caller wrote to stdout.
100 +
101 + def _loop(self) -> None:
102 + while not self._stop_event.is_set():
103 + with self._lock:
104 + self._render_locked()
105 + self._stop_event.wait(1.0)
106 +
107 + def _render_locked(self) -> None:
108 + if not self._enabled:
109 + return
110 + elapsed = timedelta(seconds=int(time.monotonic() - self._iter_start))
111 + stats_parts: list[str] = []
112 + if self._passed:
113 + stats_parts.append(click.style(f"{self._passed} passed", fg="green"))
114 + if self._failed:
115 + stats_parts.append(click.style(f"{self._failed} failed", fg="red"))
116 + stats = (" [" + ", ".join(stats_parts) + "]") if stats_parts else ""
117 + line = (
118 + f"Iteration {self._iteration}/{self._total}: "
119 + f"{self._test}, runtime: {elapsed}{stats}"
120 + )
121 + prefix = _RESET_LINE
122 + if not self._cursor_hidden:
123 + prefix = _HIDE_CURSOR + prefix
124 + self._cursor_hidden = True
125 + sys.stdout.write(prefix + line)
126 + sys.stdout.flush()
127 +
128 + def _clear_locked(self) -> None:
129 + if not self._enabled:
130 + return
131 + sys.stdout.write(_RESET_LINE)
132 + sys.stdout.flush()
133 +
134 + def _restore_cursor_locked(self) -> None:
135 + if self._cursor_hidden:
136 + sys.stdout.write(_SHOW_CURSOR)
137 + sys.stdout.flush()
138 + self._cursor_hidden = False
139 +
140 + def _safe_restore(self) -> None:
141 + if not self._enabled:
142 + return
143 + sys.stdout.write(_SHOW_CURSOR + _SGR_RESET)
144 + sys.stdout.flush()
145 +
146 +
147 +def _run_wpr(args: list[str]) -> None:
148 + """Invoke wpr; raises subprocess.CalledProcessError on non-zero exit."""
149 +
150 + si = subprocess.STARTUPINFO()
151 + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
152 + si.wShowWindow = subprocess.SW_HIDE # 0
153 +
154 + subprocess.run(
155 + ["wpr", *args],
156 + creationflags=subprocess.CREATE_NEW_CONSOLE,
157 + startupinfo=si,
158 + check=True,
159 + )
160 +
161 +
162 +def _timestamp_prefix() -> str:
163 + """Return `[HH:MM:SS.mmm] ` for the current wall-clock time."""
164 + now = datetime.now()
165 + return f"[{now.strftime('%H:%M:%S')}.{now.microsecond // 1000:03d}] "
166 +
167 +
168 +def _print_failed_tests(failed_tests: list[tuple[str, str, list[str]]]) -> None:
169 + """Print the captured output of every failed test in red."""
170 + for name, verdict, lines in failed_tests:
171 + click.secho(
172 + f"\n----- Failed test: {name} [{verdict}] -----",
173 + fg="red",
174 + bold=True,
175 + )
176 + click.secho("".join(lines).rstrip("\n"), fg="red")
177 + click.secho(f"----- end of {name} -----\n", fg="red", bold=True)
178 +
179 +
180 +def _print_output_tail(output_file: Path, max_lines: int = 80) -> None:
181 + """Fallback: dump the tail of the captured log when no test-level failure was parsed."""
182 + try:
183 + lines = output_file.read_text(encoding="utf-8", errors="replace").splitlines()
184 + except OSError as ex:
185 + click.secho(f"Could not read {output_file}: {ex}", fg="red")
186 + return
187 +
188 + tail = lines[-max_lines:]
189 + click.secho(
190 + f"\n----- Last {len(tail)} line(s) of {output_file.name} -----",
191 + fg="red",
192 + bold=True,
193 + )
194 + click.secho("\n".join(tail), fg="red")
195 + click.secho(f"----- end of {output_file.name} -----\n", fg="red", bold=True)
196 +
197 +
198 +def _run_iteration(
199 + iteration: int,
200 + test_bat: Path,
201 + test_args: tuple[str, ...],
202 + iter_dir: Path,
203 + wprp: Path | None,
204 + status: StatusLine,
205 +) -> bool:
206 + """Run one iteration. Returns True on success."""
207 + iter_dir.mkdir(parents=True, exist_ok=True)
208 + output_file = iter_dir / "test-output.log"
209 + etl_file = iter_dir / "trace.etl"
210 +
211 + status.begin_iteration(iteration)
212 +
213 + # Start ETL trace before launching tests.
214 + etl_started = False
215 + if wprp is not None:
216 + _run_wpr(["-start", str(wprp), "-filemode"])
217 + etl_started = True
218 +
219 + cmd = [str(test_bat), *test_args]
220 + return_code = 1
221 + current_test: str | None = None
222 + current_buffer: list[str] = []
223 + failed_tests: list[tuple[str, str, list[str]]] = []
224 + try:
225 + with output_file.open("w", encoding="utf-8", errors="replace") as out:
226 + out.write(f"{_timestamp_prefix()}$ {subprocess.list2cmdline(cmd)}\n\n")
227 + out.flush()
228 + proc = subprocess.Popen(
229 + cmd,
230 + stdout=subprocess.PIPE,
231 + stderr=subprocess.STDOUT,
232 + text=True,
233 + encoding="utf-8",
234 + errors="replace",
235 + bufsize=1,
236 + cwd=str(REPO_ROOT),
237 + )
238 + assert proc.stdout is not None
239 + for line in proc.stdout:
240 + # Strip embedded NULs (UTF-16 remnants, etc.) so the log stays valid text.
241 + line = line.replace("\x00", "")
242 + stamped = _timestamp_prefix() + line
243 + out.write(stamped)
244 + out.flush()
245 +
246 + start = START_GROUP_RE.search(stamped)
247 + if start:
248 + current_test = start.group(1)
249 + current_buffer = [stamped]
250 + status.update_test(current_test)
251 + continue
252 +
253 + if current_test is not None:
254 + current_buffer.append(stamped)
255 +
256 + end = END_GROUP_RE.search(stamped)
257 + if end:
258 + name = end.group(1)
259 + verdict = end.group("verdict")
260 + if verdict.lower() not in PASS_VERDICTS:
261 + failed_tests.append((name, verdict, current_buffer))
262 + current_test = None
263 + current_buffer = []
264 + return_code = proc.wait()
265 + finally:
266 + if etl_started:
267 + _run_wpr(["-stop", str(etl_file)])
268 +
269 + success = return_code == 0 and not failed_tests
270 + status.record_result(success)
271 +
272 + if not success:
273 + elapsed = status.iter_elapsed()
274 + detail = f"exit {return_code}"
275 + if failed_tests:
276 + detail += f", {len(failed_tests)} failed test(s)"
277 + verdict_str = click.style(f"FAILED ({detail})", fg="red", bold=True)
278 + with status.pause():
279 + click.echo(
280 + f"Iteration {iteration}: {verdict_str} in {elapsed} - logs at {iter_dir}"
281 + )
282 + if failed_tests:
283 + _print_failed_tests(failed_tests)
284 + else:
285 + _print_output_tail(output_file)
286 +
287 + return success
288 +
289 +
290 +def _cancel_active_trace() -> None:
291 + """Best-effort cancel of any leftover wpr session (e.g. from a crashed iteration)."""
292 + subprocess.run(
293 + ["wpr", "-cancel"],
294 + stdout=subprocess.DEVNULL,
295 + stderr=subprocess.DEVNULL,
296 + )
297 +
298 +
299 +@click.command(
300 + context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
301 + help=(
302 + "Run test.bat in a loop, capturing per-iteration output and an ETL trace. "
303 + "Any TEST_ARGS are forwarded verbatim to test.bat."
304 + ),
305 +)
306 +@click.argument("iterations", type=click.IntRange(min=1))
307 +@click.argument("test_args", nargs=-1, type=click.UNPROCESSED)
308 +@click.option(
309 + "--output-dir",
310 + "-o",
311 + default="test-loop",
312 + type=click.Path(file_okay=False),
313 + show_default=True,
314 + help="Parent directory; a timestamped subfolder is created for each run.",
315 +)
316 +@click.option(
317 + "--wprp",
318 + default=str(DEFAULT_WPRP),
319 + type=click.Path(dir_okay=False),
320 + show_default=True,
321 + help="ETL profile passed to `wpr -start` (passed verbatim; wpr validates it).",
322 +)
323 +@click.option(
324 + "--no-etl",
325 + is_flag=True,
326 + default=False,
327 + help="Skip ETL tracing (useful when wpr is not available or not needed).",
328 +)
329 +@click.option(
330 + "--platform",
331 + "platform_",
332 + type=click.Choice(["x64", "arm64"], case_sensitive=False),
333 + default="x64",
334 + show_default=True,
335 + help="Target platform subfolder under bin/ where test.bat lives.",
336 +)
337 +@click.option(
338 + "--target",
339 + type=click.Choice(["Debug", "Release"], case_sensitive=False),
340 + default="Debug",
341 + show_default=True,
342 + help="Build configuration subfolder under bin/<platform>/ where test.bat lives.",
343 +)
344 +@click.option(
345 + "--stop-on-failure/--continue-on-failure",
346 + default=True,
347 + show_default=True,
348 + help="Stop the loop on the first failing iteration.",
349 +)
350 +@click.option(
351 + "--keep-success-logs",
352 + is_flag=True,
353 + default=False,
354 + help="Keep per-iteration log folders even for successful iterations (by default they are deleted).",
355 +)
356 +def main(
357 + iterations: int,
358 + test_args: tuple[str, ...],
359 + output_dir: str,
360 + wprp: str,
361 + no_etl: bool,
362 + platform_: str,
363 + target: str,
364 + stop_on_failure: bool,
365 + keep_success_logs: bool,
366 +) -> None:
367 + platform_ = platform_.lower()
368 + target = target.capitalize()
369 + test_bat_path = REPO_ROOT / "bin" / platform_ / target / "test.bat"
370 +
371 + if not test_bat_path.is_file():
372 + raise click.ClickException(f"test.bat not found at: {test_bat_path}")
373 +
374 + wprp_path: Path | None = None
375 + if not no_etl:
376 + wprp_path = Path(wprp).resolve()
377 +
378 + base_dir = Path(output_dir).resolve()
379 + run_dir = base_dir / datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
380 + run_dir.mkdir(parents=True, exist_ok=True)
381 +
382 + digits = len(str(iterations))
383 + failed = 0
384 + print(f"Running {iterations} iteration(s) of: {subprocess.list2cmdline([str(test_bat_path), *test_args])}")
385 + print(f"Output: {run_dir}")
386 + if wprp_path is not None:
387 + print(f"ETL profile: {wprp_path}")
388 + else:
389 + print("ETL tracing: disabled")
390 + print()
391 +
392 + status = StatusLine(iterations)
393 + status.start()
394 + try:
395 + for i in range(1, iterations + 1):
396 + iter_dir = run_dir / f"iter-{i:0{digits}d}"
397 + try:
398 + ok = _run_iteration(i, test_bat_path, test_args, iter_dir, wprp_path, status)
399 + except KeyboardInterrupt:
400 + with status.pause():
401 + print("\nInterrupted by user.")
402 + raise
403 +
404 + if ok and not keep_success_logs:
405 + shutil.rmtree(iter_dir, ignore_errors=True)
406 +
407 + if not ok:
408 + failed += 1
409 + if stop_on_failure:
410 + with status.pause():
411 + print(f"Stopping after first failure (iteration {i}).")
412 + break
413 + finally:
414 + status.stop()
415 + # Make sure no ETL trace is left running from a crashed iteration.
416 + if wprp_path is not None:
417 + _cancel_active_trace()
418 +
419 + print()
420 + if failed:
421 + click.secho(
422 + f"Done: {failed} iteration(s) failed.", fg="red", bold=True
423 + )
424 + sys.exit(1)
425 + click.secho("Done: all iterations passed.", fg="green", bold=True)
426 +
427 +
428 +if __name__ == "__main__":
429 + main()