test(cli, download): add 17 gap-coverage tests for Phase 7

CLI: clear-cache (existing + missing), path (ok + error), fetch happy
path, no/unknown subcommand error paths. Calls cli.main() directly
instead of subprocess to keep tests fast and capture stderr cleanly.

Download: cache hit skips HTTP, tar.gz extraction, comment/blank
checksum lines, unknown archive format, missing entry after extract,
unsupported platform. Also covers pure helpers _parse_owner_repo,
_sha256_file, and _github_token env precedence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chrissbaumann 2026-05-14 12:35:57 +02:00
parent 54ae310bf2
commit ca8d815216
2 changed files with 285 additions and 2 deletions

View file

@ -1,8 +1,11 @@
import subprocess
import sys
from pathlib import Path
import pytest
from invisible_playwright import cli
@pytest.mark.unit
def test_version_subcommand():
@ -24,3 +27,96 @@ def test_help_subcommand():
assert "fetch" in r.stdout
assert "path" in r.stdout
assert "clear-cache" in r.stdout
# CL1: clear-cache with existing cache prints "removed:" + path
@pytest.mark.unit
def test_clear_cache_with_existing_cache(tmp_path, monkeypatch, capsys):
cache = tmp_path / "existing-cache"
cache.mkdir()
(cache / "marker").write_text("x")
monkeypatch.setattr("invisible_playwright.cli.cache_root", lambda: cache)
rc = cli.main(["clear-cache"])
captured = capsys.readouterr()
assert rc == 0
assert captured.out.startswith("removed:")
assert str(cache) in captured.out
assert not cache.exists()
# CL2: clear-cache with no cache prints "nothing to remove:"
@pytest.mark.unit
def test_clear_cache_with_no_cache(tmp_path, monkeypatch, capsys):
cache = tmp_path / "missing-cache"
assert not cache.exists()
monkeypatch.setattr("invisible_playwright.cli.cache_root", lambda: cache)
rc = cli.main(["clear-cache"])
captured = capsys.readouterr()
assert rc == 0
assert captured.out.startswith("nothing to remove:")
assert str(cache) in captured.out
# CL3: path when binary exists prints path, exit 0
@pytest.mark.unit
def test_path_subcommand_when_binary_exists(tmp_path, monkeypatch, capsys):
fake_binary = tmp_path / "firefox.exe"
fake_binary.write_text("x")
monkeypatch.setattr("invisible_playwright.cli.ensure_binary", lambda: fake_binary)
rc = cli.main(["path"])
captured = capsys.readouterr()
assert rc == 0
assert str(fake_binary) in captured.out
assert captured.err == ""
# CL4: path when binary missing prints to stderr, exit 1
@pytest.mark.unit
def test_path_subcommand_when_binary_missing(monkeypatch, capsys):
def boom():
raise RuntimeError("download failed")
monkeypatch.setattr("invisible_playwright.cli.ensure_binary", boom)
rc = cli.main(["path"])
captured = capsys.readouterr()
assert rc == 1
assert "error:" in captured.err
assert "download failed" in captured.err
assert captured.out == ""
# CL5: no subcommand → argparse error, exit != 0
@pytest.mark.unit
def test_no_subcommand_errors():
with pytest.raises(SystemExit) as exc_info:
cli.main([])
assert exc_info.value.code != 0
# CL6: unknown subcommand → argparse error
@pytest.mark.unit
def test_unknown_subcommand_errors():
with pytest.raises(SystemExit) as exc_info:
cli.main(["bogus"])
assert exc_info.value.code != 0
# Extra: fetch happy path with mocked ensure_binary
@pytest.mark.unit
def test_fetch_subcommand_prints_path(tmp_path, monkeypatch, capsys):
fake_binary = tmp_path / "firefox.exe"
fake_binary.write_text("x")
monkeypatch.setattr("invisible_playwright.cli.ensure_binary", lambda: fake_binary)
rc = cli.main(["fetch"])
captured = capsys.readouterr()
assert rc == 0
assert str(fake_binary) in captured.out

View file

