mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 13:27:33 +01:00
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
159 lines
5.9 KiB
Python
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()
|