mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-07 08:35:12 +02:00
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:
parent
54ae310bf2
commit
ca8d815216
2 changed files with 285 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue