Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/coverage-upload.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Coverage upload

on:
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: [Test-backend]
workflows: [Test]
types: [completed]

permissions: {}
Expand Down
59 changes: 52 additions & 7 deletions .github/workflows/test-backend.yml → .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Test-backend
name: Test

on:
push:
Expand Down Expand Up @@ -31,11 +31,12 @@ jobs:
src:
- .github/workflows/test-backend.yml
- backend/**
- cli/**
- .python-version
- pyproject.toml
- uv.lock

test:
test-backend:
needs:
- changes
if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/master'
Expand Down Expand Up @@ -69,18 +70,62 @@ jobs:
run: uv run --no-sync bash scripts/test-cov.sh
working-directory: backend
env:
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-backend
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}-backend
- name: Store coverage files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ runner.os }}-py${{ matrix.python-version }}
name: coverage-${{ runner.os }}-py${{ matrix.python-version }}-backend
path: backend/coverage
include-hidden-files: true

test-cli:
needs:
- changes
if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/master'
strategy:
matrix:
os: [ ubuntu-latest ]
python-version: [ "3.10", "3.13", "3.14" ]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Setup uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
version: "0.11.7"
enable-cache: true
cache-dependency-glob: |
pyproject.toml
cli/pyproject.toml
uv.lock
- name: Install Dependencies
run: uv sync --project cli
- name: Test
run: uv run --no-sync bash scripts/test-cov.sh
working-directory: cli
env:
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-cli
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}-cli
- name: Store coverage files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ runner.os }}-py${{ matrix.python-version }}-cli
path: cli/coverage
include-hidden-files: true

coverage-combine:
needs:
- test
- test-backend
- test-cli
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down Expand Up @@ -128,4 +173,4 @@ jobs:
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
with:
jobs: ${{ toJSON(needs) }}
allowed-skips: coverage-combine,test
allowed-skips: coverage-combine,test-backend,test-cli
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ wheels/
.venv

.env

# Coverage files
.coverage
3 changes: 3 additions & 0 deletions cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ covered = "covered.cli:app"
[build-system]
requires = ["uv_build>=0.11.7,<0.12.0"]
build-backend = "uv_build"

[tool.pytest.ini_options]
asyncio_mode = "auto"
6 changes: 6 additions & 0 deletions cli/scripts/test-cov-html.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash

set -e
set -x

bash scripts/test-cov.sh --cov-report=term-missing --cov-report=html ${@}
6 changes: 6 additions & 0 deletions cli/scripts/test-cov.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash

set -e
set -x

bash scripts/test.sh --cov --cov-context=test ${@}
6 changes: 6 additions & 0 deletions cli/scripts/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash

set -e
set -x

pytest tests ${@}
33 changes: 33 additions & 0 deletions cli/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import os
from collections.abc import Iterator
from unittest.mock import AsyncMock, patch

import pytest
import stamina
from typer import rich_utils


@pytest.fixture
def mock_main() -> Iterator[AsyncMock]:
with patch("covered.cli._main", AsyncMock(return_value=None)) as m:
yield m


@pytest.fixture(autouse=True)
def _isolate_covered_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""
Clear `COVERED_*` env vars from the parent shell so tests aren't polluted.
"""
for var in [k for k in os.environ if k.startswith("COVERED_")]:
monkeypatch.delenv(var) # pragma: no cover


@pytest.fixture(autouse=True, scope="session")
def _set_stamina_testing():
stamina.set_testing(True, attempts=10, cap=True)


@pytest.fixture(autouse=True, scope="session")
def setup_terminal() -> None:
rich_utils.MAX_WIDTH = 3000
rich_utils.FORCE_TERMINAL = False
17 changes: 17 additions & 0 deletions cli/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import subprocess
import sys

from typer.testing import CliRunner

from covered import cli as mod

runner = CliRunner()


def test_script(): # For coverage (if __name__ == "__main__":)
result = subprocess.run(
[sys.executable, "-m", "coverage", "run", mod.__file__, "--help"],
capture_output=True,
encoding="utf-8",
)
assert "Usage" in result.stdout
175 changes: 175 additions & 0 deletions cli/tests/test_cli_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""
Tests for the Typer `upload` command - argument validation, env-var wiring, and defaults.
"""

from pathlib import Path
from unittest.mock import AsyncMock

from typer.testing import CliRunner

from covered.cli import app

from utils import COMMON_ENV

runner = CliRunner()


def test_upload_rejects_api_url_with_trailing_slash(
mock_main: AsyncMock, tmp_path: Path
):
"""
`--api-url https://x/` (with trailing slash) exits non-zero with a clear message.
"""
env = {**COMMON_ENV, "COVERED_API_URL": "https://api.example.com/"}

result = runner.invoke(app, [str(tmp_path)], env=env)

assert result.exit_code != 0
assert "must not end with a slash" in result.stderr
assert "--api-url" in result.stderr
mock_main.assert_not_awaited()


def test_upload_requires_existing_directory(mock_main: AsyncMock, tmp_path: Path):
"""
A directory argument that does not exist fails Typer's `exists=True` validation.
"""
missing = tmp_path / "does-not-exist"

result = runner.invoke(app, [str(missing)], env=COMMON_ENV)

assert result.exit_code != 0
mock_main.assert_not_awaited()


def test_upload_rejects_file_as_directory_argument(
mock_main: AsyncMock, tmp_path: Path
):
"""
Passing a regular file (not a directory) fails validation because `file_okay=False`.
"""
file_path = tmp_path / "report.html"
file_path.write_text("<html></html>")

result = runner.invoke(app, [str(file_path)], env=COMMON_ENV)

assert result.exit_code != 0
mock_main.assert_not_awaited()


def test_upload_reads_all_options_from_env_vars(mock_main: AsyncMock, tmp_path: Path):
"""
Every `COVERED_*` env var is consumed and forwarded to `_main` as expected kwargs.
"""
env = {
**COMMON_ENV,
"COVERED_COVERAGE_THRESHOLD": "75.5",
"COVERED_PURGE_CACHE": "true",
}

result = runner.invoke(app, [str(tmp_path)], env=env)

assert result.exit_code == 0, result.stderr
kwargs = mock_main.call_args.kwargs
assert kwargs["directory"] == tmp_path
assert kwargs["api_url"] == "https://api.example.com"
assert kwargs["api_key"] == "test_api_key"
assert kwargs["repo_owner"] == "test_owner"
assert kwargs["repo_name"] == "test_repo"
assert kwargs["commit_sha"] == "test_commit_sha"
assert kwargs["gh_token"] == "test_github_token"
assert kwargs["coverage_threshold"] == 75.5
assert kwargs["purge_cache"] is True


def test_upload_cli_flags_override_env_vars(mock_main: AsyncMock, tmp_path: Path):
"""
Explicit `--api-url` (and other flags) take precedence over the corresponding env
vars.
"""
result = runner.invoke(
app,
[str(tmp_path), "--api-url", "https://override.example.com"],
env=COMMON_ENV,
)

assert result.exit_code == 0, result.stderr
assert mock_main.call_args.kwargs["api_url"] == "https://override.example.com"


def test_upload_missing_required_option_fails(mock_main: AsyncMock, tmp_path: Path):
"""
Omitting a required option (e.g. `--api-key` / `COVERED_API_KEY`) exits non-zero
with a clear message.
"""
env = {k: v for k, v in COMMON_ENV.items() if k != "COVERED_API_KEY"}

result = runner.invoke(app, [str(tmp_path)], env=env)

assert result.exit_code != 0
assert "--api-key" in result.stderr
mock_main.assert_not_awaited()


def test_upload_default_concurrency_is_50(mock_main: AsyncMock, tmp_path: Path):
"""
When `--concurrency` is not provided, `_main` receives `concurrency=50`.
"""
result = runner.invoke(app, [str(tmp_path)], env=COMMON_ENV)

assert result.exit_code == 0, result.stderr
assert mock_main.call_args.kwargs["concurrency"] == 50


def test_upload_default_coverage_threshold_is_100(mock_main: AsyncMock, tmp_path: Path):
"""
`--coverage-threshold` defaults to 100.0.
"""
result = runner.invoke(app, [str(tmp_path)], env=COMMON_ENV)

assert result.exit_code == 0, result.stderr
assert mock_main.call_args.kwargs["coverage_threshold"] == 100.0


def test_upload_purge_cache_flag_propagates_true(mock_main: AsyncMock, tmp_path: Path):
"""
`--purge-cache` results in `purge_cache=True` being passed to `_main`.
"""
result = runner.invoke(app, [str(tmp_path), "--purge-cache"], env=COMMON_ENV)

assert result.exit_code == 0, result.stderr
assert mock_main.call_args.kwargs["purge_cache"] is True


def test_upload_no_purge_cache_default_is_false(mock_main: AsyncMock, tmp_path: Path):
"""
Without `--purge-cache` (and no env override), `_main` receives `purge_cache=False`.
"""
result = runner.invoke(app, [str(tmp_path)], env=COMMON_ENV)

assert result.exit_code == 0, result.stderr
assert mock_main.call_args.kwargs["purge_cache"] is False


def test_upload_help_lists_all_options():
"""
`--help` output mentions each documented option - sanity check against accidental
removal.
"""
result = runner.invoke(app, ["--help"], env={"COLUMNS": "200"})

assert result.exit_code == 0
for option in [
"DIRECTORY",
"--api-url",
"--api-key",
"--concurrency",
"--repo-owner",
"--repo-name",
"--commit-sha",
"--coverage-threshold",
"--gh-token",
"--is-default-branch",
"--purge-cache",
]:
assert option in result.output, f"missing {option} in help output"
Loading