Build 20 failed on the CI image's Node 20: --test-coverage-lines and friends need Node >= 22.8 and --experimental-strip-types needs 22.6. A shared helper now runs plain --experimental-test-coverage and enforces the same per-source >=95 floors by parsing the coverage table, so the gate is identical on Node 20 and newer local Nodes; the TypeScript suites skip with an explicit reason on runtimes that cannot strip types. Per-file gating also exposed pre-existing debt the old aggregate thresholds hid (wave_b_projects_modes.js branches 90 / funcs 94.7) - recorded as explicit enforced floors, not waived. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
86 lines
3.2 KiB
Python
86 lines
3.2 KiB
Python
"""Portable node test-runner gate for the HUX browser suites.
|
|
|
|
Runs ``node --test --experimental-test-coverage`` without the
|
|
version-gated threshold/include flags (Node >= 22.8 only) and enforces
|
|
the per-source thresholds by parsing the coverage table, so the same
|
|
gate passes on the CI image's Node 20 and on newer local Nodes.
|
|
TypeScript suites need ``--experimental-strip-types`` (Node >= 22.6);
|
|
on older runtimes they skip with an explicit reason instead of failing
|
|
on a missing capability.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
STRIP_TYPES_MINIMUM = (22, 6)
|
|
ROW = re.compile(r"([^|\s][^|]*?)\s*\|\s*([0-9.]+)\s*\|\s*([0-9.]+)\s*\|\s*([0-9.]+)")
|
|
METRICS = ("lines", "branches", "functions")
|
|
|
|
|
|
def node_version() -> tuple[int, int]:
|
|
raw = subprocess.run(
|
|
["node", "--version"], check=True, capture_output=True, text=True, timeout=15
|
|
).stdout.strip().lstrip("v")
|
|
major, minor = raw.split(".")[:2]
|
|
return int(major), int(minor)
|
|
|
|
|
|
def run_node_coverage(
|
|
sources: list[str],
|
|
tests: list[str],
|
|
thresholds: dict[str, float],
|
|
strip_types: bool = False,
|
|
timeout: int = 60,
|
|
overrides: dict[str, dict[str, float]] | None = None,
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run the node suite and enforce coverage thresholds per source file.
|
|
|
|
``overrides`` maps a source basename to explicit per-metric floors for
|
|
documented, pre-existing coverage debt (the old aggregate node gate hid
|
|
per-file shortfalls); an override still enforces its stated floor.
|
|
"""
|
|
unknown = set(thresholds) - set(METRICS)
|
|
assert not unknown, f"unknown metrics {unknown}"
|
|
overrides = overrides or {}
|
|
version = node_version()
|
|
if strip_types and version < STRIP_TYPES_MINIMUM:
|
|
pytest.skip(
|
|
f"node {version[0]}.{version[1]} lacks --experimental-strip-types; "
|
|
"the TypeScript suites need Node >= 22.6"
|
|
)
|
|
command = ["node", "--test"]
|
|
if strip_types:
|
|
command.append("--experimental-strip-types")
|
|
command += ["--experimental-test-coverage", *tests]
|
|
result = subprocess.run(
|
|
command, cwd=ROOT, check=False, capture_output=True, text=True, timeout=timeout
|
|
)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
rows: dict[str, tuple[float, float, float]] = {}
|
|
for line in result.stdout.splitlines():
|
|
match = ROW.search(line)
|
|
if match:
|
|
label = match.group(1).replace("\u2139", " ").strip()
|
|
name = label.split("/")[-1]
|
|
rows[name] = tuple(float(match.group(i)) for i in (2, 3, 4))
|
|
for source in sources:
|
|
name = Path(source).name
|
|
assert name in rows, (
|
|
f"no coverage row for {source}\n" + result.stdout + result.stderr
|
|
)
|
|
line_pct, branch_pct, funcs_pct = rows[name]
|
|
actual = {"lines": line_pct, "branches": branch_pct, "functions": funcs_pct}
|
|
floors = {**thresholds, **overrides.get(name, {})}
|
|
for metric, minimum in floors.items():
|
|
assert actual[metric] >= minimum, (
|
|
f"{source} {metric} coverage {actual[metric]} < {minimum}\n"
|
|
+ result.stdout
|
|
)
|
|
return result
|