diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index b978ab1..f468d7d 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -1,4 +1,4 @@ -name: CI +name: E2E tests on: push: @@ -35,5 +35,7 @@ jobs: - name: Install dependencies run: uv sync - - name: Run tests with pytest - run: uv run pytest + # Coverage is skipped: the tests run the mkcd2app executable in a subprocess, + # which coverage doesn't measure + - name: Run e2e tests + run: uv run pytest -m e2e --no-cov diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml new file mode 100644 index 0000000..7a6b041 --- /dev/null +++ b/.github/workflows/unit.yaml @@ -0,0 +1,35 @@ +name: Unit tests + +on: + push: + branches: [ main ] + # Any base branch, so stacked PRs get CI too + pull_request: + +jobs: + unit: + strategy: + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + version: "latest" + + - name: Set up Python + run: uv python install 3.14 + + - name: Install dependencies + run: uv sync + + # Offline and fast; no Node.js or toolchain install needed + - name: Run unit tests with coverage + run: uv run pytest -m unit diff --git a/.gitignore b/.gitignore index dbe7f58..dc19d7a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ examples/* !examples/*.yaml !examples/*.png .idea +.coverage +.coverage.* diff --git a/CLAUDE.md b/CLAUDE.md index f29a8f9..70a9a3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,11 +17,17 @@ uv sync # install deps + the mkcd2app entry point uv run ruff check . # lint uv run ruff format --check . # format check (drop --check to apply) uv run mypy . # strict type check -uv run pytest # all tests +uv run pytest # all tests, with a coverage report +uv run pytest -m unit # only the fast offline tests (tests/unit) +uv run pytest -m e2e --no-cov # only the e2e tests (tests/e2e) +uv run pytest --no-cov # skip coverage (e.g. when using a debugger) uv run pytest tests/e2e/test_statics.py::test_empty_game_build_to_statics_static ``` -CI (`lint.yaml`, `e2e.yaml`) runs exactly these lint/format/mypy/pytest commands. +CI runs exactly these commands: `lint.yaml` the lint/format/mypy checks, and +`unit.yaml` (`-m unit`) and `e2e.yaml` (`-m e2e --no-cov`) the tests, independently, on +ubuntu/windows/macos. Every test must be under `tests/unit` or `tests/e2e`, or +collection fails, since a test in neither would run in neither workflow. pre-commit runs ruff check, ruff format --check, and mypy on staged Python files. Running the tool end to end (installs must happen first, in this order): @@ -32,6 +38,16 @@ uv run mkcd2app target install 4.0.14 uv run mkcd2app --debug build "examples/Racers.yaml" ``` +Notes on the unit tests: +- `tests/conftest.py` marks every test `unit` or `e2e` by its directory, and holds + the `mkcd2app_home` fixture (points `MKCD2APP_HOME` at a temp dir). Keep it the only + `conftest.py`: mypy fails on two modules both named `conftest`. +- Shared helpers (`make_config_yaml`, `FakeRunCmd`, `capture_logger`, ...) are in + `tests/unit/helpers.py`, imported as `from helpers import ...`. +- Call a redun task's plain function with `.func(...)`, and monkeypatch `run_cmd` in + the module under test, so no `npm`/`mkc` runs. +- Our loggers set `propagate = False`, so `caplog` needs `capture_logger` to see them. + Notes on the e2e tests: - Run pytest from the repo root: tests use `Path.cwd()` and repo-relative paths, and shell out to the installed `mkcd2app` executable rather than importing it. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 5725d8e..08ac0a8 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -4,7 +4,8 @@ _Verified against `toolchain` @ `d7ad64a` plus the `MKCD2APP_HOME` change, the small known-issue fixes, and the separate deps-install task, and `origin/main` @ `d31e2a5`, as of 2026-10-29. External program requirements audited against `toolchain` @ `4d518d9` on 2026-11-03, then updated for the Dulwich git replacement and the generic `git` code source the same day, -and for the `file` code source (PNG/UF2 with embedded project, then ZIPs) the same day._ +and for the `file` code source (PNG/UF2 with embedded project, then ZIPs) the same day. +Test suite section updated for the unit test expansion on 2026-11-03._ ## Goal Rewrite `UnsignedArduino/MakeCode-Arcade-to-App` (converts MakeCode Arcade games into a @@ -36,8 +37,8 @@ main (legacy) ← PR #6 `dag` (draft) ← PR #7 `toolchain` (draft, base = d | `utils/` | `run_cmd`, paths (platformdirs), `run_redun_task`, `rmtree_robust`, semver, `git` (Dulwich clone/checkout) | | `resources/` | Vite+React+TS template, `mkc` js_tools, empty fixture project | -Other: `tests/e2e/test_statics.py`, `tests/unit/` (config code sources, `utils/git`, PNG/UF2/ZIP file source), `tests/fixtures/`, `examples/` (Racers), CI -workflows `lint.yaml` (ubuntu) and `e2e.yaml` (ubuntu/windows/macos). Tooling: uv, +Other: `tests/e2e/test_statics.py`, `tests/unit/` (config code sources, `utils/git`, PNG/UF2/ZIP file source, code/website tasks, redun patches, utils), `tests/fixtures/`, `examples/` (Racers), CI +workflows `lint.yaml` (ubuntu), `unit.yaml` and `e2e.yaml` (both ubuntu/windows/macos). Tooling: uv, hatchling, Python ≥3.14, ruff + mypy strict, pre-commit. ## Architecture @@ -57,8 +58,8 @@ hatchling, Python ≥3.14, ruff + mypy strict, pre-commit. `build_website*` task copies that `node_modules` into its own copy of the filled site and runs only `npm run build*`. - **redun patches** (`_redun_compat.py`): `get_proto` (Windows drive letters), - `ContentDir._calc_hash` and `Dir._calc_hash` (hash file contents, not mtimes). No - tests cover them yet. + `ContentDir._calc_hash` and `Dir._calc_hash` (hash file contents, not mtimes). + Covered by `tests/unit/test_redun_compat.py`. ## External programs `run_cmd` only ever runs `npm` and `mkc`: - **Node.js ≥ 22.12 + npm, on PATH. Always required.** The floor comes from @@ -120,8 +121,10 @@ configs; `fetch_code` handles both in one `case`. logs to stderr and requires `DONE == 0` and `CACHED == TOTAL` on an unchanged rebuild; this depends on redun's log format, which is safe while redun is pinned. Nothing yet tests that changing an input does trigger a rebuild. -- **CI** (`lint.yaml`, `e2e.yaml`) runs on pushes to `main` and on PRs into any branch, - so the stacked PRs get CI. Lint and all three e2e jobs are green on PR #7. +- **CI** (`lint.yaml`, `unit.yaml`, `e2e.yaml`) runs on pushes to `main` and on PRs + into any branch, so the stacked PRs get CI. Lint and all three e2e jobs are green on + PR #7. `unit.yaml` runs `pytest -m unit` (no Node.js), and `e2e.yaml` runs + `pytest -m e2e --no-cov`; neither waits for the other. - `tests/unit` covers config parsing of the `git`/`github` sources and `clone_and_checkout` against a local repo built with Dulwich (default branch, branch, tag, short/full SHA, the same-tree-as-HEAD regression, unknown ref), so it runs @@ -138,14 +141,26 @@ configs; `fetch_code` handles both in one `case`. `empty_game_file_source.yaml`, and a ZIP of `tests/fixtures/empty_game` made at test time with a GitHub-style `empty_game-master/` wrapper (checked byte for byte against the checkout, so CRLF on Windows is fine). The duck builds end to end on 4.1.27, and on 4.0.14 - with the version-mismatch warning, but isn't in e2e. Nothing covers the redun - patches yet. + with the version-mismatch warning, but isn't in e2e. +- `test_code.py` and `test_website.py` run every code-input and website task through + `.func` with `run_cmd` (and `clone_and_checkout`, `requests.get`, + `get_sim_html_path`) monkeypatched, against a copy of the real bundled template. + `test_redun_compat.py` checks the `get_proto` patch and that directory hashes + ignore mtimes but change with content, file adds/removes/renames, and path. + `utils/` (`paths`, `run`, `filesystem`, `resources`) and `extract_meta_comment` have + their own small tests. Untested: `cli.py`, `main.py`, the `build_project` DAG + wiring, toolchain/target install tasks, and `is_strict_semver`. +- pytest config is in `pyproject.toml`: coverage on by default (`--no-cov` to skip), + `--strict-markers`, and `unit`/`e2e` markers applied by `tests/conftest.py`. Unit + tests cover about 66% of `src/mkcd2app` (run `uv run pytest -m unit`). ## Roadmap 1. ~~Fix e2e + CI~~ (done: fixture `target:`, isolated install fixture, caching on, job-status cache assertion, CI on all PRs). 2. ~~Rename `--clear-cache`~~ (done: now `--full-rebuild`, and its help text says to use it for out-of-band input changes). Still belongs in the README once that exists. -3. More unit/integration tests, including the redun patches (`git` and `file` sources are covered). +3. More unit/integration tests: `cli.py`/`main.py`, `build_project` wiring with + `run_cmd` stubbed, and the toolchain/target install tasks (redun patches, code and + website tasks, and utils are covered). 4. Electron, then Tauri (reference: `main`). 5. Save-state for Electron/Tauri (investigate `pxtsim` postMessage). 6. Toolchain polish: `status`/`list`, `build` pre-flight, extension cache, version-pinning diff --git a/pyproject.toml b/pyproject.toml index 3730fd5..eb911d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dev = [ "mypy>=2.3.0", "pre-commit>=4.6.1", "pytest>=9.1.1", + "pytest-cov>=7.1.0", "pytest-timeout>=2.4.0", "ruff>=0.16.0", "types-pyyaml>=6.0.12.20260724", @@ -42,3 +43,16 @@ target-version = "py314" [tool.mypy] python_version = "3.14" strict = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +# Coverage is on by default; pass --no-cov to turn it off (e.g. when debugging) +addopts = ["--strict-markers", "--cov", "--cov-report=term-missing"] +# Applied by directory in tests/conftest.py, so tests don't need to mark themselves +markers = [ + "unit: fast, offline tests under tests/unit", + "e2e: slow tests under tests/e2e that install the toolchain and build real games", +] + +[tool.coverage.run] +source = ["mkcd2app"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c9422b2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +from pathlib import Path + +import pytest + +TESTS_DIR = Path(__file__).parent + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """ + Mark each test `unit` or `e2e` by its directory, so `-m unit` selects them. + + CI runs `-m unit` and `-m e2e` as separate workflows, so a test outside both + directories would run in neither; fail collection instead of skipping it quietly. + """ + for item in items: + parts = item.path.relative_to(TESTS_DIR).parts + if len(parts) < 2 or parts[0] not in ("unit", "e2e"): + raise pytest.UsageError( + f"{item.nodeid}: tests must live under tests/unit or tests/e2e" + ) + item.add_marker(parts[0]) + + +@pytest.fixture +def mkcd2app_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point MKCD2APP_HOME at a temp dir, so the test can't touch the real user dirs.""" + home = tmp_path / "mkcd2app-home" + monkeypatch.setenv("MKCD2APP_HOME", str(home)) + return home diff --git a/tests/unit/helpers.py b/tests/unit/helpers.py new file mode 100644 index 0000000..4d27e99 --- /dev/null +++ b/tests/unit/helpers.py @@ -0,0 +1,109 @@ +"""Shared helpers for unit tests (a plain module, since pytest won't collect it).""" + +import hashlib +import logging +import os +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest +import yaml + +TARGET_VERSION = "4.0.14" + + +def make_config_yaml( + build_dir: Path, + code: dict[str, Any] | None = None, + icon: dict[str, Any] | None = None, + project: dict[str, Any] | None = None, + target: str = TARGET_VERSION, +) -> str: + """ + Build a version 1 config as YAML text, the form the redun tasks take. + + :param build_dir: The config's build_dir. + :param code: The inputs.code source. Defaults to a path source that doesn't exist, + for tasks that never read it. + :param icon: The inputs.assets.icon source, or None for no icon. + :param project: Keys to override in the project section. + :param target: The MakeCode Arcade target version. + """ + assets: dict[str, Any] = {"icon": icon} if icon else {} + return yaml.safe_dump( + { + "version": 1, + "project": { + "name": "Test Game", + "path_friendly_name": "test", + "description": "A test game", + "author": "Tester", + "version": "1.2.3", + **(project or {}), + }, + "inputs": { + "code": code or {"type": "path", "value": str(build_dir / "nowhere")}, + "assets": assets, + }, + "build_dir": str(build_dir), + "target": target, + "outputs": [{"type": "static"}], + } + ) + + +@dataclass +class CmdCall: + command: list[str] + cwd: Path + which_path: str | os.PathLike[str] | None + + +class FakeRunCmd: + """ + Stands in for ``run_cmd``: records each call, then runs ``on_run`` (if given) so + the test can create the files the real command would have. + """ + + def __init__(self, on_run: Callable[[CmdCall], None] | None = None) -> None: + self.calls: list[CmdCall] = [] + self.on_run = on_run + + def __call__( + self, + command: list[str], + cwd: Path | str, + which_path: str | os.PathLike[str] | None = None, + ) -> str: + call = CmdCall(command, Path(cwd), which_path) + self.calls.append(call) + if self.on_run: + self.on_run(call) + return "" + + +def tree_digest(root: Path) -> dict[str, str]: + """Map each file under ``root`` (relative POSIX path) to its SHA-256.""" + return { + p.relative_to(root).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(root.rglob("*")) + if p.is_file() + } + + +@contextmanager +def capture_logger( + caplog: pytest.LogCaptureFixture, logger: logging.Logger +) -> Iterator[pytest.LogCaptureFixture]: + """ + Capture records from one of our loggers. ``create_logger`` sets + ``propagate = False``, so caplog's root handler never sees them otherwise. + """ + logger.addHandler(caplog.handler) + try: + yield caplog + finally: + logger.removeHandler(caplog.handler) diff --git a/tests/unit/test_code.py b/tests/unit/test_code.py new file mode 100644 index 0000000..7193288 --- /dev/null +++ b/tests/unit/test_code.py @@ -0,0 +1,324 @@ +import json +import logging +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import pytest +import requests +from helpers import ( + TARGET_VERSION, + CmdCall, + FakeRunCmd, + capture_logger, + make_config_yaml, + tree_digest, +) +from PIL import Image +from redun.file import ContentDir, ContentFile + +from mkcd2app.build_project.inputs import code +from mkcd2app.build_project.inputs.code import ( + _warn_on_target_mismatch, + build_binary_js, + copy_support_files, + fetch_code, +) +from mkcd2app.utils.paths import get_js_tools_bin_dir + +FIXTURES = Path(__file__).parent.parent / "fixtures" +EMPTY_GAME_DIR = FIXTURES / "empty_game" +EMPTY_PNG = FIXTURES / "embedded" / "empty-game.png" # saved with 4.0.14 +DUCK_PNG = FIXTURES / "embedded" / "falling-duck.png" # saved with 4.1.27 +MKC_JSON = {"targetWebsite": f"https://arcade.makecode.com/v{TARGET_VERSION}"} + + +def read_mkc_json(code_path: Path) -> Any: + return json.loads((code_path / "mkc.json").read_text(encoding="utf-8")) + + +def run_fetch_code(build_dir: Path, source: dict[str, Any]) -> Path: + result: ContentDir = fetch_code.func(make_config_yaml(build_dir, code=source)) + return Path(result.path) + + +# ------------------------------------------------------------ fetch_code ------ + + +def test_path_source_copies_project(tmp_path: Path) -> None: + code_path = run_fetch_code( + tmp_path / "build", {"type": "path", "value": str(EMPTY_GAME_DIR)} + ) + assert code_path == tmp_path / "build" / "test-source" + copied = tree_digest(code_path) + assert copied.pop("mkc.json") + assert copied == tree_digest(EMPTY_GAME_DIR) + assert read_mkc_json(code_path) == MKC_JSON + + +def test_fetch_code_replaces_previous_source(tmp_path: Path) -> None: + stale = tmp_path / "build" / "test-source" / "stale.ts" + stale.parent.mkdir(parents=True) + stale.write_text("old", encoding="utf-8") + run_fetch_code(tmp_path / "build", {"type": "path", "value": str(EMPTY_GAME_DIR)}) + assert not stale.exists() + + +def test_mkc_json_uses_config_target(tmp_path: Path) -> None: + result: ContentDir = fetch_code.func( + make_config_yaml( + tmp_path / "build", + code={"type": "path", "value": str(EMPTY_GAME_DIR)}, + target="4.1.27", + ) + ) + assert read_mkc_json(Path(result.path)) == { + "targetWebsite": "https://arcade.makecode.com/v4.1.27" + } + + +def test_share_link_source_runs_mkc_download( + tmp_path: Path, mkcd2app_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + fake = FakeRunCmd() + monkeypatch.setattr(code, "run_cmd", fake) + url = "https://arcade.makecode.com/S12345-67890-12345-67890" + + code_path = run_fetch_code(tmp_path / "build", {"type": "share_link", "value": url}) + + assert len(fake.calls) == 1 + call = fake.calls[0] + assert call.command == ["mkc", "download", url] + assert call.cwd == code_path + assert call.which_path == get_js_tools_bin_dir() + assert read_mkc_json(code_path) == MKC_JSON + + +@pytest.mark.parametrize( + ("source", "expected_ref"), + [ + ({"type": "git", "url": "https://example.com/a/b.git", "ref": "v1"}, "v1"), + ({"type": "git", "url": "https://example.com/a/b.git"}, None), + ({"type": "github", "value": "https://github.com/a/b", "checkout": "dev"}, "dev"), + ({"type": "github", "value": "https://github.com/a/b"}, "master"), + ], +) # fmt: skip +def test_git_sources_clone( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + source: dict[str, str], + expected_ref: str | None, +) -> None: + # Cloning itself is covered by test_git.py; this checks fetch_code's dispatch + calls: list[tuple[str, Path, str | None]] = [] + + def fake_clone(url: str, path: Path, ref: str | None = None) -> None: + # The parent must exist, and the clone target must not + assert path.parent.is_dir() + assert not path.exists() + calls.append((url, path, ref)) + path.mkdir() + + monkeypatch.setattr(code, "clone_and_checkout", fake_clone) + code_path = run_fetch_code(tmp_path / "build", source) + + url = source.get("url") or source["value"] + assert calls == [(url, code_path, expected_ref)] + assert read_mkc_json(code_path) == MKC_JSON + + +def test_file_source_extracts_project(tmp_path: Path) -> None: + code_path = run_fetch_code( + tmp_path / "build", {"type": "file", "value": str(EMPTY_PNG)} + ) + assert (code_path / "pxt.json").is_file() + assert (code_path / "main.ts").is_file() + assert read_mkc_json(code_path) == MKC_JSON + + +def test_file_source_missing_file(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="nope.png"): + run_fetch_code( + tmp_path / "build", {"type": "file", "value": str(tmp_path / "nope.png")} + ) + + +@pytest.mark.parametrize(("png", "warns"), [(EMPTY_PNG, False), (DUCK_PNG, True)]) +def test_file_source_warns_on_target_mismatch( + tmp_path: Path, caplog: pytest.LogCaptureFixture, png: Path, warns: bool +) -> None: + with capture_logger(caplog, code.logger): + run_fetch_code(tmp_path / "build", {"type": "file", "value": str(png)}) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + if warns: + assert [r.getMessage() for r in warnings] == [ + f"Project was saved with Arcade 4.1.27, building against {TARGET_VERSION}" + ] + else: + assert warnings == [] + + +# ---------------------------------------------- _warn_on_target_mismatch ------ + + +@pytest.mark.parametrize( + "pxt_json", + [ + None, # file missing + "not json", + "[]", + "{}", + '{"targetVersions": []}', + '{"targetVersions": {"targetId": "arcade"}}', + f'{{"targetVersions": {{"target": "{TARGET_VERSION}"}}}}', + ], +) +def test_target_mismatch_no_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture, pxt_json: str | None +) -> None: + path = tmp_path / "pxt.json" + if pxt_json is not None: + path.write_text(pxt_json, encoding="utf-8") + with capture_logger(caplog, code.logger): + _warn_on_target_mismatch(path, TARGET_VERSION) + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_target_mismatch_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + path = tmp_path / "pxt.json" + path.write_text(json.dumps({"targetVersions": {"target": "2.0.48"}}), "utf-8") + with capture_logger(caplog, code.logger): + _warn_on_target_mismatch(path, TARGET_VERSION) + assert [(r.levelno, r.getMessage()) for r in caplog.records] == [ + ( + logging.WARNING, + f"Project was saved with Arcade 2.0.48, building against {TARGET_VERSION}", + ) + ] + + +# ------------------------------------------------------- build_binary_js ------ + + +def test_build_binary_js( + tmp_path: Path, mkcd2app_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + code_path = tmp_path / "build" / "test-source" + code_path.mkdir(parents=True) + (code_path / "main.ts").write_text("// game", encoding="utf-8") + before = tree_digest(code_path) + + def fake_mkc_build(call: CmdCall) -> None: + (call.cwd / "built").mkdir() + (call.cwd / "built" / "binary.js").write_text("// built", encoding="utf-8") + (call.cwd / "built" / "other.txt").write_text("x", encoding="utf-8") + + fake = FakeRunCmd(fake_mkc_build) + monkeypatch.setattr(code, "run_cmd", fake) + + result: ContentFile = build_binary_js.func( + make_config_yaml(tmp_path / "build"), ContentDir(str(code_path)) + ) + + assert [(c.command, c.cwd, c.which_path) for c in fake.calls] == [ + (["mkc", "build", "-j"], code_path, get_js_tools_bin_dir()) + ] + output = tmp_path / "build" / "test-binary-js" / "binary.js" + assert Path(result.path) == output + assert output.read_text(encoding="utf-8") == "// built" + # built/ is removed so fetch_code's ContentDir hash doesn't change + assert tree_digest(code_path) == before + + +# ---------------------------------------------------- copy_support_files ------ + + +@pytest.fixture +def sim_html(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[Path]: + path = tmp_path / "target" / TARGET_VERSION / "---simulator.html" + path.parent.mkdir(parents=True) + path.write_text("sim", encoding="utf-8") + requested: list[str] = [] + + def fake_get_sim_html_path(v: str) -> Path: + requested.append(v) + return path + + monkeypatch.setattr(code, "get_sim_html_path", fake_get_sim_html_path) + yield path + assert requested == [TARGET_VERSION] + + +def make_png(path: Path, size: tuple[int, int] = (32, 32)) -> Path: + Image.new("RGBA", size, (255, 0, 0, 255)).save(path) + return path + + +def run_copy_support_files(build_dir: Path, icon: dict[str, str] | None) -> Path: + result: ContentDir = copy_support_files.func(make_config_yaml(build_dir, icon=icon)) + return Path(result.path) + + +def test_support_files_without_icon(tmp_path: Path, sim_html: Path) -> None: + support = run_copy_support_files(tmp_path / "build", None) + assert support == tmp_path / "build" / "test-binary-js-support" + assert sorted(p.name for p in support.iterdir()) == ["---simulator.html"] + assert (support / "---simulator.html").read_bytes() == sim_html.read_bytes() + + +def test_support_files_replaces_previous_output(tmp_path: Path, sim_html: Path) -> None: + stale = tmp_path / "build" / "test-binary-js-support" / "favicon.ico" + stale.parent.mkdir(parents=True) + stale.write_bytes(b"old icon") + support = run_copy_support_files(tmp_path / "build", None) + assert not (support / "favicon.ico").exists() + + +def test_support_files_icon_from_path(tmp_path: Path, sim_html: Path) -> None: + icon = make_png(tmp_path / "icon.png") + support = run_copy_support_files( + tmp_path / "build", {"type": "path", "value": str(icon)} + ) + with Image.open(support / "favicon.ico") as im: + assert im.format == "ICO" + + +class FakeResponse: + def __init__(self, content: bytes, status_code: int = 200) -> None: + self.content = content + self.status_code = status_code + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise requests.HTTPError(f"{self.status_code} error") + + +def test_support_files_icon_from_url( + tmp_path: Path, sim_html: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + png = make_png(tmp_path / "icon.png").read_bytes() + urls: list[str] = [] + + def fake_get(url: str) -> FakeResponse: + urls.append(url) + return FakeResponse(png) + + monkeypatch.setattr(requests, "get", fake_get) + support = run_copy_support_files( + tmp_path / "build", {"type": "url", "value": "https://example.com/icon.png"} + ) + assert urls == ["https://example.com/icon.png"] + with Image.open(support / "favicon.ico") as im: + assert im.format == "ICO" + + +def test_support_files_icon_url_http_error( + tmp_path: Path, sim_html: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(requests, "get", lambda url: FakeResponse(b"", 404)) + with pytest.raises(requests.HTTPError): + run_copy_support_files( + tmp_path / "build", {"type": "url", "value": "https://example.com/x.png"} + ) diff --git a/tests/unit/test_filesystem.py b/tests/unit/test_filesystem.py new file mode 100644 index 0000000..21bc110 --- /dev/null +++ b/tests/unit/test_filesystem.py @@ -0,0 +1,28 @@ +import os +import stat +import sys +from pathlib import Path + +from mkcd2app.utils.filesystem import _remove_readonly, rmtree_robust + + +def make_readonly_tree(root: Path) -> Path: + (root / "objects" / "ab").mkdir(parents=True) + readonly = root / "objects" / "ab" / "cdef" + readonly.write_bytes(b"git object") + os.chmod(readonly, stat.S_IREAD) + return readonly + + +def test_removes_tree_with_readonly_files(tmp_path: Path) -> None: + root = tmp_path / ".git" + make_readonly_tree(root) + rmtree_robust(root) + assert not root.exists() + + +def test_remove_readonly_handler_clears_bit_and_retries(tmp_path: Path) -> None: + # Called directly, since only Windows refuses to unlink read-only files + readonly = make_readonly_tree(tmp_path) + _remove_readonly(os.unlink, str(readonly), sys.exc_info()) # type: ignore[arg-type] + assert not readonly.exists() diff --git a/tests/unit/test_meta_comment.py b/tests/unit/test_meta_comment.py new file mode 100644 index 0000000..d53c3b3 --- /dev/null +++ b/tests/unit/test_meta_comment.py @@ -0,0 +1,72 @@ +import json +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError + +from mkcd2app.utils.text import extract_meta_comment + +# Copied from a real `mkc build -j` of the empty game on 4.0.14 +META: dict[str, Any] = { + "simUrl": "https://trg-arcade.userpxt.io/---simulator", + "cdnUrl": "https://cdn.makecode.com", + "version": "v0.0.0", + "target": "arcade", + "targetVersion": "4.0.14", +} + + +def write_binary_js(tmp_path: Path, *lines: str) -> Path: + path = tmp_path / "binary.js" + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def test_extracts_metadata(tmp_path: Path) -> None: + path = write_binary_js( + tmp_path, "// start", f"// meta={json.dumps(META)}", "var x = 1;" + ) + metadata = extract_meta_comment(path) + assert str(metadata.simUrl) == META["simUrl"] + assert metadata.target == "arcade" + assert metadata.targetVersion == "4.0.14" + + +def test_indented_comment(tmp_path: Path) -> None: + path = write_binary_js(tmp_path, f" // meta={json.dumps(META)} ") + assert extract_meta_comment(path).targetVersion == "4.0.14" + + +def test_first_meta_comment_wins(tmp_path: Path) -> None: + other = {**META, "targetVersion": "9.9.9"} + path = write_binary_js( + tmp_path, f"// meta={json.dumps(META)}", f"// meta={json.dumps(other)}" + ) + assert extract_meta_comment(path).targetVersion == "4.0.14" + + +def test_missing_comment(tmp_path: Path) -> None: + path = write_binary_js(tmp_path, "// no metadata here", "var meta = {};") + with pytest.raises(ValueError, match="Meta comment not found"): + extract_meta_comment(path) + + +@pytest.mark.parametrize( + "meta", + [ + {**META, "unexpected": 1}, # extra="forbid" + {k: v for k, v in META.items() if k != "simUrl"}, + {**META, "cdnUrl": "not a url"}, + ], +) +def test_invalid_metadata(tmp_path: Path, meta: dict[str, Any]) -> None: + path = write_binary_js(tmp_path, f"// meta={json.dumps(meta)}") + with pytest.raises(ValidationError): + extract_meta_comment(path) + + +def test_invalid_json(tmp_path: Path) -> None: + path = write_binary_js(tmp_path, "// meta={not json") + with pytest.raises(ValidationError): + extract_meta_comment(path) diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py new file mode 100644 index 0000000..2ff585e --- /dev/null +++ b/tests/unit/test_paths.py @@ -0,0 +1,60 @@ +from collections.abc import Callable +from importlib.metadata import version +from pathlib import Path + +import pytest + +from mkcd2app.utils import paths + +VERSION = version("mkcd2app") + +DATA_PATHS: list[tuple[Callable[[], Path], str]] = [ + (paths.get_toolchain_dir, "toolchain"), + (paths.get_js_tools_dir, "toolchain/js_tools"), + (paths.get_js_tools_bin_dir, "toolchain/js_tools/node_modules/.bin"), + (paths.get_templates_npm_cache_dir, "toolchain/templates_npm_cache"), + (paths.get_target_dir, "target"), + (lambda: paths.get_target_version_dir("4.0.14"), "target/4.0.14"), + (lambda: paths.get_sim_html_path("4.0.14"), "target/4.0.14/---simulator.html"), +] +STATE_PATHS: list[tuple[Callable[[], Path], str]] = [ + (paths.get_redun_db_for_toolchain_path, "redun_db_for_toolchain.sqlite3"), + (paths.get_redun_db_for_target_path, "redun_db_for_target.sqlite3"), +] + + +def test_version_matches_package() -> None: + assert paths.get_mkcd2app_version() == VERSION + + +def test_home_override(mkcd2app_home: Path) -> None: + assert paths.get_home_override_dir() == mkcd2app_home + assert paths.get_user_data_dir() == mkcd2app_home / "data" / VERSION + assert paths.get_user_state_dir() == mkcd2app_home / "state" / VERSION + + +@pytest.mark.parametrize(("get_path", "relative"), DATA_PATHS) +def test_data_paths_under_home( + mkcd2app_home: Path, get_path: Callable[[], Path], relative: str +) -> None: + assert get_path() == mkcd2app_home / "data" / VERSION / relative + + +@pytest.mark.parametrize(("get_path", "relative"), STATE_PATHS) +def test_state_paths_under_home( + mkcd2app_home: Path, get_path: Callable[[], Path], relative: str +) -> None: + assert get_path() == mkcd2app_home / "state" / VERSION / relative + + +@pytest.mark.parametrize("value", [None, ""]) +def test_no_override_uses_platformdirs( + monkeypatch: pytest.MonkeyPatch, value: str | None +) -> None: + if value is None: + monkeypatch.delenv(paths.HOME_ENV_VAR, raising=False) + else: + monkeypatch.setenv(paths.HOME_ENV_VAR, value) + assert paths.get_home_override_dir() is None + assert paths.get_user_data_dir() == Path(paths.DIRS.user_data_dir) / VERSION + assert paths.get_user_state_dir() == Path(paths.DIRS.user_state_dir) / VERSION diff --git a/tests/unit/test_redun_compat.py b/tests/unit/test_redun_compat.py new file mode 100644 index 0000000..9566d18 --- /dev/null +++ b/tests/unit/test_redun_compat.py @@ -0,0 +1,130 @@ +import os +from collections.abc import Callable +from pathlib import Path + +import pytest +import redun.file +from redun.file import ContentDir, Dir, File + +# Importing mkcd2app applies the patches +from mkcd2app._redun_compat import ( + _orig_get_proto, + _patched_get_proto, + assert_pinned_redun_version, +) + + +def test_pinned_redun_version() -> None: + assert_pinned_redun_version() + + +# ------------------------------------------------------------- get_proto ------ + + +def test_get_proto_is_patched() -> None: + assert redun.file.get_proto is _patched_get_proto + + +@pytest.mark.parametrize( + "url", ["C:\\Users\\me\\game", "c:\\x", "E:/build/x", "z:/"] +) # fmt: skip +def test_windows_drive_paths_are_local(url: str) -> None: + assert _orig_get_proto(url) != "local" # the bug the patch works around + assert redun.file.get_proto(url) == "local" + + +@pytest.mark.parametrize( + ("url", "proto"), + [ + ("/tmp/x", "local"), + ("relative/x", "local"), + ("", "local"), + (None, "local"), + ("s3://bucket/key", "s3"), + ("gs://bucket/key", "gs"), + ("http://example.com/x", "http"), + ("C:x", "c"), # drive-relative, not a drive path: left to urlparse + ("1:/x", "local"), # not a letter, so not patched; urlparse has no scheme + ], +) +def test_other_urls_unchanged(url: str | None, proto: str) -> None: + assert redun.file.get_proto(url) == _orig_get_proto(url) == proto + + +def test_file_with_drive_letter_uses_local_filesystem() -> None: + assert File("C:\\Users\\me\\game\\main.ts").filesystem.name == "local" + + +# --------------------------------------------------------- content hashing ---- + + +@pytest.fixture +def tree(tmp_path: Path) -> Path: + root = tmp_path / "tree" + (root / "sub").mkdir(parents=True) + (root / "a.txt").write_text("a", encoding="utf-8") + (root / "sub" / "b.txt").write_text("b", encoding="utf-8") + return root + + +def content_hash(path: Path) -> str: + # A new object each time: redun caches the hash on the instance + return ContentDir(str(path)).hash + + +def dir_hash(path: Path) -> str: + return Dir(str(path)).hash + + +def touch_all(root: Path, mtime: float) -> None: + for p in root.rglob("*"): + os.utime(p, (mtime, mtime)) + + +@pytest.mark.parametrize("hash_fn", [content_hash, dir_hash]) +def test_hash_ignores_mtime(tree: Path, hash_fn: Callable[[Path], str]) -> None: + touch_all(tree, 1_000_000_000) + before = hash_fn(tree) + touch_all(tree, 2_000_000_000) + assert hash_fn(tree) == before + + +def test_hash_is_stable(tree: Path) -> None: + assert content_hash(tree) == content_hash(tree) + + +def test_dir_hash_matches_content_dir(tree: Path) -> None: + assert dir_hash(tree) == content_hash(tree) + + +def test_hash_changes_with_content(tree: Path) -> None: + before = content_hash(tree) + (tree / "sub" / "b.txt").write_text("B", encoding="utf-8") + assert content_hash(tree) != before + + +def test_hash_changes_with_new_file(tree: Path) -> None: + before = content_hash(tree) + (tree / "c.txt").write_text("c", encoding="utf-8") + assert content_hash(tree) != before + + +def test_hash_changes_with_removed_file(tree: Path) -> None: + before = content_hash(tree) + (tree / "a.txt").unlink() + assert content_hash(tree) != before + + +def test_hash_changes_with_rename(tree: Path) -> None: + before = content_hash(tree) + (tree / "a.txt").rename(tree / "sub" / "a.txt") + assert content_hash(tree) != before + + +def test_hash_includes_path(tree: Path, tmp_path: Path) -> None: + # Same contents somewhere else is a different output dir, so a different value + copy = tmp_path / "copy" + (copy / "sub").mkdir(parents=True) + (copy / "a.txt").write_text("a", encoding="utf-8") + (copy / "sub" / "b.txt").write_text("b", encoding="utf-8") + assert content_hash(copy) != content_hash(tree) diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py new file mode 100644 index 0000000..941ff67 --- /dev/null +++ b/tests/unit/test_resources.py @@ -0,0 +1,30 @@ +from mkcd2app.utils.resources import ( + get_resource_empty_project_path, + get_resource_js_tools_path, + get_resource_template_path, +) + + +def test_templates_dir() -> None: + with get_resource_template_path() as path: + assert (path / "vite-project").is_dir() + + +def test_vite_template() -> None: + with get_resource_template_path("vite-project") as path: + for name in ("package.json", "package-lock.json", "index.html"): + assert (path / name).is_file(), name + # fill_website_template copies binary.js and the simulator in here + assert (path / "src" / "assets").is_dir() + + +def test_js_tools() -> None: + with get_resource_js_tools_path() as path: + assert (path / "package.json").is_file() + assert (path / "package-lock.json").is_file() + + +def test_empty_project() -> None: + with get_resource_empty_project_path() as path: + assert (path / "pxt.json").is_file() + assert (path / "main.ts").is_file() diff --git a/tests/unit/test_run.py b/tests/unit/test_run.py new file mode 100644 index 0000000..2aa40cf --- /dev/null +++ b/tests/unit/test_run.py @@ -0,0 +1,61 @@ +import sys +from pathlib import Path + +import pytest + +from mkcd2app.utils.run import BuildError, run_cmd + + +def python(code: str) -> list[str]: + return [sys.executable, "-c", code] + + +def test_returns_stdout(tmp_path: Path) -> None: + assert run_cmd(python("print('hello')"), cwd=tmp_path).strip() == "hello" + + +def test_runs_in_cwd(tmp_path: Path) -> None: + out = run_cmd(python("import os; print(os.getcwd())"), cwd=tmp_path) + assert Path(out.strip()).samefile(tmp_path) + + +def test_accepts_str_cwd(tmp_path: Path) -> None: + assert run_cmd(python("print(1)"), cwd=str(tmp_path)).strip() == "1" + + +def test_failure_raises_build_error(tmp_path: Path) -> None: + command = python( + "import sys; print('to stdout'); print('to stderr', file=sys.stderr); " + "sys.exit(3)" + ) + with pytest.raises(BuildError) as exc_info: + run_cmd(command, cwd=tmp_path) + err = exc_info.value + assert err.returncode == 3 + assert err.command == command + assert err.cwd == tmp_path + assert "to stdout" in err.output + assert "to stderr" in err.output + assert "(3)" in str(err) + + +def test_missing_executable(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError, match="definitely-not-a-real-program"): + run_cmd(["definitely-not-a-real-program"], cwd=tmp_path) + + +def test_which_path_limits_search(tmp_path: Path) -> None: + # The interpreter's own name is on PATH, but not in an empty which_path dir + name = Path(sys.executable).name + empty = tmp_path / "empty" + empty.mkdir() + with pytest.raises(FileNotFoundError): + run_cmd([name, "-c", "pass"], cwd=tmp_path, which_path=empty) + + +def test_which_path_finds_executable(tmp_path: Path) -> None: + exe = Path(sys.executable) + out = run_cmd( + [exe.name, "-c", "print('found')"], cwd=tmp_path, which_path=exe.parent + ) + assert out.strip() == "found" diff --git a/tests/unit/test_website.py b/tests/unit/test_website.py new file mode 100644 index 0000000..db8a4fb --- /dev/null +++ b/tests/unit/test_website.py @@ -0,0 +1,276 @@ +import base64 +import json +import os +import shutil +from pathlib import Path + +import pytest +from helpers import CmdCall, FakeRunCmd, make_config_yaml, tree_digest +from redun import Task +from redun.file import ContentDir, ContentFile + +from mkcd2app.build_project import website +from mkcd2app.build_project.website import ( + build_website, + build_website_singlefile, + copy_website_template, + fill_website_template, + install_website_deps, +) +from mkcd2app.utils.paths import get_templates_npm_cache_dir +from mkcd2app.utils.resources import get_resource_template_path + +FAVICON_LINK = '' +BINARY_JS = "// fake binary.js\n" +SIM_HTML = "fake simulator\n" + + +@pytest.fixture +def template_dir(tmp_path: Path) -> Path: + """A copy of the real bundled Vite template, so template drift gets caught.""" + dst = tmp_path / "template" + with get_resource_template_path("vite-project") as src: + shutil.copytree(src, dst, ignore=shutil.ignore_patterns("node_modules", "dist")) + return dst + + +@pytest.fixture +def website_dir(tmp_path: Path, template_dir: Path) -> Path: + """The template as copy_website_template leaves it in the build dir.""" + dst = tmp_path / "build" / "test-website" + shutil.copytree(template_dir, dst) + return dst + + +def make_support_dir(tmp_path: Path, favicon: bytes | None = None) -> Path: + support = tmp_path / "build" / "test-binary-js-support" + support.mkdir(parents=True) + (support / "---simulator.html").write_text(SIM_HTML, encoding="utf-8") + if favicon is not None: + (support / "favicon.ico").write_bytes(favicon) + return support + + +def fill(tmp_path: Path, website_dir: Path, favicon: bytes | None = None) -> Path: + bin_js = tmp_path / "build" / "test-binary-js" / "binary.js" + bin_js.parent.mkdir(parents=True) + bin_js.write_text(BINARY_JS, encoding="utf-8") + result: ContentDir = fill_website_template.func( + make_config_yaml(tmp_path / "build"), + ContentDir(str(website_dir)), + ContentFile(str(bin_js)), + ContentDir(str(make_support_dir(tmp_path, favicon))), + ) + return Path(result.path) + + +# ------------------------------------------------- copy_website_template ------ + + +def test_copy_template_to_build_dir(tmp_path: Path, template_dir: Path) -> None: + build_dir = tmp_path / "build" + result: ContentDir = copy_website_template.func( + make_config_yaml(build_dir), ContentDir(str(template_dir)) + ) + assert Path(result.path) == build_dir / "test-website" + assert tree_digest(Path(result.path)) == tree_digest(template_dir) + + +def test_copy_template_replaces_previous_copy( + tmp_path: Path, template_dir: Path +) -> None: + stale = tmp_path / "build" / "test-website" / "stale.txt" + stale.parent.mkdir(parents=True) + stale.write_text("old", encoding="utf-8") + copy_website_template.func( + make_config_yaml(tmp_path / "build"), ContentDir(str(template_dir)) + ) + assert not stale.exists() + + +# ------------------------------------------------- fill_website_template ------ + + +def test_fill_writes_to_sibling_dir(tmp_path: Path, website_dir: Path) -> None: + assert fill(tmp_path, website_dir) == tmp_path / "build" / "test-website-filled" + + +def test_fill_copies_binary_and_simulator(tmp_path: Path, website_dir: Path) -> None: + assets = fill(tmp_path, website_dir) / "src" / "assets" + assert (assets / "binary.js").read_text(encoding="utf-8") == BINARY_JS + assert (assets / "---simulator.html").read_text(encoding="utf-8") == SIM_HTML + + +def test_fill_sets_default_title(tmp_path: Path, website_dir: Path) -> None: + index_html = (fill(tmp_path, website_dir) / "index.html").read_text("utf-8") + assert "Test Game v1.2.3" in index_html + assert "vite-project" not in index_html + + +def test_fill_inlines_favicon(tmp_path: Path, website_dir: Path) -> None: + icon = b"\x00\x00\x01\x00fake icon bytes" + index_html = (fill(tmp_path, website_dir, icon) / "index.html").read_text("utf-8") + data_uri = "data:image/x-icon;base64," + base64.b64encode(icon).decode("ascii") + assert f'' in index_html + assert "./favicon.ico" not in index_html + + +def test_fill_without_favicon_keeps_link(tmp_path: Path, website_dir: Path) -> None: + index_html = (fill(tmp_path, website_dir) / "index.html").read_text("utf-8") + assert FAVICON_LINK in index_html + + +def test_fill_updates_package_json(tmp_path: Path, website_dir: Path) -> None: + original = json.loads((website_dir / "package.json").read_text(encoding="utf-8")) + filled_path = fill(tmp_path, website_dir) / "package.json" + filled = json.loads(filled_path.read_text(encoding="utf-8")) + assert filled["name"] == "test" + assert filled["version"] == "1.2.3" + assert filled["description"] == "A test game" + assert filled["authors"] == {"name": "Tester"} + # Everything that npm needs to install and build is untouched + for key in ("scripts", "dependencies", "devDependencies"): + assert filled.get(key) == original.get(key) + + +def test_fill_does_not_modify_input(tmp_path: Path, website_dir: Path) -> None: + before = tree_digest(website_dir) + fill(tmp_path, website_dir, b"icon") + assert tree_digest(website_dir) == before + + +def test_fill_replaces_previous_output(tmp_path: Path, website_dir: Path) -> None: + stale = tmp_path / "build" / "test-website-filled" / "stale.txt" + stale.parent.mkdir(parents=True) + stale.write_text("old", encoding="utf-8") + fill(tmp_path, website_dir) + assert not stale.exists() + + +# -------------------------------------------------- install_website_deps ------ + + +def test_install_deps_runs_offline_npm_ci( + tmp_path: Path, + template_dir: Path, + mkcd2app_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + deps_dir = tmp_path / "build" / "test-website-deps" + (deps_dir / "stale.txt").parent.mkdir(parents=True) + (deps_dir / "stale.txt").write_text("old", encoding="utf-8") + + def fake_npm_ci(call: CmdCall) -> None: + # The lockfile must already be in place when npm runs + assert (call.cwd / "package.json").is_file() + assert (call.cwd / "package-lock.json").is_file() + (call.cwd / "node_modules").mkdir() + + fake = FakeRunCmd(fake_npm_ci) + monkeypatch.setattr(website, "run_cmd", fake) + + result: ContentDir = install_website_deps.func( + str(deps_dir), + ContentFile(str(template_dir / "package.json")), + ContentFile(str(template_dir / "package-lock.json")), + ) + + cache = get_templates_npm_cache_dir() + assert cache.is_relative_to(mkcd2app_home) + assert [(c.command, c.cwd) for c in fake.calls] == [ + (["npm", "ci", "--cache", str(cache), "--offline"], deps_dir) + ] + assert Path(result.path) == deps_dir / "node_modules" + assert not (deps_dir / "stale.txt").exists() + for name in ("package.json", "package-lock.json"): + assert (deps_dir / name).read_bytes() == (template_dir / name).read_bytes() + + +# --------------------------------------------- build_website(_singlefile) ----- + + +@pytest.fixture +def node_modules(tmp_path: Path) -> Path: + """A fake node_modules with a relative .bin symlink when the OS allows it.""" + nm = tmp_path / "deps" / "node_modules" + (nm / "vite" / "bin").mkdir(parents=True) + (nm / "vite" / "bin" / "vite.js").write_text("// vite", encoding="utf-8") + (nm / ".bin").mkdir() + try: + os.symlink(Path("..") / "vite" / "bin" / "vite.js", nm / ".bin" / "vite") + except OSError: + pass # e.g. Windows without symlink privileges + return nm + + +def fake_vite_build(call: CmdCall) -> None: + # Fails like the real build would if node_modules wasn't copied in + assert (call.cwd / "node_modules" / "vite" / "bin" / "vite.js").is_file() + (call.cwd / "dist").mkdir() + (call.cwd / "dist" / "index.html").write_text("built", "utf-8") + + +@pytest.mark.parametrize( + ("task", "script", "suffix", "output"), + [ + (build_website, "build", "-built", "dist"), + (build_website_singlefile, "build:singlefile", "-singlefile-built", "dist/index.html"), + ], + ids=["static", "singlefile"], +) # fmt: skip +def test_build_website( + tmp_path: Path, + website_dir: Path, + node_modules: Path, + monkeypatch: pytest.MonkeyPatch, + task: Task[..., ContentDir | ContentFile], + script: str, + suffix: str, + output: str, +) -> None: + fake = FakeRunCmd(fake_vite_build) + monkeypatch.setattr(website, "run_cmd", fake) + before = tree_digest(website_dir) + + result: ContentDir | ContentFile = task.func( + ContentDir(str(website_dir)), ContentDir(str(node_modules)) + ) + + built = website_dir.parent / f"{website_dir.name}{suffix}" + assert [(c.command, c.cwd) for c in fake.calls] == [(["npm", "run", script], built)] + assert Path(result.path) == built / output + assert tree_digest(website_dir) == before + assert not (website_dir / "node_modules").exists() + + +def test_build_website_keeps_bin_symlinks( + tmp_path: Path, + website_dir: Path, + node_modules: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + if not (node_modules / ".bin" / "vite").is_symlink(): + pytest.skip("can't create symlinks here") + monkeypatch.setattr(website, "run_cmd", FakeRunCmd(fake_vite_build)) + + build_website.func(ContentDir(str(website_dir)), ContentDir(str(node_modules))) + + link = website_dir.parent / "test-website-built" / "node_modules" / ".bin" / "vite" + assert link.is_symlink() + assert os.readlink(link) == str(Path("..") / "vite" / "bin" / "vite.js") + + +def test_build_website_replaces_previous_output( + tmp_path: Path, + website_dir: Path, + node_modules: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + stale = website_dir.parent / "test-website-built" / "stale.txt" + stale.parent.mkdir(parents=True) + stale.write_text("old", encoding="utf-8") + monkeypatch.setattr(website, "run_cmd", FakeRunCmd(fake_vite_build)) + + build_website.func(ContentDir(str(website_dir)), ContentDir(str(node_modules))) + + assert not stale.exists() diff --git a/uv.lock b/uv.lock index 0f83316..5748348 100644 --- a/uv.lock +++ b/uv.lock @@ -288,6 +288,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/9b/639ff6f5b3158999fab6f03cb7597526ad57e15cad0c6cfa9b64bac874dc/coverage-7.17.1.tar.gz", hash = "sha256:5c5c77f95a0e83e6b79eff948d251c9911cfd61d67f5059159fc25b7bc4aec27", size = 995565, upload-time = "2026-11-03T12:38:47.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/3b/41fb1e5297e4a14ec9a4f8d620b7c5bc4acd62ad8247a84287c5f05a3611/coverage-7.17.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:932a542ea396dc842d0d2882ac95f33242c0029ddc226323c243d7e35978bfde", size = 228890, upload-time = "2026-11-03T12:37:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/80/a0/e7b12a2e40f9af75ed4e64cbf7a9bfd17b9e1834bebd4e46b5965cb0be06/coverage-7.17.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6cd9deae02bf24bf16fd72d1c505e81beb4cd0bbfc1b75af8ba1096a8461e62f", size = 229006, upload-time = "2026-11-03T12:37:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d7/3b69b6d1fdaeb399c6a0f0dee5b94b337f5248c0d0d5b78d736563d7eda6/coverage-7.17.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d1d62732067fe938796db0b460b0788db69cef8f91e802747eb59b2d4c1da9f9", size = 227174, upload-time = "2026-11-03T12:37:16.022Z" }, + { url = "https://files.pythonhosted.org/packages/1f/5a/a7fa52b9a98b98fbbeecc18abbd0bdacb292c4121a31497b39f54146a0d1/coverage-7.17.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7681bd8f1eb33c778fff244bd06dacabf1e4325fa578c8265367216ac8b5a059", size = 227475, upload-time = "2026-11-03T12:37:17.356Z" }, + { url = "https://files.pythonhosted.org/packages/12/d3/394272eadeb616010db92d4d650b983c54b0596be87685c242b9e1f35b1f/coverage-7.17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c948f185d1ca81ee2f1383f1a51387277bf6da5b14a469136031827d51d0ced", size = 227712, upload-time = "2026-11-03T12:37:19.062Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5a/35108cca71cb81b671492aede98a422330bca2558aa07d2cf3ee249ff06e/coverage-7.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9db719b76551607ca01b5b5464f554bec78344fff4a7f09c58f4d55cf9499cd7", size = 227866, upload-time = "2026-11-03T12:37:20.408Z" }, + { url = "https://files.pythonhosted.org/packages/5d/56/21400d3216320c3214bcd88b6d57e7217a4475d37494cfc05b2c2984b6f0/coverage-7.17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:612b22d9322a0728bc209122847fd48d6353eea92e314f70a1278350dcd48de3", size = 259786, upload-time = "2026-11-03T12:37:21.688Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/a8ab57e646da306d854613bbf2972a8a20257fecb5edb13ca2378774af9b/coverage-7.17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:22edba362121a928bb4bff377915e5ce9b8f2e254239f93f38931de021485d32", size = 262792, upload-time = "2026-11-03T12:37:23.015Z" }, + { url = "https://files.pythonhosted.org/packages/c1/94/5e40670e4893e8e8cb338ae80f7c43e6cc47ce1f49891a2d02b94184605f/coverage-7.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ee8e36fd04c566fdf3d0e978b343eb1b96e9444484c199a32648cd60b0c2e4", size = 263774, upload-time = "2026-11-03T12:37:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/cf67121055a6540e7ad0060dac0d2061bd39ac9bc45fe9168a47c0fa40c9/coverage-7.17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a87e4286f0743d0e2a0eba077453595afb6cfb2d0d18b25dea74dc9553e8dad", size = 266251, upload-time = "2026-11-03T12:37:25.535Z" }, + { url = "https://files.pythonhosted.org/packages/18/8d/b2884aa3cee232c924393ba0b7db5d44f520e02a56497e176e5128e9a4ef/coverage-7.17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:806ab974da8a718697a329a126ae3655f0c8d8bfd12a7ca6c2089a2350ed0485", size = 259565, upload-time = "2026-11-03T12:37:26.798Z" }, + { url = "https://files.pythonhosted.org/packages/fc/11/1c0ccea4d6d35ac421d10f2b210bb61f3e412ad769bd5cc08011876c7577/coverage-7.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2cd58acb64ad41f2e01a4d35e35c1060a1dd64699a146caef8c360cf7408083a", size = 261626, upload-time = "2026-11-03T12:37:28.196Z" }, + { url = "https://files.pythonhosted.org/packages/13/1a/ba1b45a8da773e70e5b1cdd08450a5a7cda84ab31a7aeb2c2a8c94361b2b/coverage-7.17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:354ceff3c2233ed017df0f4d5d65dcd87c350ca6a129fcaa9478a7031957b03c", size = 259668, upload-time = "2026-11-03T12:37:29.526Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/5e4ca2c677569f439269a0aab412f8e198751660412b4613ffa3f2473e5f/coverage-7.17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b927e5e4708344f94011bcb36a58023f82d2f0c8757ff2c5bf869d5c6ca0aa16", size = 263965, upload-time = "2026-11-03T12:37:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/14/28/d0880224c1f8182e523c454aadd30c89af959b477245463decc7bf166cc1/coverage-7.17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:65ad67be7eb4c9c48814c35c53f5fc1cf6ba17892b0e183ef3413b991085df62", size = 259298, upload-time = "2026-11-03T12:37:32.282Z" }, + { url = "https://files.pythonhosted.org/packages/5a/64/0f7c0ba50b6d3083478be28b0ffdc1de9d16df1d8abfc920de164e8ef60c/coverage-7.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9e85d83d4e41e7ec7fcd026765947ae1b068eb134ba820c8ebb3bba290878972", size = 261408, upload-time = "2026-11-03T12:37:33.999Z" }, + { url = "https://files.pythonhosted.org/packages/92/71/6eab8cd02e941a8473b1e1ed579848e51a3ecaaf8300e1a43d4059d32fa0/coverage-7.17.1-cp314-cp314-win32.whl", hash = "sha256:457ad13b8ec34f5dd607cc9b68c4f6af579dfe1bb6368a25e334976860c03329", size = 229639, upload-time = "2026-11-03T12:37:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/54/42/fb8cedbe674bcdecf37327b014764c98a6bbcbdf2b5faf4ff12ad850555e/coverage-7.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:73a8278e7dcd338e8be66eb66deea97dc290cdc0b50e176bf96095dd498addb3", size = 230271, upload-time = "2026-11-03T12:37:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/2fc61e1a128f21a6673e3ee5273e494f02489c6f2b818152db534d0c879f/coverage-7.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be94c7a728f5bdeb09fe6d0b89542e57e0162a80555a205eec849fc25e45695", size = 230121, upload-time = "2026-11-03T12:37:38.417Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ab/bcbd5e621a8c6e14336fbb0570a2d4268e1259e7ed1f0704701c314608b9/coverage-7.17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5208b4cbd83fdbf6cce21fb6137e1986c9c5a5eda6400029ab1b448c5b8f9747", size = 228174, upload-time = "2026-11-03T12:37:39.637Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0b/d0307ba45d2f46d1ebfd421e9eb8201bb3bbdc5edd388432e33c3c2f850b/coverage-7.17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efb34d2401f0ef64502f46f9f077b376836cb5cdb98b5d481da534487f7d3f15", size = 228551, upload-time = "2026-11-03T12:37:41.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/d855372f3e102e3ad9f4bdb8fbdd71df501a932b17b8da9dcd14dd2188f3/coverage-7.17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:70c1747f2c7b146396da314ec0a7e45ac3dc25b883f39fc7e73cbbb42ee02a6a", size = 268543, upload-time = "2026-11-03T12:37:42.233Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/c0dffce72ab78176d2fc9d0c695399394b172a8fef880f73670bd9061759/coverage-7.17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9412b525747a004fb450432aba6b0ed28891c6d224507020f2c1b05787b9c9da", size = 271097, upload-time = "2026-11-03T12:37:43.461Z" }, + { url = "https://files.pythonhosted.org/packages/cb/54/1e810bf8040b335a9a1434aa29ac278027413b0209638674481a7a4439e6/coverage-7.17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45946d12c25ceb14967e873a0a46526cfe337b075b6dff917603fd4b5448b90a", size = 273718, upload-time = "2026-11-03T12:37:44.734Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5d/c8c8becee8228047577f7b352902fe34a4cca5691be12120caf3faf71d5d/coverage-7.17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76e08d61b3791098befa989da61b899ad0e4b36686534cd0078d68e940a97eed", size = 275208, upload-time = "2026-11-03T12:37:46.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/fb/b00edd3abb50265c7165922b1a6820fd4b34d2917f5d573fa19ae32f73dc/coverage-7.17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c5b025803be4dee29fafc44aa6bc20255842ab26dc27de19cd0e90c4e8bf6ac9", size = 268433, upload-time = "2026-11-03T12:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/45/af/42809663d7a98ba6f95dd976527a0b5d762eed87701dab71af4e14e60adb/coverage-7.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:782d2f15f59864202d3939e9bea35b8a6fae6a1d4ef489beca7bacd24058b160", size = 271007, upload-time = "2026-11-03T12:37:49.118Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/9b93e96f43f8dcc10bba69ac1ade3e707b91581d04847e05f29abf0b0136/coverage-7.17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b34dac11d9e03897f64732e3cb3a3e8dba29a7f32a34eacb9536591099fc254f", size = 268434, upload-time = "2026-11-03T12:37:50.413Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/bc1983e6715cd211c21dc0389d1d32b789f7dfec31988cc67016aa5ed7a9/coverage-7.17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a01f6f44bd92958dba923aa414d1b868a74d175574adb50d50d60157f85da242", size = 272731, upload-time = "2026-11-03T12:37:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/53/29/7a7be7b0f778d48f13908f1bb8eb0d02f9e09448d3852974cc235effbe0f/coverage-7.17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62726d1c435d1476de876bd9c6656cc69d157e0e3c419cceb4a8a588db55017c", size = 267851, upload-time = "2026-11-03T12:37:53.083Z" }, + { url = "https://files.pythonhosted.org/packages/f1/80/9b8e09889158a06d9776551d7765ac2627ae21428189af39d21818a314f3/coverage-7.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:05858414f6c3557c9ce2d4348215997c3834bdb95a9f6f62e97ab558d1960fc5", size = 269785, upload-time = "2026-11-03T12:37:54.396Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d6/975b76074ce079c3a91248b94bab4f938f5fc39970b384cb292d26b3bfa2/coverage-7.17.1-cp314-cp314t-win32.whl", hash = "sha256:04d8ce47c70cc3f8e760cfabac5c3182f3e03829dc3cecfbfca69ff7d7ee48a6", size = 229953, upload-time = "2026-11-03T12:37:55.731Z" }, + { url = "https://files.pythonhosted.org/packages/bb/34/b55e15f3a916bb9111bad357a44413bc123346f4b72ee002f810c2e8eecc/coverage-7.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:006453b2515ea4c44a857e07b985429038b164080fa777cabea6d22d2e686325", size = 230884, upload-time = "2026-11-03T12:37:56.971Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/55ce659eb8a4e31c48ff433f4910dc48fb843a672a64f51fc6dd918d00b1/coverage-7.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b1dc930b405b761efbfef2b3782ae5d12e22708d8086875c5f1e64318b2b857f", size = 230453, upload-time = "2026-11-03T12:37:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/8d/76/a3443e64fd29e8709f274875d56f9d0c399d13d2146ff785865ec8df7766/coverage-7.17.1-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:67e193d7c93aa93870d78594adfbe2926e9ca8bbc92c963c011092441880edb0", size = 228888, upload-time = "2026-11-03T12:37:59.709Z" }, + { url = "https://files.pythonhosted.org/packages/28/37/de21f8bebe25f3bf95f368451db12e884147514139fd0b8bc18990b2a1bb/coverage-7.17.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:eea6030a00b96f656016ea58b19d61fad3de7add8340e2e7c3b032d1516561d5", size = 229006, upload-time = "2026-11-03T12:38:01.022Z" }, + { url = "https://files.pythonhosted.org/packages/a5/41/fbf7073c3d6b053106352bfd2b4de8e0c9d27756fccc22d6b0ee20a892e9/coverage-7.17.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:603b5795c56fabfe2d0fcd64685eacba5eff1c7797741fa9b898564bb53a9207", size = 227185, upload-time = "2026-11-03T12:38:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b1/e758d35cf81d6c4f405f66578ed59c1dcfbb1de5deb5662c5bf23057b6e5/coverage-7.17.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ae938f631ffdf9183851140a245b44f74e35f25fc7d72edb261546ffbe8e5dde", size = 227486, upload-time = "2026-11-03T12:38:03.816Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f3/c9c3d530bb7bb6142095e5d0a10e9d73107964b4b6345c922809806f6eb7/coverage-7.17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:65f74048adacb5b031186becc29ce2a12a8aa6f86df86562a947120f5d8e58bc", size = 227716, upload-time = "2026-11-03T12:38:05.14Z" }, + { url = "https://files.pythonhosted.org/packages/1f/40/cc5395ca5038c96637510122da45d58d023e50491a2796cb920eb25ee266/coverage-7.17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:185ff7348ca70b1663b558a9a8ec66f34451c7a9428627543a0e9575045bed0a", size = 227882, upload-time = "2026-11-03T12:38:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/73/89/5b8f9051cc86dba736c3b35538faa8dc4792e1b7140641531e65661274c6/coverage-7.17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b0b4793548784c9986a4df4c90b9f672447a25c40783f1a736c7fe171a79cf0", size = 259735, upload-time = "2026-11-03T12:38:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/47/5e/14081087f40abde7006010ba12cc3a0412de8088ab4afb857bf19ece28c9/coverage-7.17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:834a15c96d92d2fa9503674c42615f843cc2f427b93d0a0a6c8e437ec728b1b3", size = 263426, upload-time = "2026-11-03T12:38:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2c/8c41550d3280cc71640905f0961e734488328627bd367e95451fd2b10df3/coverage-7.17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16903fc7d3e60fb9336ade7b12a8343f0381a1ca719d008b3d94a051b520a1c1", size = 264509, upload-time = "2026-11-03T12:38:10.656Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/37d4eaf0313b042b26decc8b3d4f90baae3302b0e940074b4720b724392b/coverage-7.17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6611d1cceadef78a6c136bb94d4e1d1db9024fe8f14b6ba4424e95bb2ac9e1a", size = 266883, upload-time = "2026-11-03T12:38:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c5/45823bfbb8d0c0d3ac69f6ad1fda556d87ee3d2bc930790ad12d54bc3484/coverage-7.17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:efaa35a6671a9ce23d568e291edc5f97efe689eaa291a6468007cf12d3402e85", size = 260122, upload-time = "2026-11-03T12:38:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/80/22/3b7eb82197af8a7b11ce57b1a3b2ecb891287a9016a77d3587051147ed8e/coverage-7.17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:125b5146f8624275df6ade5b10d40fb18231ed8f79650a4c522adf7e300db963", size = 262389, upload-time = "2026-11-03T12:38:14.568Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/460b53c3ba449af2c70cb2ca5f2583d483b57fb9a15f2a964badbd8c18bd/coverage-7.17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:08e6db8cef186e7954861705102d50d56f273026fce963135a029b1eed870955", size = 260339, upload-time = "2026-11-03T12:38:15.907Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/9a6f1abdcc0ce00de6aa28a2ccb5c622c0f4c4fc2365d4b18d54073eec9b/coverage-7.17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3832721ed2cae5d70ee4c6549ef166d16b41ff5c351fa0aae24463b620e65bec", size = 264672, upload-time = "2026-11-03T12:38:17.149Z" }, + { url = "https://files.pythonhosted.org/packages/82/b5/cdd25a9319af55fa504ba6cfc063cd38c1326b7382aa2f73a66e376ce5a6/coverage-7.17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b785cc3b82f469db18ca5b123c5eab7cf739d6a99be1fc5b4eb1c395dace5ab4", size = 259651, upload-time = "2026-11-03T12:38:18.475Z" }, + { url = "https://files.pythonhosted.org/packages/d6/80/273c406302ca223d3c72d2a277b1e776b4b21a2391c22fd2c4f41582949c/coverage-7.17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2f09e92d9f3bc2f7193fd6b354a45817c89d3cebfdd0912900f67c7822af105f", size = 262195, upload-time = "2026-11-03T12:38:19.983Z" }, + { url = "https://files.pythonhosted.org/packages/81/ec/f2d4baf81838d3fe269090b26c98f5c48f6efb482ca365f852d72d0671a3/coverage-7.17.1-cp315-cp315-win32.whl", hash = "sha256:d27344a94963356614e884c5739871190438bce03d2973ff9c13be37d40c7371", size = 229634, upload-time = "2026-11-03T12:38:21.688Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bd/6b6467a081d5da4c5fbd65908070bb9c6b90471738d74239a23ab8ef1e59/coverage-7.17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4ebb24cdee700ba3987aa5c9bc773677cafb6d88edc6f6d9cf79bc0df96b473e", size = 230256, upload-time = "2026-11-03T12:38:23.307Z" }, + { url = "https://files.pythonhosted.org/packages/00/1f/522e0d03e2cdf4a7f2538d6858b64fb95a3193003bb0d79a42b6e1426bc2/coverage-7.17.1-cp315-cp315-win_arm64.whl", hash = "sha256:cbbfb4b9d99766653447f664622168e0d00fef41e6d25a7700eaf6d9f10ae0fd", size = 230120, upload-time = "2026-11-03T12:38:24.514Z" }, + { url = "https://files.pythonhosted.org/packages/bf/47/7ea4bdf31bf175b53067998a6e9b1e4e50fef191f0e3d4c426843dcc1801/coverage-7.17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:9cd33f622f11927736fcacf884fc6a61605e9c15e489d24dc77cf84c7015ffe5", size = 228174, upload-time = "2026-11-03T12:38:25.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/05a784dffccc384aa939e3d4afbfed7c03dbbd4e56c15fa2262fc665a789/coverage-7.17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:719b3925108a2ce55a6a9dd5bfd9bf9114c7da1c57b9ea39e59f0fc1ba9d4fa7", size = 228566, upload-time = "2026-11-03T12:38:27.073Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/c91c08cb288588711eaf448873536472aa6bec8e97e8f5c4bb7c3472f9a6/coverage-7.17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:15981f51ef23c16de05e740677e1dbc59fd078fbf60ce4cb89687135585a791e", size = 268923, upload-time = "2026-11-03T12:38:28.267Z" }, + { url = "https://files.pythonhosted.org/packages/9c/96/99fc10e4eb6e64e6c613158c79805653366991b93293d18a1a62748e2038/coverage-7.17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042e093be797b929741fb71238da028f0f2d9d669a3a7ab3588967d77f860b51", size = 271762, upload-time = "2026-11-03T12:38:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/fd2aa355efe9cceca9518a7b1f73623ff9a1deb2709cac2b8a096a1773ee/coverage-7.17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:118f9bacb98a78b5e81c3a4721adcfb1b404942a5a2ec34e0d47a1ee7d772b3d", size = 274461, upload-time = "2026-11-03T12:38:30.828Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ad/e67a208e56876a5b632cfeff790804e331db581c9c5f439235bf40cfc13f/coverage-7.17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0411fe8b17b7f481cdfe9138bd5b266bb18de124114cfab0cf796b61383ef321", size = 275794, upload-time = "2026-11-03T12:38:32.203Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c9/90717d790b248e1f081f9498d91050969de86d35c687a755db1c48ada4f8/coverage-7.17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af1f1c7057ff655f0e2673ca931ab9e69683c26841d7838a9677eb6484c637fd", size = 269483, upload-time = "2026-11-03T12:38:33.602Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bc/162be11201171171e084cb388bdd8a44ccb820733a3cf13bd3da398e46a5/coverage-7.17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0bc7c1dbd90be3ddb3bab347deb91996bcb33828aa662acbe83334c55ee0642d", size = 271952, upload-time = "2026-11-03T12:38:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2d/9ae1eb08020d998d4c9bb6e539b096642129eaec5cc423b0eca6ea216a3a/coverage-7.17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:7c93a389c1f696ab7ca4764f6629dae187f41aac19ae763e18b35cd60dd7d2b4", size = 268977, upload-time = "2026-11-03T12:38:36.164Z" }, + { url = "https://files.pythonhosted.org/packages/81/6c/33577528fdb8a9d6af5e5f188c28a364d5ed82c57c49dcade7e96682da06/coverage-7.17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:208ecaaa274e8c97ff8939baa15cfd4e9a5ed0b7a0132206548277e7938e96e7", size = 273587, upload-time = "2026-11-03T12:38:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/6449f497df0012d8e793bab0788d0265d9d2753b33b64fbd47ce8dc50eed/coverage-7.17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:043af54ef18a5ea6ec5c5347408d21165fd5ddff4f62f63efea32cf3c0ba6ca1", size = 269072, upload-time = "2026-11-03T12:38:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/9f/08/35148999d7e8bef6e596cc056d5a7a403d2c702daa5b51e22d138056b254/coverage-7.17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8830f81b489c1fcba77338cf9e176d253117dd886fb58e587a137f9f5bf9b062", size = 270684, upload-time = "2026-11-03T12:38:40.155Z" }, + { url = "https://files.pythonhosted.org/packages/fd/bb/6c0714bcd3ed4b9ec00902281dc17fcf59eb308185ae78e7bea2f037dc20/coverage-7.17.1-cp315-cp315t-win32.whl", hash = "sha256:f705761e5187ba08bbc80fd4b5b6d675e23ecc4f468daee2f5b22cfc8f5ed96b", size = 229942, upload-time = "2026-11-03T12:38:41.524Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/8d88d51ff9c3da15430c902aca5409a98c01b8d7fad18d631daa53df734b/coverage-7.17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:87e86e0c7712ce64316f649c6d0b30225aaa64da25ff201bf6a1d86d21168d73", size = 230870, upload-time = "2026-11-03T12:38:43.146Z" }, + { url = "https://files.pythonhosted.org/packages/8e/58/9cdb5667edcd9bd5b17293e96620e50e89d425d8a450a1fa57f18e68b5d9/coverage-7.17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:9cd4319b51f18519688baf38e01424ec439a5da5ef034a7e429bc631afa95e63", size = 230443, upload-time = "2026-11-03T12:38:44.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/7ad3e5f92557c73044d3dfaf88cc65fb7b1ad589c744e8629af8e6370224/coverage-7.17.1-py3-none-any.whl", hash = "sha256:fda1023828e4a9f4a40ef06783e005f3630f805f839edc679c87f597f33e041f", size = 219074, upload-time = "2026-11-03T12:38:46.229Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -627,6 +704,7 @@ dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "ruff" }, { name = "types-pyyaml" }, @@ -649,6 +727,7 @@ dev = [ { name = "mypy", specifier = ">=2.3.0" }, { name = "pre-commit", specifier = ">=4.6.1" }, { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "ruff", specifier = ">=0.16.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20260724" }, @@ -974,6 +1053,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0"