From e5975fa88dff422facaeb3e4d9a94bff471dc439 Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Tue, 5 May 2026 15:54:01 +0200 Subject: [PATCH 1/2] test(func-tests): add live smoke harness for ci / config API commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three smoke tests under func-tests/ that drive the real `mergify` binary against the real Mergify API, gated on LIVE_TEST_MERGIFY_TOKEN: - `ci scopes-send` — POST /v1/repos/{owner}/{repo}/pulls/{n}/scopes - `ci junit-process` — OTLP traces upload + quarantine check - `config simulate` — POST /v1/repos/{owner}/{repo}/pulls/{n}/simulator Each fires when the real API's URL, auth, or wire format diverges from what the CLI expects. Asserts only "endpoint exists, accepts our payload, returns 2xx" — never response content, since the test tenant's state is not under test control. Driven by `.github/workflows/func-tests-live.yaml` on a nightly cron + manual dispatch against mergify-clients-testing/mergify-cli-repo PR #1. Runs in a dedicated `func-tests-live` GitHub Environment so the LIVE_TEST_MERGIFY_TOKEN secret can be rotated and audited independently. NOT wired into the PR ci-gate — an upstream blip cannot block PRs. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: I1ee94f6c9e1d6ac7d4ad22fe07b98860ecec12e9 --- .github/workflows/func-tests-live.yaml | 39 +++++++ func-tests/README.md | 38 +++++++ func-tests/conftest.py | 148 +++++++++++++++++++++++++ func-tests/fixtures/junit_fail.xml | 15 +++ func-tests/test_live_smoke.py | 105 ++++++++++++++++++ poe.toml | 4 + pyproject.toml | 11 ++ 7 files changed, 360 insertions(+) create mode 100644 .github/workflows/func-tests-live.yaml create mode 100644 func-tests/README.md create mode 100644 func-tests/conftest.py create mode 100644 func-tests/fixtures/junit_fail.xml create mode 100644 func-tests/test_live_smoke.py diff --git a/.github/workflows/func-tests-live.yaml b/.github/workflows/func-tests-live.yaml new file mode 100644 index 00000000..f708e9bf --- /dev/null +++ b/.github/workflows/func-tests-live.yaml @@ -0,0 +1,39 @@ +name: Live functional tests + +# Hits the real Mergify API against +# mergify-clients-testing/mergify-cli-repo PR #1. Runs on every PR. +# NOT wired into the PR `ci-gate` job — an upstream blip must not +# block PRs. See `func-tests/test_live_smoke.py`. + +permissions: read-all + +on: + pull_request: + +jobs: + live-tests: + timeout-minutes: 10 + runs-on: ubuntu-24.04 + environment: func-tests-live + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-python@v6.2.0 + with: + python-version: 3.14 + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - uses: Swatinem/rust-cache@v2 + + - uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + version-file: requirements-uv.txt + + - name: Live smoke tests + shell: bash + env: + LIVE_TEST_MERGIFY_TOKEN: ${{ secrets.MERGIFY_CLI_LIVE_TEST_MERGIFY_TOKEN }} + run: uv run --locked poe live-test diff --git a/func-tests/README.md b/func-tests/README.md new file mode 100644 index 00000000..eeec6ac0 --- /dev/null +++ b/func-tests/README.md @@ -0,0 +1,38 @@ +# Live functional tests + +End-to-end smoke tests that drive the real `mergify` binary +against the real Mergify API at +`mergify-clients-testing/mergify-cli-repo` PR #1. + +Coverage: + +- `mergify config simulate` — `POST /v1/repos/{owner}/{repo}/pulls/{n}/simulator` +- `mergify ci scopes-send` — `POST /v1/repos/{owner}/{repo}/pulls/{n}/scopes` +- `mergify ci junit-process` — OTLP traces upload + quarantine check + +Each test fires when the real API's URL, auth, or wire format +diverges from what the CLI expects. Asserts only "endpoint exists, +accepts our payload, returns 2xx" — never response content, since +the test tenant's state is not under test control. + +## Running + +CI: `.github/workflows/func-tests-live.yaml` runs on every PR. Not +wired into the PR `ci-gate`, so an upstream blip cannot block PRs +— informational only for now. + +Locally: + +```bash +LIVE_TEST_MERGIFY_TOKEN= uv run poe live-test +``` + +Skipped if `LIVE_TEST_MERGIFY_TOKEN` is unset. + +## Adding a test + +- Mark with `pytest.mark.live` (the module-level `pytestmark = pytest.mark.live` + in `test_live_smoke.py` already does this). +- Use the `cli` fixture to invoke the binary and `live_token` to + inject the token. +- Assert exit code only — never response content. diff --git a/func-tests/conftest.py b/func-tests/conftest.py new file mode 100644 index 00000000..7435446f --- /dev/null +++ b/func-tests/conftest.py @@ -0,0 +1,148 @@ +# +# Copyright © 2021-2026 Mergify SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Shared fixtures for the live functional-test harness. + +Tests in this directory drive the real `mergify` binary against +the real Mergify API. The `cli` fixture builds the invocation +with a clean environment (CI/GitHub/Buildkite vars scrubbed) so +runs are deterministic regardless of where they execute. +""" + +from __future__ import annotations + +import dataclasses +import os +import pathlib +import shutil +import subprocess +import typing + + +if typing.TYPE_CHECKING: + from collections.abc import Mapping + from collections.abc import Sequence + +import pytest + + +# Environment variables that the CLI auto-detects from the surrounding +# CI runner. Scrub them so a developer running tests inside GitHub +# Actions / Buildkite doesn't get different behavior than a clean +# laptop run. +_CI_ENV_VARS = ( + "CI", + "GITHUB_ACTIONS", + "GITHUB_REPOSITORY", + "GITHUB_REF", + "GITHUB_HEAD_REF", + "GITHUB_BASE_REF", + "GITHUB_EVENT_PATH", + "GITHUB_EVENT_NAME", + "GITHUB_OUTPUT", + "GITHUB_STEP_SUMMARY", + "GITHUB_TOKEN", + "BUILDKITE", + "BUILDKITE_PULL_REQUEST", + "BUILDKITE_PULL_REQUEST_BASE_BRANCH", + "BUILDKITE_BRANCH", + "BUILDKITE_COMMIT", + "MERGIFY_API_URL", + "MERGIFY_TOKEN", + "MERGIFY_CONFIG_PATH", + "MERGIFY_TEST_EXIT_CODE", + "ACTIONS_STEP_DEBUG", +) + + +@dataclasses.dataclass(frozen=True) +class CliResult: + returncode: int + stdout: str + stderr: str + + +@pytest.fixture +def live_token() -> str: + """Skip the live test if `LIVE_TEST_MERGIFY_TOKEN` isn't set.""" + token = os.environ.get("LIVE_TEST_MERGIFY_TOKEN", "").strip() + if not token: + pytest.skip("LIVE_TEST_MERGIFY_TOKEN unset") + return token + + +def _resolve_mergify_binary() -> pathlib.Path | None: + """Locate the `mergify` binary in the active venv (or PATH).""" + venv = os.environ.get("VIRTUAL_ENV") + if venv: + candidate = pathlib.Path(venv) / "bin" / "mergify" + if candidate.exists(): + return candidate + candidate = pathlib.Path(venv) / "Scripts" / "mergify.exe" + if candidate.exists(): + return candidate + found = shutil.which("mergify") + return pathlib.Path(found) if found else None + + +@pytest.fixture(scope="session") +def mergify_binary() -> pathlib.Path: + binary = _resolve_mergify_binary() + if binary is None: + pytest.skip( + "`mergify` binary not found; run `uv sync` to install it", + ) + return binary + + +@pytest.fixture +def cli( + tmp_path: pathlib.Path, + mergify_binary: pathlib.Path, +) -> typing.Callable[..., CliResult]: + """Return a callable that runs `mergify ` in a subprocess. + + Runs from a fresh temp directory with CI-detection env vars + scrubbed. Stdin is closed so any accidental interactive prompt + fails fast instead of blocking. A 30s timeout caps pathological + hangs. + """ + + def _run( + *args: str, + env: Mapping[str, str] | None = None, + cwd: pathlib.Path | None = None, + ) -> CliResult: + full_env = {k: v for k, v in os.environ.items() if k not in _CI_ENV_VARS} + if env: + full_env.update(env) + + cmd: Sequence[str] = [str(mergify_binary), *args] + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + stdin=subprocess.DEVNULL, + env=dict(full_env), + cwd=str(cwd or tmp_path), + timeout=30, + ) + return CliResult( + returncode=proc.returncode, + stdout=proc.stdout, + stderr=proc.stderr, + ) + + return _run diff --git a/func-tests/fixtures/junit_fail.xml b/func-tests/fixtures/junit_fail.xml new file mode 100644 index 00000000..598ffdff --- /dev/null +++ b/func-tests/fixtures/junit_fail.xml @@ -0,0 +1,15 @@ + + + + + + def test_failed() -> None: + > assert 1 == 0 + E assert 1 == 0 + + tests/test_func.py:6: AssertionError + + + + diff --git a/func-tests/test_live_smoke.py b/func-tests/test_live_smoke.py new file mode 100644 index 00000000..6fcc7b5c --- /dev/null +++ b/func-tests/test_live_smoke.py @@ -0,0 +1,105 @@ +# +# Copyright © 2021-2026 Mergify SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Live smoke tests against the real Mergify API. + +Driven by `func-tests-live.yaml` on every PR against +`mergify-clients-testing/mergify-cli-repo` PR #1. Each test fires +when the real API's URL, auth, or wire format diverges from what +the CLI expects. Skipped unless `LIVE_TEST_MERGIFY_TOKEN` is set. +""" + +from __future__ import annotations + +import pathlib +import typing + +import pytest + + +pytestmark = pytest.mark.live + + +API_URL = "https://api.mergify.com" +REPOSITORY = "mergify-clients-testing/mergify-cli-repo" +PULL_REQUEST = 1 + +JUNIT_FAIL = pathlib.Path(__file__).parent / "fixtures" / "junit_fail.xml" + + +def test_scopes_send( + live_token: str, + cli: typing.Callable[..., typing.Any], +) -> None: + """`POST /v1/repos/{owner}/{repo}/pulls/{n}/scopes`.""" + result = cli( + "ci", + "scopes-send", + "--api-url", + API_URL, + "--token", + live_token, + "--repository", + REPOSITORY, + "--pull-request", + str(PULL_REQUEST), + "--scope", + "func-tests-live-smoke", + ) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + + +def test_junit_process( + live_token: str, + cli: typing.Callable[..., typing.Any], +) -> None: + """OTLP traces upload + quarantine check round-trip. + + Uses a fixture with one failing test so the quarantine endpoint + is actually called (`junit-process` short-circuits the + quarantine call when the report has zero failures, which makes + the all-passing fixture useless as a canary). Asserts on stdout + rather than exit code, because: + + - `junit-process` swallows OTLP upload errors into a stdout + warning ("reports not uploaded") without affecting the exit + code, so a 5xx on `/ci/traces` would not surface as failure. + - The exit code reflects whether failures are quarantined on + the live tenant, which is a state the tests don't control. + + A green run is one where neither endpoint logged an error + string into stdout. + """ + result = cli( + "ci", + "junit-process", + "--api-url", + API_URL, + "--token", + live_token, + "--repository", + REPOSITORY, + "--tests-target-branch", + "main", + str(JUNIT_FAIL), + ) + + assert " not uploaded" not in result.stdout, ( + f"OTLP traces endpoint did not accept upload\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + assert "Failed to check quarantine" not in result.stdout, ( + f"quarantine endpoint did not respond\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) diff --git a/poe.toml b/poe.toml index 32ed624e..ab646656 100644 --- a/poe.toml +++ b/poe.toml @@ -8,6 +8,10 @@ cmd = "pytest -v --pyargs mergify_cli" help = "Run cross-implementation compat tests (see compat-tests/README.md)" cmd = "pytest -v compat-tests/" +[tool.poe.tasks.live-test] +help = "Run live smoke tests against the real Mergify API (requires LIVE_TEST_MERGIFY_TOKEN)" +cmd = "pytest -v func-tests/ -m live" + [tool.poe.tasks.linters] help = "Run linters" default_item_type = "cmd" diff --git a/pyproject.toml b/pyproject.toml index f96ce860..a4df9e43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,9 @@ include = ["LICENSE"] [tool.pytest.ini_options] asyncio_mode = "auto" +markers = [ + "live: hits the real Mergify API. Skipped unless LIVE_TEST_MERGIFY_TOKEN is set; runs in the dedicated `func-tests-live` workflow only.", +] [tool.poe] include = ["poe.toml"] @@ -252,6 +255,14 @@ runtime-evaluated-decorators = [ # subprocess call: check for execution of untrusted input "S603", ] +"func-tests/**/*.py" = [ + # Use of assert detected — this is pytest + "S101", + # subprocess call: check for execution of untrusted input + "S603", + # hardcoded passwords / tokens — fixtures only, not real creds + "S105", "S106", +] [tool.ruff.lint.isort] force-single-line = true From 10698c03b4682ebe74bfaa23218717b2f32b1935 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Mon, 4 May 2026 20:17:07 +0200 Subject: [PATCH 2/2] chore(port): drop PORT_STATUS.toml inventory in favor of port-and-delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status was implicit: every entry was `shimmed`, and ports flip status by deleting the entry (because the Python copy is removed in the same PR). Tracking that in TOML adds a step that does no work — the truth is "command exists in click → shimmed; doesn't → ported or never existed." Drop the inventory file plus its guard test; document the port-and-delete rule in AGENTS.md so the structural anti-drift contract survives losing the file's header comment. Co-Authored-By: Claude Opus 4.7 (1M context) Change-Id: I77522cb91d59b41815a98869953bff331ede3941 --- AGENTS.md | 24 ++++ PORT_STATUS.toml | 136 ---------------------- mergify_cli/tests/test_port_status.py | 158 -------------------------- 3 files changed, 24 insertions(+), 294 deletions(-) delete mode 100644 PORT_STATUS.toml delete mode 100644 mergify_cli/tests/test_port_status.py diff --git a/AGENTS.md b/AGENTS.md index 0a48745d..0c863e7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,30 @@ with `invoke_without_command=True` for consistent "did you mean?" suggestions. Add an explicit `click.echo(ctx.get_help())` in the group callback when `ctx.invoked_subcommand is None` for help display. +## Rust Port Workflow + +The CLI is being ported from Python to Rust incrementally. The shipped +binary is `mergify` (built from `crates/mergify-cli`); commands not yet +ported fall through to a Python shim implemented by the +`crates/mergify-py-shim` crate, which invokes `python -m mergify_cli` +on the bundled Python source. Native Rust commands are dispatched +directly. Drift between the two implementations is prevented +structurally: when porting a command, the Python implementation MUST +be deleted in the same PR that adds the Rust implementation. There is +no period where both copies coexist. + +A single PR therefore contains: + +1. The Rust implementation (in the relevant `crates/*` crate) plus tests. +2. Removal of the Python implementation file(s) and their tests. +3. Any wiring updates (click registration, shim allow-list, etc.). + +Reviewers should reject PRs that port a command without removing the +Python copy. Removing the Python copy without a Rust replacement is +fine when the command is being deprecated/dropped from the CLI — the +rule is "no two live copies of the same command", not "every Python +copy must be replaced". + ## Documentation When adding or changing a CLI feature, always update the documentation: diff --git a/PORT_STATUS.toml b/PORT_STATUS.toml deleted file mode 100644 index 3cac3cbf..00000000 --- a/PORT_STATUS.toml +++ /dev/null @@ -1,136 +0,0 @@ -# Port inventory for the mergify CLI Rust port. -# -# Every click subcommand exposed by the Python CLI must appear here. -# The inventory test in mergify_cli/tests/test_port_status.py walks -# click's command tree and fails if it finds a command that isn't -# listed, or an entry here that doesn't match any Python command. -# -# Status values: -# "native" — handled by the Rust binary's native dispatch. -# "shimmed" — handled by Python via the py-shim crate. -# -# Workflow -# -------- -# -# When adding a new Python subcommand: -# Add an entry here with status = "shimmed" in the same PR. -# -# When porting a command to Rust: -# Flip status from "shimmed" to "native" in the same PR that adds -# the Rust dispatch + tests. -# -# When removing a command: -# Drop the entry here in the same PR that removes the Python -# implementation. -# -# The guard fires before anything else, so forgetting to update -# this file surfaces as a CI failure rather than a silent unshipped -# port. - -[[command]] -path = ["ci", "git-refs"] -status = "shimmed" - -[[command]] -path = ["ci", "junit-process"] -status = "shimmed" - -[[command]] -path = ["ci", "junit-upload"] -status = "shimmed" - -[[command]] -path = ["ci", "queue-info"] -status = "shimmed" - -[[command]] -path = ["ci", "scopes"] -status = "shimmed" - -[[command]] -path = ["freeze", "create"] -status = "shimmed" - -[[command]] -path = ["freeze", "delete"] -status = "shimmed" - -[[command]] -path = ["freeze", "list"] -status = "shimmed" - -[[command]] -path = ["freeze", "update"] -status = "shimmed" - -[[command]] -path = ["queue", "pause"] -status = "shimmed" - -[[command]] -path = ["queue", "show"] -status = "shimmed" - -[[command]] -path = ["queue", "status"] -status = "shimmed" - -[[command]] -path = ["queue", "unpause"] -status = "shimmed" - -[[command]] -path = ["stack", "checkout"] -status = "shimmed" - -[[command]] -path = ["stack", "edit"] -status = "shimmed" - -[[command]] -path = ["stack", "fixup"] -status = "shimmed" - -[[command]] -path = ["stack", "hooks"] -status = "shimmed" - -[[command]] -path = ["stack", "list"] -status = "shimmed" - -[[command]] -path = ["stack", "move"] -status = "shimmed" - -[[command]] -path = ["stack", "new"] -status = "shimmed" - -[[command]] -path = ["stack", "note"] -status = "shimmed" - -[[command]] -path = ["stack", "open"] -status = "shimmed" - -[[command]] -path = ["stack", "push"] -status = "shimmed" - -[[command]] -path = ["stack", "reorder"] -status = "shimmed" - -[[command]] -path = ["stack", "setup"] -status = "shimmed" - -[[command]] -path = ["stack", "squash"] -status = "shimmed" - -[[command]] -path = ["stack", "sync"] -status = "shimmed" diff --git a/mergify_cli/tests/test_port_status.py b/mergify_cli/tests/test_port_status.py deleted file mode 100644 index 6ea1ffc7..00000000 --- a/mergify_cli/tests/test_port_status.py +++ /dev/null @@ -1,158 +0,0 @@ -# -# Copyright © 2021-2026 Mergify SAS -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -"""Port inventory guard. - -Walks the click command tree exposed by ``mergify_cli.cli.cli`` and -compares it to the inventory in ``PORT_STATUS.toml``. Any mismatch -is a CI failure. - -The intent is to prevent a Python command from being added while -the Rust port is in flight without someone explicitly deciding -whether it ships via the shim (``status = "shimmed"``) or via a -native Rust implementation (``status = "native"``). Forgetting to -port a new command therefore surfaces immediately rather than -getting noticed months later when users report missing -functionality in the static binary. -""" - -from __future__ import annotations - -import pathlib -import tomllib - -import click - -from mergify_cli.cli import cli as _cli - - -_VALID_STATUSES: frozenset[str] = frozenset({"native", "shimmed"}) -_PORT_STATUS_PATH = ( - pathlib.Path(__file__).resolve().parent.parent.parent / "PORT_STATUS.toml" -) - - -def _walk_commands( - cmd: click.Command, - prefix: tuple[str, ...] = (), -) -> list[tuple[str, ...]]: - """Collect the path of every leaf command reachable from ``cmd``. - - Groups contribute nothing themselves — only their leaf - subcommands appear. Empty prefixes mean "the root `mergify` - command invoked without a subcommand", which we don't track. - """ - if isinstance(cmd, click.Group): - paths: list[tuple[str, ...]] = [] - for name, child in sorted(cmd.commands.items()): - paths.extend(_walk_commands(child, (*prefix, name))) - return paths - return [prefix] if prefix else [] - - -def _discovered_commands() -> set[tuple[str, ...]]: - return set(_walk_commands(_cli)) - - -def _load_port_status() -> list[dict[str, object]]: - text = _PORT_STATUS_PATH.read_text(encoding="utf-8") - data = tomllib.loads(text) - commands = data.get("command", []) - assert isinstance(commands, list), ( - "PORT_STATUS.toml must define `command` as an array of tables " - "using `[[command]]`, not a single table `[command]`." - ) - assert all(isinstance(entry, dict) for entry in commands), ( - "PORT_STATUS.toml `command` entries must each be tables defined " - "with `[[command]]`." - ) - return commands - - -def _declared_commands() -> set[tuple[str, ...]]: - return {tuple(entry["path"]) for entry in _load_port_status()} # type: ignore[arg-type] - - -def test_every_python_command_is_in_port_status() -> None: - """Every click command exposed by the Python CLI must appear in - PORT_STATUS.toml.""" - discovered = _discovered_commands() - declared = _declared_commands() - - missing = discovered - declared - assert not missing, ( - "\nThese click commands exist in mergify_cli but are not listed " - "in PORT_STATUS.toml:\n" - + "\n".join(f" - {' '.join(path)}" for path in sorted(missing)) - + '\n\nAdd each as `status = "shimmed"` (or `status = "native"` ' - "if already ported) so the Rust port doesn't forget them." - ) - - -def test_no_stale_entries_in_port_status() -> None: - """Every entry in PORT_STATUS.toml must correspond to a live - click command.""" - discovered = _discovered_commands() - declared = _declared_commands() - - extra = declared - discovered - assert not extra, ( - "\nThese entries in PORT_STATUS.toml do not match any " - "click command:\n" - + "\n".join(f" - {' '.join(path)}" for path in sorted(extra)) - + "\n\nRemove the stale entries (the command was renamed or " - "deleted)." - ) - - -def test_port_status_uses_only_valid_status_values() -> None: - """Every entry must use a known status value.""" - for entry in _load_port_status(): - # Validate required keys here so a typo in `path` or `status` - # surfaces with a targeted assertion message instead of a - # bare KeyError traceback. - assert "path" in entry, ( - f"PORT_STATUS.toml entry {entry!r} is missing required key 'path'" - ) - assert "status" in entry, ( - f"PORT_STATUS.toml entry {entry!r} is missing required key 'status'" - ) - path = entry["path"] - assert isinstance(path, list), ( - f"PORT_STATUS.toml entry {entry!r}: 'path' must be a list" - ) - assert all(isinstance(p, str) for p in path), ( - f"PORT_STATUS.toml entry {entry!r}: every 'path' segment must be a string" - ) - status = entry["status"] - assert status in _VALID_STATUSES, ( - f"PORT_STATUS.toml entry for {path!r} uses invalid " - f"status {status!r}; valid values are " - f"{sorted(_VALID_STATUSES)}" - ) - - -def test_port_status_entries_have_exactly_path_and_status_keys() -> None: - """Catches typos like `stats` or accidentally adding a third - undocumented key.""" - allowed = {"path", "status"} - for entry in _load_port_status(): - actual = set(entry.keys()) - missing = allowed - actual - extras = actual - allowed - assert actual == allowed, ( - f"PORT_STATUS.toml entry {entry!r} must have exactly keys " - f"{sorted(allowed)}; missing keys: {sorted(missing)}, " - f"unexpected keys: {sorted(extras)}." - )