From 6fe2974030e486d215dad3047643856b1bb5954e Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Mon, 26 Oct 2026 17:55:42 +0900 Subject: [PATCH 1/3] build(make): add integration, wheel-smoke and verify targets --- CONTRIBUTING.md | 14 +++- Makefile | 16 ++++ docs/development.md | 39 ++++++++- scripts/local-smoke.sh | 134 ++++++++++++++++++++++++++++++ tests/test_make_smoke_contract.py | 81 ++++++++++++++++++ 5 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 scripts/local-smoke.sh create mode 100644 tests/test_make_smoke_contract.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b78c8021..e275332e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,14 +82,24 @@ an `E2E_BASE_URL`; use `make e2e-azure` only when that environment is available. ```bash make format # Format code with ruff -make lint # Lint with ruff +make lint # Lint and type check (ruff + mypy) make typecheck # Type check with mypy make test # Run tests make cov # Run tests with coverage make perf # Check spec-generation performance budgets -make check-all # Run the full local gate +make check-all # Offline gate: lint-workflows + check + test + security +make integration # Real Functions host + Azurite smoke, using free ports +make docs # Strict documentation build, matching CI +make wheel-smoke # Build and test an installed wheel outside the checkout +make verify # Full local gate: check + test + integration + docs + wheel-smoke ``` +`make verify` runs these lanes in order and stops on the first failure, even with +parallel make enabled. `make check-all` retains its offline scope; it does not +start services or build docs. Install dependencies before running offline checks. +For integration prerequisites and the one-time smoke venv setup, see the +[Development Guide](docs/development.md#local-smoke-prerequisites). + Before opening a PR, run `ruff format --check ` (or `ruff format ` to fix them). `make format-check` checks `src` and `tests` without changing files. CI checks those same trees and also diff --git a/Makefile b/Makefile index e317c6bb..3cd7fe53 100644 --- a/Makefile +++ b/Makefile @@ -145,6 +145,22 @@ cov: ensure-hatch @echo "Open htmlcov/index.html in your browser to view the coverage report." @echo "coverage.xml generated for Codecov upload." +.PHONY: integration +integration: + @PYTHON="$(PYTHON)" bash scripts/local-smoke.sh integration + +.PHONY: wheel-smoke +wheel-smoke: ensure-hatch + @PYTHON="$(PYTHON)" HATCH="$(abspath $(HATCH))" bash scripts/local-smoke.sh wheel-smoke + +.PHONY: verify +verify: + @$(MAKE) check + @$(MAKE) test + @$(MAKE) integration + @$(MAKE) docs + @$(MAKE) wheel-smoke + .PHONY: browser-install test-browser browser-install: @npm install --cache .cache/npm --prefix .cache/browser --no-save --package-lock=false playwright@$(PLAYWRIGHT_VERSION) diff --git a/docs/development.md b/docs/development.md index 4e16d6f8..84edfd06 100644 --- a/docs/development.md +++ b/docs/development.md @@ -138,12 +138,15 @@ Use these as the **golden commands** for local validation and CI parity. Prefer | `make typecheck` | Run mypy type checking | | `make security` | Run Bandit security scan | | `make test` | Run pytest | +| `make integration` | Run real Functions host + Azurite smoke on runtime-selected free ports | +| `make wheel-smoke` | Build a wheel, install into a throwaway venv outside the checkout, and generate a spec | | `make cov` | Run tests with coverage (gate: `fail_under = 95`) | | `make e2e-local` | Run e2e tests against local Azurite on `:7071` | | `make e2e-azure` | Run e2e tests against Azure (`E2E_BASE_URL` required) | | `make lint-workflows` | Lint release workflows, action pins, and the Hatch matrix | | `make check` | Run `make lint`, which includes the type check, once | -| `make check-all` | Run lint-workflows + check + test + security | +| `make check-all` | Offline gate: lint-workflows + check + test + security | +| `make verify` | Full local gate: check + test + integration + strict docs + wheel-smoke, in order, failing fast | | `make docs` | Build the MkDocs site with `mkdocs build --strict`, like the CI docs gate | | `make docs-serve` | Serve the docs locally with live reload | | `make demo` | Run the Swagger UI and example demos | @@ -175,6 +178,40 @@ newer on `PATH` and `.venv/bin/hatch` (created by `make install`) are required: only a local Functions host needs them. A Python 3.11 that only Hatch manages is not on `PATH`, so `doctor` reports it as missing. +### Local smoke prerequisites + +`make integration` requires Python 3.11+, Bash, curl, Functions Core Tools v4 +(`func`), Azurite (`azurite`, or an already installed/cached `npx --no-install azurite`), +and the existing host-smoke app's venv. It fails instead of skipping when a +prerequisite is missing. Prepare that venv once from the repository root: + +```bash +python3.11 -m venv tests/host_smoke/.venv +(cd tests/host_smoke && .venv/bin/python -m pip install -r requirements.txt pytest pytest-cov requests) +make integration PYTHON=python3.11 +``` + +Use `PYTHON=` if your default `python3` is older than 3.11. +Integration starts its own host and Azurite with four distinct free ports and +temporary storage, overrides `AzureWebJobsStorage` without modifying settings, +then checks the custom-name route, the route taken from the generated spec, +Swagger UI, and all `host_smoke` route tests via `HOST_SMOKE_BASE_URL`. +Service process groups and temporary data are cleaned on success, failure, +Ctrl-C, or termination. Existing hosts and storage emulators are not touched. + +`make wheel-smoke PYTHON=python3.11` builds only the candidate wheel and installs +it with runtime dependencies in a temporary venv outside the repository. It checks +dependencies, imports outside the checkout, and generates the same minimal spec +as CI's artifact smoke. The wheel, venv, and scratch directory are removed on exit. +Dependency installation may need package-index access unless wheels are available +locally (`PIP_NO_INDEX=1 PIP_FIND_LINKS=/path/to/wheels`). Neither smoke uses Azure. + +`make verify` runs check, test, integration, strict docs, then wheel-smoke +sequentially, stopping at the first failing lane even under `make -j`. +`make check-all` remains the offline gate (lint-workflows + check + test + +security), with dependencies already installed; it does not invoke host smoke, +docs, or wheel installation. + > For the full release workflow, see [Release Process](release_process.md). --- diff --git a/scripts/local-smoke.sh b/scripts/local-smoke.sh new file mode 100644 index 00000000..0d1e02db --- /dev/null +++ b/scripts/local-smoke.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(pwd) +python=${PYTHON:-python3} +fail() { echo "Error: $*" >&2; exit 1; } +command -v "$python" >/dev/null || fail "Python >=3.11 is required (PYTHON=$python)" +"$python" -c 'import sys; sys.exit(sys.version_info < (3, 11))' || fail "Python >=3.11 is required" + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/openapi-smoke.XXXXXXXX") +pids=() +cleanup() { + status=$? + trap - EXIT + trap '' INT TERM + for pid in "${pids[@]}"; do kill -TERM -- "-$pid" 2>/dev/null || true; done + if ((${#pids[@]})); then + sleep 1 + for pid in "${pids[@]}"; do kill -KILL -- "-$pid" 2>/dev/null || true; done + for pid in "${pids[@]}"; do wait "$pid" 2>/dev/null || true; done + fi + if ((status != 0)); then + for log in "$tmp"/*.log; do + if [[ -f "$log" ]]; then echo "--- $log"; cat "$log"; fi + done + fi + rm -rf "$tmp" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +# Job control gives each service its own process group, including npm/worker descendants. +set -m + +case "${1:-}" in +integration) + for tool in func curl; do command -v "$tool" >/dev/null || fail "$tool is required for integration"; done + azurite=(azurite) + if ! command -v azurite >/dev/null; then + command -v npx >/dev/null || fail "azurite or npx is required for integration" + azurite=(npx --no-install azurite) + fi + smoke="$root/tests/host_smoke/.venv/bin/python" + [[ -x "$smoke" ]] || fail "smoke venv missing: create tests/host_smoke/.venv and install tests/host_smoke/requirements.txt, pytest, pytest-cov, requests (see docs/development.md)" + "$smoke" -c 'import sys; sys.exit(sys.version_info < (3, 11))' || fail "smoke venv needs Python >=3.11" + "$smoke" -c 'import azure_functions_openapi, pytest, pytest_cov, requests' || fail "smoke venv dependencies are missing; see docs/development.md" + read -r host_port blob_port queue_port table_port < <("$python" - <<'PY' +from contextlib import ExitStack +import socket + +with ExitStack() as stack: + sockets = [stack.enter_context(socket.socket()) for _ in range(4)] + for sock in sockets: + sock.bind(("127.0.0.1", 0)) + print(*(sock.getsockname()[1] for sock in sockets)) +PY + ) + base="http://127.0.0.1:$host_port" + export HOST_SMOKE_BASE_URL="$base" + # Public Azurite development key, not an Azure credential. Override local.settings.json. + export AzureWebJobsStorage="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:$blob_port/devstoreaccount1;QueueEndpoint=http://127.0.0.1:$queue_port/devstoreaccount1;TableEndpoint=http://127.0.0.1:$table_port/devstoreaccount1;" + export FUNCTIONS_WORKER_RUNTIME=python FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT=1 + "${azurite[@]}" --silent --location "$tmp/azurite" \ + --blobHost 127.0.0.1 --queueHost 127.0.0.1 --tableHost 127.0.0.1 \ + --blobPort "$blob_port" --queuePort "$queue_port" --tablePort "$table_port" >"$tmp/azurite.log" 2>&1 & + pids+=("$!") + (cd tests/host_smoke; export PATH="$PWD/.venv/bin:$PATH"; exec func start --port "$host_port" --verbose) >"$tmp/host.log" 2>&1 & + host_pid=$! + pids+=("$host_pid") + echo "Host smoke: $base (Azurite $blob_port/$queue_port/$table_port)" + ready=false + for ((attempt=0; attempt<60; attempt++)); do + if curl --noproxy '*' --fail --silent --max-time 2 "$base/api/custom_name" --output "$tmp/custom.json"; then ready=true; break; fi + kill -0 "$host_pid" 2>/dev/null || break + kill -0 "${pids[0]}" 2>/dev/null || break + sleep 2 + done + [[ "$ready" == true ]] || fail "The custom-name route did not become ready" + "$smoke" -c 'import json, sys; assert json.load(open(sys.argv[1])) == {"function_name": "custom_name"}' "$tmp/custom.json" + curl --noproxy '*' --fail --silent --max-time 10 "$base/api/openapi.json" --output "$tmp/openapi.json" + documented_path=$("$smoke" - "$tmp/openapi.json" <<'PY' +import json +import sys + +with open(sys.argv[1]) as source: + spec = json.load(source) +assert spec["openapi"] +paths = [path for path in spec["paths"] if path not in ("/api/openapi.json", "/api/docs")] +assert paths == ["/api/custom_name"], paths +print(paths[0]) +PY + ) + curl --noproxy '*' --fail --silent --max-time 10 "$base$documented_path" --output "$tmp/documented.json" + "$smoke" -c 'import json, sys; assert json.load(open(sys.argv[1])) == {"function_name": "custom_name"}' "$tmp/documented.json" + curl --noproxy '*' --fail --silent --max-time 10 "$base/api/docs" --output "$tmp/swagger.html" + grep -q 'SwaggerUIBundle' "$tmp/swagger.html" + "$smoke" -m pytest tests/host_smoke -m host_smoke --no-cov -rs + if grep -Eq "0 functions (loaded|found \(Worker\))|No job functions found|Executed 'Functions\..*' \(Failed" "$tmp/host.log"; then + fail "The Functions host reported indexing or invocation errors" + fi + ;; +wheel-smoke) + : "${HATCH:?HATCH must name the Hatch executable}" + "$HATCH" build -t wheel "$tmp/dist" + "$python" -m venv "$tmp/venv" + "$tmp/venv/bin/python" -m pip install --disable-pip-version-check "$tmp"/dist/*.whl + "$tmp/venv/bin/python" -m pip check + (cd "$tmp"; CHECKOUT="$root" "$tmp/venv/bin/python" -I - <<'PY' +import os +from pathlib import Path + +import azure.functions as func +import azure_functions_openapi +from azure_functions_openapi import generate_openapi_spec, openapi, scan_endpoint_metadata + +module_path = Path(azure_functions_openapi.__file__).resolve() +print(f"imported module: {module_path}") +assert not module_path.is_relative_to(Path(os.environ["CHECKOUT"]).resolve()) +app = func.FunctionApp() + +@openapi(summary="Artifact smoke", responses={200: {"description": "OK"}}) +@app.route(route="artifact-smoke", methods=["GET"]) +def artifact_smoke(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("ok") + +scan_endpoint_metadata(app) +spec = generate_openapi_spec() +assert spec["paths"]["/api/artifact-smoke"]["get"] +PY + ) + ;; +*) fail "usage: local-smoke.sh integration|wheel-smoke" ;; +esac diff --git a/tests/test_make_smoke_contract.py b/tests/test_make_smoke_contract.py new file mode 100644 index 00000000..51057e39 --- /dev/null +++ b/tests/test_make_smoke_contract.py @@ -0,0 +1,81 @@ +"""Local smoke targets remain discoverable, ordered and fail-fast (#811).""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import shutil +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MAKE = shutil.which("make") or "make" +LANES = ("check", "test", "integration", "docs", "wheel-smoke") +pytestmark = pytest.mark.skipif(shutil.which("make") is None, reason="requires make") + + +def test_verify_dry_run_lists_lanes_in_order() -> None: + # Given: the repository Makefile, without executing tools or installing anything. + # When + result = subprocess.run( + [MAKE, "--no-print-directory", "-n", "verify", "MAKE=make"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + env={key: value for key, value in os.environ.items() if not key.startswith("MAKE")}, + ) + # Then + assert result.returncode == 0, result.stderr + commands = [line for line in result.stdout.splitlines() if line.startswith("make ")] + assert commands == [f"make {lane}" for lane in LANES] + + +def test_smoke_targets_are_phony_and_visible_in_help() -> None: + # Given + targets = {"integration", "wheel-smoke", "verify"} + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + # When + phony = { + target + for line in makefile.splitlines() + if line.startswith(".PHONY:") + for target in line.partition(":")[2].split() + } + result = subprocess.run( + [MAKE, "--no-print-directory", "help"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + # Then + assert targets <= phony + assert all(f"make {target}" in result.stdout for target in targets) + + +@pytest.mark.parametrize("failed_lane", LANES) +def test_verify_stops_at_the_first_failed_lane(tmp_path: Path, failed_lane: str) -> None: + # Given: real make recursion, replacing only the expensive lane recipes. + source = (ROOT / "Makefile").read_text(encoding="utf-8") + source = re.sub(r"^(check|test|wheel-smoke):.*$", r"\1:", source, flags=re.MULTILINE) + (tmp_path / "Makefile").write_text(source, encoding="utf-8") + with (tmp_path / "Makefile").open("a", encoding="utf-8") as makefile: + makefile.write("\nensure-hatch: ; @:\nbootstrap: ; @:\n") + makefile.write( + "".join(f"{lane}: ; @echo {lane}; exit {int(lane == failed_lane)}\n" for lane in LANES) + ) + # When + result = subprocess.run( + [MAKE, "--no-print-directory", "verify", "MAKE=make"], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + env={key: value for key, value in os.environ.items() if not key.startswith("MAKE")}, + ) + # Then + assert result.returncode != 0 + assert result.stdout.splitlines() == list(LANES[: LANES.index(failed_lane) + 1]), result.stderr From 4beb6af26f60c1ebcbd825107d449ed0f6525035 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Mon, 26 Oct 2026 18:06:53 +0900 Subject: [PATCH 2/3] test(make): read the verify recipe instead of dry-running it --- tests/test_make_smoke_contract.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/test_make_smoke_contract.py b/tests/test_make_smoke_contract.py index 51057e39..9c365432 100644 --- a/tests/test_make_smoke_contract.py +++ b/tests/test_make_smoke_contract.py @@ -16,21 +16,17 @@ pytestmark = pytest.mark.skipif(shutil.which("make") is None, reason="requires make") -def test_verify_dry_run_lists_lanes_in_order() -> None: - # Given: the repository Makefile, without executing tools or installing anything. +def test_verify_recipe_lists_lanes_in_order() -> None: + # Read the recipe instead of running `make -n verify`: make executes any recipe line that + # contains $(MAKE) even under -n, and `docs` would then really run `pip install -e` in CI. + # Given + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") # When - result = subprocess.run( - [MAKE, "--no-print-directory", "-n", "verify", "MAKE=make"], - cwd=ROOT, - capture_output=True, - text=True, - check=False, - env={key: value for key, value in os.environ.items() if not key.startswith("MAKE")}, - ) + match = re.search(r"^verify:[^\n]*\n((?:\t[^\n]*\n)+)", makefile, flags=re.MULTILINE) # Then - assert result.returncode == 0, result.stderr - commands = [line for line in result.stdout.splitlines() if line.startswith("make ")] - assert commands == [f"make {lane}" for lane in LANES] + assert match is not None, "verify target not found" + recipe = [line.strip() for line in match.group(1).splitlines()] + assert recipe == [f"@$(MAKE) {lane}" for lane in LANES] def test_smoke_targets_are_phony_and_visible_in_help() -> None: From dde5eda1789eac2f1d75233618ecec420ad4f1d4 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Mon, 26 Oct 2026 20:17:41 +0900 Subject: [PATCH 3/3] build(make): fail integration on a dead Azurite and clean up on INT/TERM during blocking commands --- scripts/local-smoke.sh | 34 ++++++++++++++---- tests/test_make_smoke_contract.py | 57 +++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/scripts/local-smoke.sh b/scripts/local-smoke.sh index 0d1e02db..904b3fdc 100644 --- a/scripts/local-smoke.sh +++ b/scripts/local-smoke.sh @@ -33,6 +33,23 @@ trap 'exit 143' TERM # Job control gives each service its own process group, including npm/worker descendants. set -m +# Run a blocking command as a tracked background job and wait for it with the builtin `wait`. +# Under job control a foreground command would sit in its own process group, so Bash would defer +# the INT/TERM trap until it finished; waiting on a job lets the trap fire at once and cleanup +# then terminates the command's process group together with the services. +run() { + "$@" & + local job=$! + pids+=("$job") + local status=0 + wait "$job" || status=$? + # Finished: drop it so cleanup never signals a recycled PID. + local kept=() pid + for pid in "${pids[@]}"; do [[ "$pid" == "$job" ]] || kept+=("$pid"); done + pids=("${kept[@]}") + return "$status" +} + case "${1:-}" in integration) for tool in func curl; do command -v "$tool" >/dev/null || fail "$tool is required for integration"; done @@ -64,7 +81,8 @@ PY "${azurite[@]}" --silent --location "$tmp/azurite" \ --blobHost 127.0.0.1 --queueHost 127.0.0.1 --tableHost 127.0.0.1 \ --blobPort "$blob_port" --queuePort "$queue_port" --tablePort "$table_port" >"$tmp/azurite.log" 2>&1 & - pids+=("$!") + azurite_pid=$! + pids+=("$azurite_pid") (cd tests/host_smoke; export PATH="$PWD/.venv/bin:$PATH"; exec func start --port "$host_port" --verbose) >"$tmp/host.log" 2>&1 & host_pid=$! pids+=("$host_pid") @@ -73,10 +91,13 @@ PY for ((attempt=0; attempt<60; attempt++)); do if curl --noproxy '*' --fail --silent --max-time 2 "$base/api/custom_name" --output "$tmp/custom.json"; then ready=true; break; fi kill -0 "$host_pid" 2>/dev/null || break - kill -0 "${pids[0]}" 2>/dev/null || break + kill -0 "$azurite_pid" 2>/dev/null || break sleep 2 done [[ "$ready" == true ]] || fail "The custom-name route did not become ready" + # The routes below are HTTP-only and never touch storage, so a dead Azurite would not fail + # them. Check it explicitly before and after the route tests. + kill -0 "$azurite_pid" 2>/dev/null || fail "Azurite is not running; check $tmp/azurite.log" "$smoke" -c 'import json, sys; assert json.load(open(sys.argv[1])) == {"function_name": "custom_name"}' "$tmp/custom.json" curl --noproxy '*' --fail --silent --max-time 10 "$base/api/openapi.json" --output "$tmp/openapi.json" documented_path=$("$smoke" - "$tmp/openapi.json" <<'PY' @@ -95,17 +116,18 @@ PY "$smoke" -c 'import json, sys; assert json.load(open(sys.argv[1])) == {"function_name": "custom_name"}' "$tmp/documented.json" curl --noproxy '*' --fail --silent --max-time 10 "$base/api/docs" --output "$tmp/swagger.html" grep -q 'SwaggerUIBundle' "$tmp/swagger.html" - "$smoke" -m pytest tests/host_smoke -m host_smoke --no-cov -rs + run "$smoke" -m pytest tests/host_smoke -m host_smoke --no-cov -rs + kill -0 "$azurite_pid" 2>/dev/null || fail "Azurite stopped while the route tests ran; check $tmp/azurite.log" if grep -Eq "0 functions (loaded|found \(Worker\))|No job functions found|Executed 'Functions\..*' \(Failed" "$tmp/host.log"; then fail "The Functions host reported indexing or invocation errors" fi ;; wheel-smoke) : "${HATCH:?HATCH must name the Hatch executable}" - "$HATCH" build -t wheel "$tmp/dist" + run "$HATCH" build -t wheel "$tmp/dist" "$python" -m venv "$tmp/venv" - "$tmp/venv/bin/python" -m pip install --disable-pip-version-check "$tmp"/dist/*.whl - "$tmp/venv/bin/python" -m pip check + run "$tmp/venv/bin/python" -m pip install --disable-pip-version-check "$tmp"/dist/*.whl + run "$tmp/venv/bin/python" -m pip check (cd "$tmp"; CHECKOUT="$root" "$tmp/venv/bin/python" -I - <<'PY' import os from pathlib import Path diff --git a/tests/test_make_smoke_contract.py b/tests/test_make_smoke_contract.py index 9c365432..d5de32cb 100644 --- a/tests/test_make_smoke_contract.py +++ b/tests/test_make_smoke_contract.py @@ -75,3 +75,60 @@ def test_verify_stops_at_the_first_failed_lane(tmp_path: Path, failed_lane: str) # Then assert result.returncode != 0 assert result.stdout.splitlines() == list(LANES[: LANES.index(failed_lane) + 1]), result.stderr + + +SMOKE_SCRIPT = ROOT / "scripts" / "local-smoke.sh" + + +def test_smoke_script_checks_azurite_before_and_after_the_route_tests() -> None: + # The routes are HTTP-only, so a dead Azurite would not fail them. The script must check its + # liveness itself, both before running them and after (#811 review). + # Given + lines = SMOKE_SCRIPT.read_text(encoding="utf-8").splitlines() + checks = [ + i for i, line in enumerate(lines) if 'kill -0 "$azurite_pid"' in line and "fail" in line + ] + pytest_run = next(i for i, line in enumerate(lines) if "-m pytest tests/host_smoke" in line) + # Then + assert any(i < pytest_run for i in checks), "no Azurite liveness check before the route tests" + assert any(i > pytest_run for i in checks), "no Azurite liveness check after the route tests" + + +@pytest.mark.skipif(os.name != "posix", reason="process groups") +def test_term_during_a_blocking_command_cleans_up_promptly(tmp_path: Path) -> None: + # Under job control a foreground command sits in its own process group, so Bash would defer + # the TERM trap until it finished. A hung Hatch must not block cancellation or leak its + # scratch directory. + # Given: a `hatch` that hangs, and a private TMPDIR so only this run's scratch dir is seen. + import signal + import sys + import time + + hatch = tmp_path / "hatch" + hatch.write_text("#!/bin/sh\nexec sleep 300\n", encoding="utf-8") + hatch.chmod(0o755) + scratch = tmp_path / "tmp" + scratch.mkdir() + env = {**os.environ, "PYTHON": sys.executable, "HATCH": str(hatch), "TMPDIR": str(scratch)} + # When + proc = subprocess.Popen( # noqa: S603 + ["bash", str(SMOKE_SCRIPT), "wheel-smoke"], # noqa: S607 + cwd=ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline and not any(scratch.glob("openapi-smoke.*")): + time.sleep(0.1) + time.sleep(0.5) # let the blocking command start + proc.send_signal(signal.SIGTERM) + # Then + assert proc.wait(timeout=10) == 143 + assert not list(scratch.glob("openapi-smoke.*")), "scratch directory was not removed" + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait()