chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run jcode's CI-style test suites with timing and timeout reporting.
|
||||
|
||||
This is intentionally split the same way as `.github/workflows/ci.yml` instead of
|
||||
using one monolithic `cargo test --workspace --all-targets`, which is harder to
|
||||
interpret locally and can exceed interactive harness command limits. By default
|
||||
it uses one Rust test thread for deterministic local runs because several tests
|
||||
exercise process-wide environment and server state; pass `--parallel` to use
|
||||
Cargo's default test harness parallelism.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Suite:
|
||||
name: str
|
||||
timeout_seconds: int
|
||||
cargo_args: list[str]
|
||||
|
||||
def command(self, *, parallel: bool) -> list[str]:
|
||||
command = ["cargo", *self.cargo_args]
|
||||
if not parallel:
|
||||
command.extend(["--", "--test-threads=1"])
|
||||
return command
|
||||
|
||||
|
||||
SUITES = {
|
||||
"lib-bins": Suite("lib-bins", 1800, ["test", "--lib", "--bins"]),
|
||||
"provider-matrix": Suite(
|
||||
"provider-matrix", 900, ["test", "--test", "provider_matrix"]
|
||||
),
|
||||
"e2e": Suite("e2e", 1800, ["test", "--test", "e2e"]),
|
||||
}
|
||||
|
||||
CURRENT_PROC: subprocess.Popen[bytes] | None = None
|
||||
|
||||
|
||||
def terminate_process_group(proc: subprocess.Popen[bytes]) -> None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
proc.wait(timeout=5)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.wait()
|
||||
|
||||
|
||||
def handle_signal(signum: int, _frame: FrameType | None) -> None:
|
||||
if CURRENT_PROC is not None and CURRENT_PROC.poll() is None:
|
||||
terminate_process_group(CURRENT_PROC)
|
||||
raise SystemExit(128 + signum)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"suite",
|
||||
nargs="*",
|
||||
choices=["all", *SUITES.keys()],
|
||||
default=["all"],
|
||||
help="Suite(s) to run. Defaults to all CI-style suites.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout-scale",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Scale each suite timeout, useful for slow local machines.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parallel",
|
||||
action="store_true",
|
||||
help="Use Cargo's default parallel Rust test execution instead of --test-threads=1.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def selected_suites(names: list[str]) -> list[Suite]:
|
||||
if not names or "all" in names:
|
||||
return list(SUITES.values())
|
||||
return [SUITES[name] for name in names]
|
||||
|
||||
|
||||
def progress(message: str, **extra: object) -> None:
|
||||
payload = {"kind": "indeterminate", "message": message}
|
||||
payload.update(extra)
|
||||
print("JCODE_PROGRESS " + json.dumps(payload), flush=True)
|
||||
|
||||
|
||||
def run_suite(suite: Suite, timeout_scale: float, *, parallel: bool) -> tuple[int, float]:
|
||||
timeout_seconds = max(1, int(suite.timeout_seconds * timeout_scale))
|
||||
started = time.monotonic()
|
||||
progress(
|
||||
f"Running {suite.name}",
|
||||
current=0,
|
||||
total=1,
|
||||
unit="suite",
|
||||
eta_seconds=timeout_seconds,
|
||||
)
|
||||
command = suite.command(parallel=parallel)
|
||||
print(f"\n=== {suite.name} ===", flush=True)
|
||||
print("$ " + " ".join(command), flush=True)
|
||||
|
||||
global CURRENT_PROC
|
||||
proc = subprocess.Popen(command, cwd=REPO_ROOT, start_new_session=True)
|
||||
CURRENT_PROC = proc
|
||||
try:
|
||||
returncode = proc.wait(timeout=timeout_seconds)
|
||||
elapsed = time.monotonic() - started
|
||||
print(
|
||||
f"=== {suite.name} exit={returncode} elapsed={elapsed:.1f}s timeout={timeout_seconds}s ===",
|
||||
flush=True,
|
||||
)
|
||||
return returncode, elapsed
|
||||
except subprocess.TimeoutExpired:
|
||||
elapsed = time.monotonic() - started
|
||||
terminate_process_group(proc)
|
||||
print(
|
||||
f"=== {suite.name} timed out after {elapsed:.1f}s timeout={timeout_seconds}s ===",
|
||||
flush=True,
|
||||
)
|
||||
return 124, elapsed
|
||||
finally:
|
||||
if CURRENT_PROC is proc:
|
||||
CURRENT_PROC = None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
args = parse_args()
|
||||
suites = selected_suites(args.suite)
|
||||
failures: list[tuple[str, int, float]] = []
|
||||
total_started = time.monotonic()
|
||||
|
||||
for index, suite in enumerate(suites, start=1):
|
||||
progress(f"Running {suite.name} ({index}/{len(suites)})", current=index, total=len(suites), unit="suite")
|
||||
code, elapsed = run_suite(suite, args.timeout_scale, parallel=args.parallel)
|
||||
if code != 0:
|
||||
failures.append((suite.name, code, elapsed))
|
||||
break
|
||||
|
||||
total_elapsed = time.monotonic() - total_started
|
||||
print("\n=== test suite summary ===", flush=True)
|
||||
print(f"suites={len(suites)} elapsed={total_elapsed:.1f}s", flush=True)
|
||||
if failures:
|
||||
for name, code, elapsed in failures:
|
||||
print(f"FAILED {name}: exit={code} elapsed={elapsed:.1f}s", flush=True)
|
||||
return failures[0][1]
|
||||
|
||||
print("All selected CI-style test suites passed.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user