Files
ersatztv/scripts/tests/test_jq_preflight.py
T
timothyandtimothy d4c72697f2
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 28s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m54s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 15s
feat(780): commit a ruff config and enforce it in CI (#813)
Python lint here was a property of the operator's laptop: the global instructions
say to run ruff, no workflow ran it, and with no committed config ruff fell back
to whichever ~/.config/ruff/ruff.toml the machine happened to have.

- ruff.toml at the root, pinned ruff==0.12.11 in the script-tests job.
- Both lint steps pass an EXPLICIT population from `git ls-files` with
  `--no-force-exclude`, never `ruff check .` — an `exclude` empties a
  discovery-based run into a GREEN one (top level empties both commands, [lint]
  empties check, [format] empties format --check), and `ruff check .` over zero
  files exits 0 with only a stderr warning. Guarded by an empty-population arm.
- Tree clean: 74 findings at 706674272, 57 fixed in code, 17 per-site noqa with
  reasons inline. S105 deliberately per-site, not a directory blanket. RUF100
  selected so a suppression that suppresses nothing is itself a finding.
- pyright stays ungated; reasoning in the record.

Both steps witnessed red on the runner against the shipped bodies: run 2173 job
9176 (ruff check) and run 2170 job 9163 (ruff format --check).

Docs: new record ci.python-lint-ruff-config-committed, ci.script-tests-job
cross-ref, docs/ci-cd.md (also correcting a stale ~190-tests/~10s figure to the
measured 773 tests / ~4.5 min), docs/defect-shapes-773.md §5.2 resolved.

fixes #780

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-22 00:33:18 +00:00

364 lines
16 KiB
Python

"""Tests for `scripts/jq-preflight.sh` — the jq version contract (ersatztv#648).
The axis this guards. Every shell gate in this repo is authored on a Mac shipping jq 1.8.x; the CI
runner ships jq 1.6. Nothing pinned or checked that, and three independent divergences surfaced in a
single day — `jq -e` over empty input (exit 4 vs 0), `contains("<NUL>")` (false vs true for every
string), and the parse-error exit code (5 vs 4, colliding with "no output"). Each was patched with a
version-stable construct, but patching constructs one at a time leaves the AXIS untested.
These tests shim `jq` on PATH with a fake reporting an arbitrary version, so the preflight's own
behaviour is verified by MEASUREMENT rather than by observing a green CI tick — ersatztv#648's third
Done-when box. Doing it here rather than by pushing a deliberately-red commit also keeps the proof
reproducible: it re-runs on every PR instead of living in one CI run's history.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "jq-preflight.sh"
WORKFLOWS = REPO_ROOT / ".gitea" / "workflows"
# Resolved BEFORE PATH is narrowed to the shim dir — the tests strip PATH down to just that
# directory, so `bash` could not be found by name from inside them.
BASH = shutil.which("bash") or "/bin/bash"
def _shq(s):
"""Single-quote a string for /bin/sh."""
return "'" + s.replace("'", "'\\''") + "'"
@pytest.fixture
def preflight(tmp_path):
bindir = tmp_path / "bin"
bindir.mkdir()
class Handle:
def with_jq(self, version_line, stderr="", exit_code=0):
"""Install a fake `jq` reporting `version_line` for --version.
`stderr` and `exit_code` exist because an earlier version of this shim ALWAYS exited 0
and never wrote to stderr — so it structurally could not observe the worst failure this
script has: a jq that cannot start. The preflight was folding stderr into the parse via
`2>&1` and discarding the exit status, so a glibc-mismatch message containing `2.34`
parsed as version 2.34 and PASSED the floor. Every case the shim could express was clean,
so every test passed.
"""
shim = bindir / "jq"
body = '#!/bin/sh\nif [ "$1" = "--version" ]; then\n'
if version_line:
body += f' printf "%s\\n" {_shq(version_line)}\n'
if stderr:
body += f' printf "%s\\n" {_shq(stderr)} >&2\n'
body += f" exit {exit_code}\nfi\nexit 0\n"
shim.write_text(body)
shim.chmod(0o755)
def without_jq(self):
shim = bindir / "jq"
if shim.exists():
shim.unlink()
def run(self, *args):
env = dict(os.environ)
# PATH contains ONLY the shim dir. An earlier draft appended /usr/bin:/bin "for the
# basics" and the missing-jq test passed vacuously against the developer machine's real
# /usr/bin/jq — the negative case was never negative. The script needs nothing from PATH
# but jq itself (`command -v` is a builtin, and bash is invoked by absolute path), so
# there is nothing to keep.
env["PATH"] = str(bindir)
return subprocess.run([BASH, str(SCRIPT), *args], env=env, capture_output=True, text=True)
def run_bytes(self, *args):
"""Same, but WITHOUT text mode.
`text=True` enables universal-newlines translation, which rewrites `\\r` to `\\n` in the
captured output — so any assertion about a stray carriage return is unfalsifiable through
`run()`. That is not hypothetical: the CR test passed identically with the strip removed
until this was noticed, while the mutant demonstrably emits
`... = jq-1.6\\r (parsed 1.6; ...)` at the byte level.
"""
env = dict(os.environ)
env["PATH"] = str(bindir)
return subprocess.run([BASH, str(SCRIPT), *args], env=env, capture_output=True)
return Handle()
def test_the_version_is_printed_so_the_job_log_shows_it(preflight):
"""ersatztv#648's second Done-when box: the jq version CI actually uses must be OBSERVABLE."""
preflight.with_jq("jq-1.6")
r = preflight.run()
assert r.returncode == 0, r.stderr
assert "jq-1.6" in r.stdout
def test_floor_mode_accepts_the_runner_version(preflight):
preflight.with_jq("jq-1.6")
assert preflight.run().returncode == 0
def test_floor_mode_accepts_a_newer_jq(preflight):
"""No upper bound in floor mode — review-verdict.yml writes the REQUIRED merge check, so a jq
bump must never be able to deadlock every merge in the repo."""
preflight.with_jq("jq-1.8.2")
assert preflight.run().returncode == 0
def test_below_the_floor_is_LOUD(preflight):
preflight.with_jq("jq-1.5")
r = preflight.run()
assert r.returncode == 1
assert "below the supported floor" in r.stderr.lower()
def test_missing_jq_is_loud(preflight):
preflight.without_jq()
r = preflight.run()
assert r.returncode == 1
assert "not on PATH" in r.stderr
@pytest.mark.parametrize("version_line", ["jq-1.6-dirty", "jq-1.6", "jq-1.6.0"])
def test_build_suffixes_still_parse_as_1_6(preflight, version_line):
"""A packaging suffix must not fail a perfectly ordinary jq closed — that would be a tripwire
firing on noise, which is how tripwires get disabled."""
preflight.with_jq(version_line)
assert preflight.run("--expect", "1.6").returncode == 0, version_line
def test_expect_mismatch_is_LOUD(preflight):
"""THE TRIPWIRE. scripts/tests exercises the jq 1.6 path only because the runner ships 1.6. If
the runner were upgraded that coverage would vanish silently, so the pin must go red instead."""
preflight.with_jq("jq-1.7.1")
r = preflight.run("--expect", "1.6")
assert r.returncode == 1
assert "expected jq 1.6, found 1.7" in r.stderr
def test_expect_match_passes(preflight):
preflight.with_jq("jq-1.6")
assert preflight.run("--expect", "1.6").returncode == 0
def test_unknown_argument_is_a_usage_error(preflight):
preflight.with_jq("jq-1.6")
assert preflight.run("--pin", "1.6").returncode == 2
def test_expect_without_a_value_is_a_usage_error_WITH_output(preflight):
"""`shift 2` on a missing value exits 1 under `set -e` with NOTHING on either stream. A CI step
that dies with an empty log is the diagnostic hole this script exists to remove."""
preflight.with_jq("jq-1.6")
r = preflight.run("--expect")
assert r.returncode == 2
assert "requires a <major.minor> value" in r.stderr
# --- Version parsing: the guard must never assert a floor against an unparsed version ----------
#
# The original strip-based parse assumed the format is exactly `jq-X.Y`. Anything else left major or
# minor EMPTY, and the sanity check concatenated them — so `jq version 1.6` produced "6", which is
# non-empty and all-digits, so the check PASSED. The floor comparison then ran `[ "" -lt 1 ]`, which
# errors; `set -e` exempts a failing command in an `if` condition, so the conditional read false and
# the script exited 0 having asserted NOTHING. That is this script's own stated failure mode,
# reproduced inside itself, which is why these cases are pinned rather than left to inspection.
@pytest.mark.parametrize(
"version_line",
[
"jq version 1.6", # some distro wrappers print this form
"JQ-1.6",
"jq-1.6-dirty",
],
)
def test_unusual_but_parseable_version_forms_are_accepted(preflight, version_line):
preflight.with_jq(version_line)
r = preflight.run()
assert r.returncode == 0, f"{version_line!r}: {r.stderr}"
assert "parsed 1.6" in r.stdout
@pytest.mark.parametrize("version_line", ["jq-1.-6", "jq-.6", "not-a-version", ""])
def test_unparseable_version_fails_CLOSED_rather_than_asserting_nothing(preflight, version_line):
preflight.with_jq(version_line)
r = preflight.run()
assert r.returncode == 1, (
f"{version_line!r} exited {r.returncode}: an unparsed version must never reach — or "
"silently skip — the floor assertion"
)
assert "could not parse" in r.stderr
def test_a_jq_that_cannot_START_fails_closed(preflight):
"""THE case the previous shim could not express, and the guard therefore got wrong.
A jq broken by a glibc mismatch (the canonical post-base-image-bump failure) exits 127 and writes
`... version 'GLIBC_2.34' not found` to STDERR. The preflight was reading `jq --version 2>&1` and
discarding the exit status, so that message became the parse input, `2.34` matched, and the floor
was certified green on a jq that cannot run at all.
"""
preflight.with_jq("", stderr="jq: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found", exit_code=127)
r = preflight.run()
assert r.returncode == 1
assert "cannot run" in r.stderr
assert "parsed 2.34" not in r.stdout, "stderr must never be parsed as a version"
@pytest.mark.parametrize(
"version_line",
[
"warning: something 3.14", # a noise line carrying a plausible number
"2026.07.26 jq-1.6", # a date prefix, which outranks the real version if unanchored
"jq-master-v0.0.0-1.6",
],
)
def test_a_number_that_is_not_the_VERSION_is_not_accepted_as_one(preflight, version_line):
"""Matching the first `<digits>.<digits>` ANYWHERE let a prefix win over the real version.
`2026.07.26 jq-1.6` parsed as 2026.07 and sailed over the floor. The pattern is anchored to the
leading `jq` token, so these fail closed instead."""
preflight.with_jq(version_line)
r = preflight.run()
assert r.returncode == 1, f"{version_line!r} was accepted as a version"
assert "could not parse" in r.stderr
@pytest.mark.parametrize(
"version_line",
[
"jq-99999999999999999999999.0",
"jq-1.99999999999999999999999",
],
)
def test_an_OUT_OF_RANGE_digit_run_fails_closed(preflight, version_line):
"""The round-1 fail-open mechanism, resurrected via an over-long number.
A regex that guarantees *digits* does not guarantee they fit `test`'s integer range. With a
23-digit major, `[ "$major" -lt "$min_major" ]` errors with "integer expression expected" — and
`set -e` exempts a failing command in an `if` condition, so the conditional read false and THE
FLOOR WAS NEVER ASSERTED, exit 0. Identical in shape to the empty-string case that started this.
Bounding the run with `{1,9}` alone was NOT enough either: the pattern is unanchored at the end,
so an over-long minor just matched its first 9 digits and compared that instead — a mis-parse
that passes. The trailing non-digit requirement is what actually closes it.
"""
preflight.with_jq(version_line)
r = preflight.run()
assert r.returncode == 1, f"{version_line!r} exited 0 — the floor was not asserted"
@pytest.mark.parametrize(
"version_line",
[
# Killed by the SEPARATOR restriction (a blank separator must be followed by `version`).
"jq\n2.34: cannot load shared library",
"jq\n\n\n99.9",
"jq -- 2.34 (real jq-1.6)",
"jq\t\t9.9",
# Killed ONLY by the first-line slice + `[[:blank:]]`. These carry the literal word `version`,
# so the separator restriction is satisfied and cannot save us — the newline must be excluded
# from the separator class AND the parse confined to line one.
#
# Without these, a round-5 mutation check found that reverting BOTH of those changes together
# (`[[:blank:]]`→`[[:space:]]` and parsing `$raw` instead of `$first`) left the whole suite
# GREEN: the four cases above are all killed by the separator alone, so they attributed the fix
# to the wrong layer. A test that passes for the wrong reason is how the previous three rounds
# each shipped a defect.
"jq\nversion\n9.9",
"jq\nversion 9.9",
"jq \n version \n 9.9",
],
)
def test_a_number_AFTER_the_jq_token_is_not_reachable_across_filler(preflight, version_line):
"""Two independent layers keep a stray number from being read as the version, and both are
pinned here: the separator must be one of the forms real jq emits (`jq-1.6` / `jq version 1.6`),
AND the match is confined to the first line with `[[:blank:]]` (which, unlike `[[:space:]]`,
does not match a newline). Round 3's 'anchor' had neither and parsed `jq\\n2.34: cannot load`
as 2.34."""
preflight.with_jq(version_line)
r = preflight.run()
assert r.returncode == 1, f"{version_line!r} was accepted as a version"
def test_a_CRLF_version_line_parses_and_logs_without_the_carriage_return(preflight):
"""The trailing `\\r` strip was unpinned — the commit claimed CRLF was verified, but nothing in
the suite contained one. Harmless today (a `\\r` satisfies the trailing non-digit boundary, so
the version still parses) but the log line would carry a stray CR."""
preflight.with_jq("jq-1.6\r")
r = preflight.run_bytes()
assert r.returncode == 0, r.stderr
assert b"parsed 1.6" in r.stdout
# Two separate traps had to be cleared for this assertion to mean anything:
# 1. `str.splitlines()` also splits on `\r`, so inspecting the "version in use" line would drop
# the stray CR before the assertion could see it;
# 2. `subprocess.run(text=True)` translates `\r` to `\n` outright, so even raw-string checks on
# `r.stdout` were unfalsifiable.
# Both made the test pass identically with the strip removed. Hence `run_bytes()` and a bytes
# comparison — verified by mutation, not by reading the code.
assert b"\r" not in r.stdout, "the carriage return leaked into the log line"
def test_the_observability_line_stays_on_ONE_line(preflight):
"""The no-arg mode exists to put a single grep-able version line in the job log; interpolating a
multi-line `--version` would split it."""
preflight.with_jq("jq-1.6\ntrailing noise")
r = preflight.run()
assert r.returncode == 0, r.stderr
version_lines = [ln for ln in r.stdout.splitlines() if "version in use" in ln]
assert len(version_lines) == 1
assert "trailing noise" not in r.stdout
@pytest.mark.parametrize(
"version_line,expected",
[
("jq-1.6 (Debian 1.6-2.1)", "1.6"), # distro packaging suffix
("jq-1.10", "1.10"), # two-digit minor: must compare numerically, not lexically
("jq-1.7.1", "1.7"),
("jq-1.6.0", "1.6"),
("jq-v1.6", "1.6"),
("JQ-1.6", "1.6"),
],
)
def test_legitimate_forms_still_parse_to_the_right_version(preflight, version_line, expected):
preflight.with_jq(version_line)
r = preflight.run()
assert r.returncode == 0, f"{version_line!r}: {r.stderr}"
assert f"parsed {expected}" in r.stdout
# --- Wiring guards: the preflight is worthless if a caller silently stops running it ------------
def test_script_tests_pins_the_jq_version():
"""The pin is the tripwire, so its presence is asserted rather than merely commented.
SCOPE NOTE — the symmetric assertion about `review-verdict.yml` (that it runs the FLOOR-only
mode and must never pin, because it writes the branch-protection-required `review-verdict/h10`
status and a pin would deadlock every merge on a jq bump) lands with the follow-up PR that
wires that workflow. It cannot land here: that workflow checks out the BASE ref, and the base
is `main`, which does not yet contain `scripts/jq-preflight.sh`.
"""
pr_checks = (WORKFLOWS / "pr-checks.yml").read_text()
assert "jq-preflight.sh --expect" in pr_checks, "script-tests must pin the jq version — that pin is the tripwire"
def test_review_verdict_never_pins_a_jq_version():
"""Whatever else changes, the REQUIRED merge check must never carry a hard version pin.
Asserted now, before the workflow is wired, so the constraint is already enforced when the
follow-up PR adds the floor-only call — rather than being a comment someone can miss.
"""
review_verdict = (WORKFLOWS / "review-verdict.yml").read_text()
assert "jq-preflight.sh --expect" not in review_verdict, (
"review-verdict.yml must NOT pin a jq version: it writes the required review-verdict/h10 "
"status, so a pin would deadlock every merge on a jq bump (ersatztv#648)"
)