From b4a17527d14924c73fc404890775167ff6b16a44 Mon Sep 17 00:00:00 2001 From: wiperp Date: Sat, 22 Aug 2026 18:08:48 +0200 Subject: [PATCH 1/2] docker smoke testing --- .dockerignore | 12 +++-- .github/workflows/ci.yml | 9 ++++ .pre-commit-config.yaml | 39 +++------------- CONTRIBUTING.md | 20 ++++++-- Dockerfile | 6 ++- scripts/docker_smoke.py | 98 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 40 deletions(-) create mode 100644 scripts/docker_smoke.py diff --git a/.dockerignore b/.dockerignore index 9cee37b..bf7a73a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,12 +8,18 @@ __pycache__ *$py.class *.so .Python -.venv +.venv* venv/ env/ ENV/ -.mypy_cache -.pytest_cache +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.uv-cache/ +.test-tmp/ +.coverage +htmlcov/ +test-results/ *.egg-info dist/ build/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8eab71..7821a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,15 @@ jobs: - name: Build package run: uv build + docker: + name: Docker / Build and HTTP smoke test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + - name: Build image and test application response + run: python3 scripts/docker_smoke.py + browser-tests: name: E2E / Selection / ${{ matrix.browser }} runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8fe6318..ad7ee28 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,62 +1,35 @@ minimum_pre_commit_version: "4.0.0" default_install_hook_types: [pre-commit] -# These local hooks intentionally mirror .github/workflows/ci.yml. They run -# against the whole repository so documentation-only commits cannot bypass CI. +# Keep the commit-time feedback fast. Package, Docker, and browser checks run +# in CI and remain available as explicit local commands. repos: - repo: local hooks: - id: ruff-lint - name: CI / Ruff lint + name: Local / Ruff lint entry: uv run --locked --extra dev ruff check . language: system pass_filenames: false always_run: true - id: ruff-format - name: CI / Ruff format + name: Local / Ruff format entry: uv run --locked --extra dev ruff format --check . language: system pass_filenames: false always_run: true - id: mypy - name: CI / mypy + name: Local / mypy entry: uv run --locked --extra dev mypy --no-incremental src/spanselector language: system pass_filenames: false always_run: true - id: pytest - name: CI / pytest + name: Local / pytest entry: uv run --locked --extra dev pytest language: system pass_filenames: false always_run: true - - - id: build - name: CI / package build - entry: uv build - language: system - pass_filenames: false - always_run: true - - - id: e2e-chromium - name: CI / Playwright Chromium - entry: >- - uv run --locked --extra dev --extra e2e pytest tests/e2e --run-e2e - --browser chromium --no-cov --tracing retain-on-failure - --screenshot only-on-failure - language: system - pass_filenames: false - always_run: true - - - id: e2e-firefox - name: CI / Playwright Firefox - entry: >- - uv run --locked --extra dev --extra e2e pytest tests/e2e --run-e2e - --browser firefox --no-cov --tracing retain-on-failure - --screenshot only-on-failure - language: system - pass_filenames: false - always_run: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4ab194..30c8231 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,14 +14,18 @@ uv run spanselector ## Before opening a pull request -Every commit runs the same lint, format, type, test, build, and browser checks as CI. To run the -complete gate without creating a commit: +Every commit runs the fast lint, format, type, and unit-test checks. To run the commit gate without +creating a commit: ```bash uv run pre-commit run --all-files ``` -The individual commands are: +Pull-request CI also builds the package, builds and smoke-tests the Docker image, and runs the +end-to-end suite in Chromium and Firefox. These heavier checks stay out of pre-commit so commits do +not require a running Docker engine or open browser processes. + +The fast individual commands are: ```bash uv run ruff format --check . @@ -30,6 +34,16 @@ uv run mypy src/spanselector uv run pytest ``` +The package and Docker checks can still be run locally when relevant: + +```bash +uv build +uv run python scripts/docker_smoke.py +``` + +The Docker smoke test requires a running Docker-compatible engine. It reports an actionable error +when the engine is unavailable and does not attempt any platform-specific startup command. + For changes to browser interactions, install the optional end-to-end dependencies and run the same pointer interaction in both supported browser engines: diff --git a/Dockerfile b/Dockerfile index e72497d..cea3121 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,8 @@ ENV UV_COMPILE_BYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ HOST=0.0.0.0 \ - PORT=8080 + PORT=8080 \ + SHOW=false WORKDIR /app @@ -20,4 +21,7 @@ USER appuser EXPOSE 8080 +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ + CMD ["/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/', timeout=2)"] + CMD ["/app/.venv/bin/spanselector"] diff --git a/scripts/docker_smoke.py b/scripts/docker_smoke.py new file mode 100644 index 0000000..4b17c31 --- /dev/null +++ b/scripts/docker_smoke.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import socket +import subprocess +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +IMAGE_TAG = "spanselector:smoke" +PROJECT_ROOT = Path(__file__).resolve().parents[1] +STARTUP_TIMEOUT_SECONDS = 30 + + +def _available_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _docker(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["docker", *args], + check=check, + cwd=PROJECT_ROOT, + text=True, + ) + + +def _require_docker_daemon() -> None: + try: + result = subprocess.run( + ["docker", "info"], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + raise SystemExit("Docker is not installed or is not available on PATH.") from None + + if result.returncode == 0: + return + + detail = f"{result.stderr}\n{result.stdout}".strip().splitlines() + connection_errors = [line for line in detail if "error during connect" in line.lower()] + reason = connection_errors[-1] if connection_errors else "Docker did not report a server." + raise SystemExit( + "Docker daemon is unavailable. Start your Docker engine or compatible Docker API " + f"provider, wait until it is ready, and retry.\nDocker said: {reason}" + ) + + +def _wait_for_app(url: str) -> None: + deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as response: + body = response.read().decode("utf-8", errors="replace") + if response.status == 200 and "spanselector" in body.lower(): + return + except (OSError, urllib.error.URLError) as error: + last_error = error + time.sleep(0.25) + raise RuntimeError(f"Container did not serve spanselector at {url}: {last_error}") + + +def main() -> None: + _require_docker_daemon() + container_name = f"spanselector-smoke-{uuid.uuid4().hex[:12]}" + host_port = _available_port() + url = f"http://127.0.0.1:{host_port}/" + + _docker("build", "--tag", IMAGE_TAG, ".") + try: + _docker( + "run", + "--detach", + "--name", + container_name, + "--publish", + f"127.0.0.1:{host_port}:8080", + "--env", + "SHOW=false", + IMAGE_TAG, + ) + _wait_for_app(url) + print(f"Docker smoke test passed: {url}") + except Exception: + _docker("logs", container_name, check=False) + raise + finally: + _docker("rm", "--force", container_name, check=False) + + +if __name__ == "__main__": + main() From f42270378e3ca3e0e18ed75b6c77c39f078edf7f Mon Sep 17 00:00:00 2001 From: wiperp Date: Sat, 22 Aug 2026 18:17:27 +0200 Subject: [PATCH 2/2] pr template --- .github/PULL_REQUEST_TEMPLATE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..65cc2e6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Summary + + + +## Testing + + + +## Screenshots + +