steam-backlog-enforcer/steam_backlog_enforcer/tests/test_web_build.py
Krzysztof kuhy Rudnicki 47f77f5f1e
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Make serve re-runnable, and cover it to 100%
The serve rework was sitting untracked: a /proc-based port guard, the
start-or-defer decision, and a stale-bundle rebuild. It had no tests, and it
changed cmd_serve(_config, state) to cmd_serve(argv) without updating the one
test that called it, so the suite was red and coverage was 251 lines short.

Tests, all against fakes:
- _fake_proc builds a synthetic /proc. _NET_TCP is derived from _PROC at
  import time, so patch_proc redirects both; patching _PROC alone would have
  had these tests parse the host's real process table.
- _serve_guard is split across two test files to stay under the 250-line cap.
  os is replaced wholesale in every termination test, so no signal can reach a
  real process even if the guard's self-protection were removed.
- _web_build mocks subprocess entirely and redirects every path constant into
  tmp_path. _no_subprocess now blocks _web_build.subprocess.run too: an
  unguarded test would fire a real `npm run build`, rewriting web/dist and
  perturbing frontend_is_stale() for every later test in the run.

Source changes, both required rather than incidental:
- _serve_guard's wildcard match was a `# noqa: S104` on a "0.0.0.0" literal,
  which the no-noqa hook rejects outright. It is now the all-zero hex column
  /proc actually prints, so the dotted-quad literal never appears.
- main/__init__.py's serve dispatch arm was itself uncovered; it now has a
  test asserting the raw argv passes through unparsed.

Also drops the document.hidden guard from useBudgetPoll: it skipped the very
first load, so the budget panel sat on "Reading budget state…" forever in a
background tab, which is every tab under browser automation.

Verified: 1712 python tests, 100% branch coverage; 128 vitest, 100%. The
guard's /proc parser was checked read-only against a real listener on :8000
and resolved the same pid as `ss`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
2026-08-29 18:52:11 +02:00

159 lines
5.9 KiB
Python

"""Tests for _web_build — 100% branch coverage.
Every module-level path constant is derived from the real repo ``web/`` at
import time, so each test redirects all of them into ``tmp_path``. Patching
only ``WEB_DIR`` would leave ``_BUILT_MARKER`` and friends pointing at the
working tree, and the tests would silently grade the developer's own bundle.
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer import _web_build
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_PKG = "steam_backlog_enforcer._web_build"
@pytest.fixture
def web(tmp_path: Path) -> Iterator[Path]:
"""Redirect the whole module's path constants into a fake web/ tree."""
root = tmp_path / "web"
(root / "src").mkdir(parents=True)
(root / "dist").mkdir()
with (
patch.object(_web_build, "WEB_DIR", root),
patch.object(_web_build, "_BUILT_MARKER", root / "dist" / "index.html"),
patch.object(_web_build, "_SOURCE_DIR", root / "src"),
patch.object(
_web_build,
"_SOURCE_FILES",
(root / "index.html", root / "package.json"),
),
):
yield root
def _touch(path: Path, mtime: float) -> None:
"""Create *path* with a fixed mtime."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("x", encoding="utf-8")
import os
os.utime(path, (mtime, mtime))
class TestMtime:
"""Tests for the _mtime helper."""
def test_missing_path_is_zero(self, tmp_path: Path) -> None:
assert _web_build._mtime(tmp_path / "nope") == 0.0
def test_existing_path_returns_mtime(self, tmp_path: Path) -> None:
target = tmp_path / "f"
_touch(target, 1000.0)
assert _web_build._mtime(target) == pytest.approx(1000.0)
class TestFrontendIsStale:
"""Tests for frontend_is_stale."""
def test_unbuilt_but_present_web_dir_is_stale(self, web: Path) -> None:
# No dist/index.html at all: a fresh clone must build, not serve the
# "not built" placeholder.
assert web.is_dir()
assert _web_build.frontend_is_stale() is True
def test_missing_web_dir_is_not_stale(self, web: Path) -> None:
# Nothing to build from — claiming staleness would make serve exit 1
# on an install that legitimately ships no frontend sources.
with patch.object(_web_build, "WEB_DIR", web / "absent"):
assert _web_build.frontend_is_stale() is False
def test_newer_source_file_is_stale(self, web: Path) -> None:
_touch(web / "dist" / "index.html", 1000.0)
_touch(web / "package.json", 2000.0)
assert _web_build.frontend_is_stale() is True
def test_newer_src_tree_entry_is_stale(self, web: Path) -> None:
_touch(web / "dist" / "index.html", 1000.0)
_touch(web / "src" / "nested" / "app.tsx", 2000.0)
assert _web_build.frontend_is_stale() is True
def test_all_sources_older_is_current(self, web: Path) -> None:
_touch(web / "package.json", 1000.0)
_touch(web / "src" / "app.tsx", 1000.0)
_touch(web / "dist" / "index.html", 2000.0)
assert _web_build.frontend_is_stale() is False
def test_directories_in_src_are_skipped(self, web: Path) -> None:
# A directory's own mtime bumps whenever a file inside it is added or
# removed, so counting it would report staleness after a build.
_touch(web / "src" / "old.tsx", 1000.0)
_touch(web / "dist" / "index.html", 2000.0)
(web / "src" / "assets").mkdir()
assert _web_build.frontend_is_stale() is False
def test_missing_src_dir_still_checks_flat_sources(self, web: Path) -> None:
_touch(web / "dist" / "index.html", 1000.0)
_touch(web / "index.html", 2000.0)
with patch.object(_web_build, "_SOURCE_DIR", web / "absent"):
assert _web_build.frontend_is_stale() is True
class TestBuildFrontend:
"""Tests for build_frontend. subprocess is never actually executed."""
def test_missing_npm_explains_and_fails(self, web: Path) -> None:
with (
patch(f"{_PKG}.shutil.which", return_value=None),
patch(f"{_PKG}._echo") as echo,
):
assert _web_build.build_frontend() is False
assert "npm was not found" in echo.call_args[0][0]
def test_successful_build_returns_true(self, web: Path) -> None:
run = MagicMock(return_value=MagicMock(returncode=0))
with (
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"),
patch(f"{_PKG}.subprocess.run", run),
patch(f"{_PKG}._echo"),
):
assert _web_build.build_frontend() is True
assert run.call_args[0][0] == ["/usr/bin/npm", "run", "build"]
assert run.call_args[1]["cwd"] == web
def test_failed_build_prints_output_and_returns_false(self, web: Path) -> None:
result = MagicMock(returncode=1, stdout=" out \n", stderr=" err \n")
with (
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"),
patch(f"{_PKG}.subprocess.run", return_value=result),
patch(f"{_PKG}._echo") as echo,
):
assert _web_build.build_frontend() is False
printed = [call[0][0] for call in echo.call_args_list]
assert any("FAILED" in line for line in printed)
assert "out" in printed
assert "err" in printed
def test_timeout_propagates(self, web: Path) -> None:
# A hung build must not be swallowed into "bundle is fine".
with (
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"),
patch(
f"{_PKG}.subprocess.run",
side_effect=subprocess.TimeoutExpired("npm", 1),
),
patch(f"{_PKG}._echo"),
pytest.raises(subprocess.TimeoutExpired),
):
_web_build.build_frontend()