From ca8d8152168e05c6eae7573e162e0f2b90684568 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:35:57 +0200 Subject: [PATCH] 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) --- tests/test_cli.py | 96 +++++++++++++++++++++ tests/test_download.py | 191 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 285 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index b4d09a6..1c72feb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_download.py b/tests/test_download.py index 04df648..baf177c 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -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()