@ -1,15 +1,23 @@
import hashlib
import io
import tarfile
from pathlib import Path
import pytest
import responses
from invisible_playwright.constants import BINARY_VERSION
from invisible_playwright.download import ensure_binary
from invisible_playwright.download import (
_extract,
_github_token,
_parse_checksums,
_parse_owner_repo,
_sha256_file,
ensure_binary,
)
def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes:
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
@ -19,6 +27,17 @@ def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes:
return data
def _make_targz(path: Path, inner_name: str, payload: bytes) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
info = tarfile.TarInfo(name=inner_name)
info.size = len(payload)
tf.addfile(info, io.BytesIO(payload))
data = buf.getvalue()
path.write_bytes(data)
return data
@pytest.mark.unit
@responses.activate
def test_ensure_binary_downloads_and_verifies(tmp_path, monkeypatch):
@ -71,3 +90,171 @@ def test_ensure_binary_rejects_sha_mismatch(tmp_path, monkeypatch):
with pytest.raises(RuntimeError, match="SHA256"):
ensure_binary()
# DL1: cache hit returns cached path without HTTP call
@pytest.mark.unit
def test_ensure_binary_cache_hit_skips_http(tmp_path, monkeypatch):
"""When the binary already exists in cache, ensure_binary returns immediately
without issuing any HTTP request."""
cache = tmp_path / "cache"
version_dir = cache / BINARY_VERSION
version_dir.mkdir(parents=True)
pre_cached = version_dir / "firefox.exe"
pre_cached.write_text("cached-content")
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
monkeypatch.setattr("sys.platform", "win32")
import platform
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
def _fail_get(*args, **kwargs):
raise AssertionError("HTTP must not be called on cache hit")
monkeypatch.setattr("invisible_playwright.download.requests.get", _fail_get)
path = ensure_binary()
assert path == pre_cached
assert path.read_text() == "cached-content"
# DL2: .tar.gz extraction works
@pytest.mark.unit
def test_extract_tar_gz(tmp_path):
"""_extract handles .tar.gz archives and unpacks the inner files."""
archive = tmp_path / "bundle.tar.gz"
_make_targz(archive, "firefox", b"ELF!")
dst = tmp_path / "out"
_extract(archive, dst)
assert (dst / "firefox").exists()
assert (dst / "firefox").read_bytes() == b"ELF!"
# DL3: checksum line with comment (#) is skipped
@pytest.mark.unit
def test_parse_checksums_skips_comments_and_blanks():
text = (
"# this is a comment\n"
"\n"
" # indented comment\n"
"abc123 file1.zip\n"
"def456 file2.tar.gz\n"
)
out = _parse_checksums(text)
assert out == {"file1.zip": "abc123", "file2.tar.gz": "def456"}
# DL3 sibling: malformed lines (fewer than 2 fields) are silently ignored
@pytest.mark.unit
def test_parse_checksums_ignores_single_field_lines():
text = "loner\nabc123 file.zip\n"
out = _parse_checksums(text)
assert out == {"file.zip": "abc123"}
# DL3 sibling: last field is treated as filename (supports trailing whitespace tokens)
@pytest.mark.unit
def test_parse_checksums_uses_last_token_as_filename():
text = "abc123 some/nested/file.zip\n"
out = _parse_checksums(text)
assert "some/nested/file.zip" in out
# DL4: unknown archive format (.rar) raises RuntimeError
@pytest.mark.unit
def test_extract_unknown_format_raises(tmp_path):
archive = tmp_path / "thing.rar"
archive.write_bytes(b"not-a-real-rar")
dst = tmp_path / "out"
with pytest.raises(RuntimeError, match="unknown archive format"):
_extract(archive, dst)
# DL5: binary not found after extraction raises RuntimeError
@pytest.mark.unit
@responses.activate
def test_ensure_binary_missing_entry_after_extract_raises(tmp_path, monkeypatch):
"""If the archive extracts cleanly but the expected entry isn't present,
ensure_binary raises RuntimeError."""
cache = tmp_path / "cache"
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
archive_path = tmp_path / "archive.zip"
# zip without firefox.exe inside
archive_bytes = _make_zip(archive_path, "other.bin", b"X")
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
from invisible_playwright.constants import ARCHIVE_NAME
asset = ARCHIVE_NAME("win32", "AMD64")
url_archive = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/{asset}"
url_sums = f"https://github.com/feder-cr/invisible_playwright/releases/download/{BINARY_VERSION}/checksums.txt"
responses.add(responses.GET, url_archive, body=archive_bytes, status=200)
responses.add(responses.GET, url_sums, body=f"{archive_sha} {asset}\n", status=200)
monkeypatch.setattr("sys.platform", "win32")
import platform
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
with pytest.raises(RuntimeError, match="binary not found after extraction"):
ensure_binary()
# Pure helper: _parse_owner_repo
@pytest.mark.unit
def test_parse_owner_repo_valid():
owner, repo = _parse_owner_repo(
"https://github.com/feder-cr/invisible_playwright/releases/download/x/y"
)
assert owner == "feder-cr"
assert repo == "invisible_playwright"
@pytest.mark.unit
def test_parse_owner_repo_invalid_raises():
with pytest.raises(RuntimeError, match="cannot parse owner/repo"):
_parse_owner_repo("not-a-github-url")
# Pure helper: _sha256_file matches hashlib output
@pytest.mark.unit
def test_sha256_file_matches_hashlib(tmp_path):
payload = b"hello world"
f = tmp_path / "file.bin"
f.write_bytes(payload)
expected = hashlib.sha256(payload).hexdigest()
assert _sha256_file(f) == expected
# _github_token precedence: STEALTHFOX_GITHUB_TOKEN beats GITHUB_TOKEN
@pytest.mark.unit
def test_github_token_stealthfox_wins(monkeypatch):
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "stealth")
monkeypatch.setenv("GITHUB_TOKEN", "generic")
assert _github_token() == "stealth"
@pytest.mark.unit
def test_github_token_falls_back_to_github_token(monkeypatch):
monkeypatch.delenv("STEALTHFOX_GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "generic")
assert _github_token() == "generic"
@pytest.mark.unit
def test_github_token_none_when_unset(monkeypatch):
monkeypatch.delenv("STEALTHFOX_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
assert _github_token() is None
# Bonus coverage: unsupported platform raises NotImplementedError before any HTTP
@pytest.mark.unit
def test_ensure_binary_unsupported_platform_raises(monkeypatch):
monkeypatch.setattr("sys.platform", "darwin")
import platform
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
with pytest.raises(NotImplementedError, match="unsupported platform"):
ensure_binary()