signal-bot/tests/test_tool_imports.py
Krzysztof kuhy Rudnicki a24d0d5905
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
feat: one page to manage the bot, and the tagger folded into it
./web.sh serves health, the configuration and the photo album on
127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8,
TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked +
unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics.

No coverage exclude list, including for main.tsx. This repository treats a
coverage omit as a suppression, and "the DOM bootstrap is awkward to assert"
is the same sentence as "this code is untested" -- so the bootstrap is a shim
thin enough to test in jsdom, and the album view holds its file list apart
from the album so the update path stopped carrying a "what if it is null"
branch that cannot happen. An untestable branch is an untested one.

TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares
typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the
compiler waits for the linter. The freshness gate then caught two pins the
reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a
floating node-version: "24" in CI -- both bumped rather than allowlisted.

Traps the gates found, all of them real:
- .gitignore has a bare lib/, which matches at any depth, so web/src/lib/
  would have been silently untracked. The source lives in src/api/.
- CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in
  CI for its whole life and web.sh would have inherited that. Now ./*.sh.
- tsconfig.json is strict JSON, not JSONC: check-json is generic and
  tsconfig being commentable is a convention, not a requirement.
- The personal-data gate refused an invented phone number in a test, which
  is exactly what it is for.

The album's warning that names a command moved with the command, and every
path that pointed at tool/tagger.py now points at ./web.sh. The historical
mentions stay: they explain why the code is shaped as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 14:54:49 +02:00

112 lines
4.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Every script under tool/ imports, and the launcher answers --help.
This exists because ``tool/tagger.py`` shipped an unconditional ImportError
and survived two releases with it: commit 48957a1 renamed
``pack.default_dir`` to ``default_dirs`` and missed the caller. Nothing could
have caught it. ``tool/`` is outside ``--cov`` in pyproject.toml, mypy ran
with ``args: [signal_bot, fake_signal]``, and ruff lints these files but
cannot resolve a name in another module.
So this is deliberately the cheapest possible test: import each file and, for
the one with a CLI, run it. It adds no coverage burden -- ``tests/`` is not
measured -- and no module-scope ImportError can survive it.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from signal_bot.external import run
TOOL = Path(__file__).resolve().parent.parent / "tool"
def _modules() -> list[Path]:
"""Every Python file under tool/, so a new one is covered automatically."""
return sorted(path for path in TOOL.glob("*.py") if path.name != "__init__.py")
def test_there_is_something_to_check() -> None:
# Guards against the glob silently matching nothing and the whole file
# passing vacuously.
assert _modules()
def _load(stem: str) -> object:
"""Import one tool/ module by path, the way the parametrised test does."""
spec = importlib.util.spec_from_file_location(f"_probe_{stem}", TOOL / f"{stem}.py")
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name)
def test_the_module_imports(path: Path) -> None:
spec = importlib.util.spec_from_file_location(f"_tool_{path.stem}", path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
# tool/ is a directory of scripts, not a package, and a couple of them
# import a sibling by bare name. That resolves in real use -- running
# "python tool/x.py" puts tool/ at sys.path[0] -- so the harness has to
# reproduce it rather than the scripts having to work around it.
sys.path.insert(0, str(TOOL))
try:
spec.loader.exec_module(module)
finally:
sys.path.remove(str(TOOL))
sys.modules.pop(spec.name, None)
def test_the_launcher_answers_help() -> None:
# Through signal_bot.external, like every other subprocess here: S603 is
# suppressed for exactly one file and widening it is what that entry
# exists to prevent.
result = run([sys.executable, str(TOOL / "web.py"), "--help"])
assert result.ok, result.stderr
for flag in ("--import", "--untagged", "--dry-run"):
assert flag in result.stdout
def test_the_bootstrap_reports_what_this_interpreter_cannot_import() -> None:
# The whole decision rests on this: an interpreter that can already
# import everything must be left alone, or every run pays for a venv it
# does not need.
bootstrap = _load("venv_bootstrap")
assert bootstrap.missing() == []
assert bootstrap.missing(("definitely_not_a_real_module",)) == ["definitely_not_a_real_module"]
# A name that is not importable *and* not a valid spec query has to be
# reported, not raised: find_spec raises ValueError for some of them.
assert bootstrap.missing((".relative",)) == [".relative"]
def test_the_bootstrap_module_is_stdlib_only() -> None:
# It runs before .venv exists, so a third-party import here would be the
# same bug one level down.
source = (TOOL / "venv_bootstrap.py").read_text(encoding="utf-8")
for banned in ("import fastapi", "import httpx", "import uvicorn", "from signal_bot"):
assert banned not in source
def test_the_bootstrap_only_installs_when_the_venv_cannot_already_do_it(
tmp_path: Path,
) -> None:
# Measured bug: ensure() tested the *current* interpreter to decide both
# whether to re-exec and whether to install. The interpreter you typed
# never grows a fastapi, so every single launch reinstalled the project
# before re-execing. The two decisions are separate.
bootstrap = _load("venv_bootstrap")
bootstrap.VENV_PYTHON = tmp_path / "nonexistent" / "python"
assert bootstrap.ready() is False
bootstrap.VENV_PYTHON = Path(sys.executable)
assert bootstrap.ready(("sys",)) is True
assert bootstrap.ready(("definitely_not_a_real_module",)) is False