from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
RUNNING = re.compile(r"^\s*Running (?:unittests )?(\S+)")
DOCTESTS = re.compile(r"^\s*Doc-tests (\S+)")
SECTION = re.compile(r"^running (\d+) tests?$")
RESULT = re.compile(
r"^test result: (ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored"
)
CASE_FAILED = re.compile(r"^test (\S+) \.\.\. FAILED")
class Outcome:
def __init__(self, kind: str, detail: str, annotations: list[str] | None = None):
self.kind = kind
self.detail = detail
self.annotations = annotations or []
@property
def passed(self) -> bool:
return self.kind == "PASSED"
def targets_from(stderr: str) -> list[str]:
names = []
for line in ANSI.sub("", stderr).splitlines():
m = RUNNING.match(line)
if m:
names.append(Path(m.group(1)).stem)
continue
m = DOCTESTS.match(line)
if m:
names.append(f"doctests({m.group(1)})")
return names
def sections_from(stdout: str) -> list[dict]:
sections: list[dict] = []
for line in ANSI.sub("", stdout).splitlines():
m = SECTION.match(line)
if m:
sections.append(
{"announced": int(m.group(1)), "result": None, "failed_cases": []}
)
continue
if not sections:
continue
current = sections[-1]
m = CASE_FAILED.match(line)
if m:
current["failed_cases"].append(m.group(1))
continue
m = RESULT.match(line)
if m:
current["result"] = {
"ok": m.group(1) == "ok",
"passed": int(m.group(2)),
"failed": int(m.group(3)),
}
return sections
def classify(proc: subprocess.CompletedProcess) -> Outcome:
targets = targets_from(proc.stderr)
sections = sections_from(proc.stdout)
def name(i: int) -> str:
return targets[i] if i < len(targets) else f"target#{i + 1}"
if not targets and not sections:
if proc.returncode == 0:
return Outcome(
"INCOMPLETE",
"cargo exited 0 having produced no test targets and no test "
"output. Nothing ran, and nothing said why.",
)
return Outcome(
"BUILD",
f"cargo produced no test targets and no test output "
f"(exit {proc.returncode}). The suite did not run; this is a "
f"build failure.",
)
if targets and len(sections) < len(targets):
missing = targets[len(sections):] or ["(position not recoverable)"]
return Outcome(
"INCOMPLETE",
f"cargo announced {len(targets)} targets but only {len(sections)} "
f"started running. First unaccounted for: {missing[0]}",
[f"target {missing[0]} was announced but produced no test output"],
)
failing = [
(name(i), s) for i, s in enumerate(sections)
if s["result"] and not s["result"]["ok"]
]
if failing:
annotations = []
total = 0
for target, section in failing:
total += section["result"]["failed"]
for case in section["failed_cases"] or ["(name not printed)"]:
annotations.append(f"{target}: {case} FAILED")
return Outcome(
"FAILED",
f"{total} test(s) failed across {len(failing)} target(s). "
f"Not retried -- this is a result, not noise.",
annotations,
)
crashed = [
(name(i), s) for i, s in enumerate(sections) if s["result"] is None
]
if crashed:
names = ", ".join(t for t, _ in crashed)
lost = sum(s["announced"] - len(s["failed_cases"]) for _, s in crashed)
return Outcome(
"CRASH",
f"{len(crashed)} target(s) started and printed no summary: {names}. "
f"Up to {lost} test(s) silently absent. This is R15's shape.",
)
if proc.returncode != 0:
return Outcome(
"TEARDOWN",
f"all {len(sections)} targets reported ok and cargo exited "
f"{proc.returncode}. Look at teardown, not at the assertions.",
)
total = sum(s["result"]["passed"] for s in sections)
return Outcome("PASSED", f"{total} passed across {len(sections)} targets")
GREEN_STDOUT = (
"\nrunning 2 tests\ntest a ... ok\ntest b ... ok\n\n"
"test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; "
"0 filtered out; finished in 0.01s\n\n"
"\nrunning 1 test\ntest c ... ok\n\n"
"test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; "
"0 filtered out; finished in 0.01s\n"
)
PLAIN_STDERR = (
" Compiling macrame-db v0.7.0 (/x)\n"
" Finished `test` profile [unoptimized + debuginfo] target(s) in 1s\n"
" Running unittests src/lib.rs (target/debug/deps/macrame-abc)\n"
" Doc-tests macrame\n"
)
COLOUR_STDERR = (
"\x1b[1m\x1b[92m Compiling\x1b[0m macrame-db v0.7.0 (/x)\n"
"\x1b[1m\x1b[92m Finished\x1b[0m `test` profile target(s) in 1s\n"
"\x1b[1m\x1b[92m Running\x1b[0m unittests src/lib.rs "
"(target/debug/deps/macrame-abc)\n"
"\x1b[1m\x1b[92m Doc-tests\x1b[0m macrame\n"
)
def _self_test() -> int:
def proc(out: str, err: str, code: int) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(["cargo"], code, out, err)
crashed = GREEN_STDOUT[: GREEN_STDOUT.rindex("test result:")]
red = GREEN_STDOUT.replace(
"test c ... ok\n\ntest result: ok. 1 passed; 0 failed",
"test c ... FAILED\n\ntest result: FAILED. 0 passed; 1 failed",
)
cases = [
("green, plain stderr", proc(GREEN_STDOUT, PLAIN_STDERR, 0), "PASSED"),
("green, COLOURED stderr", proc(GREEN_STDOUT, COLOUR_STDERR, 0), "PASSED"),
("crash, coloured", proc(crashed, COLOUR_STDERR, 101), "CRASH"),
("failure, coloured", proc(red, COLOUR_STDERR, 101), "FAILED"),
("green summaries, bad exit", proc(GREEN_STDOUT, PLAIN_STDERR, 101), "TEARDOWN"),
(
"one section missing",
proc(GREEN_STDOUT[: GREEN_STDOUT.index("\nrunning 1 test")], PLAIN_STDERR, 101),
"INCOMPLETE",
),
("build failure", proc("", "error: could not compile\n", 101), "BUILD"),
("silent success", proc("", "", 0), "INCOMPLETE"),
("stderr unreadable", proc(GREEN_STDOUT, "<<garbage>>\n", 0), "PASSED"),
]
bad = 0
for label, p, expected in cases:
got = classify(p)
ok = got.kind == expected
bad += not ok
print(f" {'ok ' if ok else 'FAIL'} {label:<26} expected {expected:<10} got {got.kind}")
if not ok:
print(f" {got.detail}")
plain = classify(proc(GREEN_STDOUT, PLAIN_STDERR, 0))
colour = classify(proc(GREEN_STDOUT, COLOUR_STDERR, 0))
if plain.detail != colour.detail:
bad += 1
print(f" FAIL colour changes the answer:\n {plain.detail}\n {colour.detail}")
else:
print(" ok colour makes no difference to the detail line")
print("self-test FAILED" if bad else "self-test passed")
return 1 if bad else 0
def run_once(cargo_args: list[str]) -> Outcome:
proc = subprocess.run(
["cargo", "test", *cargo_args, "--no-fail-fast"],
cwd=REPO,
capture_output=True,
text=True,
errors="replace",
)
sys.stdout.write(proc.stdout)
sys.stderr.write(proc.stderr)
return classify(proc)
def run_docs(features: str) -> int:
cmd = ["cargo", "doc", "--no-deps"]
if features:
cmd += ["--features", features]
print(f"::group::{' '.join(cmd)} (RUSTDOCFLAGS=-D warnings)")
proc = subprocess.run(cmd, cwd=REPO, env={**os.environ, "RUSTDOCFLAGS": "-D warnings"})
print("::endgroup::")
if proc.returncode == 0:
print("PASSED: rustdoc is clean with -D warnings")
return 0
print("::error::DOCS: rustdoc failed under -D warnings. A broken intra-doc "
"link is the usual cause, and a link to a private item is the usual "
"broken link -- rustdoc documents public items only.")
return 1
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--features",
default="",
help="passed through to cargo test; empty means default features",
)
parser.add_argument(
"--attempts",
type=int,
default=3,
help="how many times to retry a CRASH (nothing else is retried)",
)
parser.add_argument(
"--docs",
action="store_true",
help="run ci.yml's rustdoc gate instead of the test suite; not retried",
)
parser.add_argument(
"--self-test",
action="store_true",
help="classify fixed fixtures and exit; runs no cargo, compiles nothing",
)
args, passthrough = parser.parse_known_args()
if args.self_test:
return _self_test()
if args.docs:
return run_docs(args.features)
cargo_args = ["--features", args.features] if args.features else []
cargo_args += passthrough
for attempt in range(1, args.attempts + 1):
print(f"::group::cargo test{' ' + args.features if args.features else ''}, "
f"attempt {attempt}/{args.attempts}")
outcome = run_once(cargo_args)
print("::endgroup::")
if outcome.passed:
print(f"{outcome.kind}: {outcome.detail} (attempt {attempt}/{args.attempts})")
return 0
for annotation in outcome.annotations:
print(f"::error::{annotation}")
if outcome.kind != "CRASH":
print(f"::error::{outcome.kind}: {outcome.detail}")
return 1
print(f"::warning::CRASH on attempt {attempt}/{args.attempts}: {outcome.detail}")
print(
f"::error::CRASH: {args.attempts} consecutive attempts died without a "
f"summary, every one of them R15's shape. On the quarantined step this "
f"is the EXPECTED outcome more often than not -- measured at 93% per "
f"attempt, six in a row is about 65% of runs (.cargo/config.toml, "
f"D-147). This message said 'roughly 1 run in 20' until 0.12.0, from a "
f"rate nobody had measured on this step. Before treating it as real, "
f"run the named binary on its own a few times: alone it should be "
f"clean, and exit 0xC0000005 there is still R15."
)
return 1
if __name__ == "__main__":
raise SystemExit(main())