mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-07 08:35:12 +02:00
#15 shipped because unit tests only covered text-mode sha256sum output. This adds a comprehensive parser test matrix (binary mode `*` prefix, mixed, CRLF, BOM, indent, trailing whitespace, multiple stars, empty, comment-only, sha256sum -b coreutils format) plus the integration sentinel test_ensure_binary_accepts_binary_mode_checksums that reproduces #15 against the live wire format. Also covered for the first time: - _resolve_asset_url public/private branches, auth header propagation, asset-missing failure, HTTP 4xx propagation - _download_file 200/404/500, parent mkdir, auth on api.github.com only (not leaking to CDN URLs) - cache_root / cache_dir_for_version path shape and version isolation - _parse_owner_repo malformed inputs and dash/underscore/dot repo names ARCHIVE_NAME case-matrix (uppercase platform, lowercase machine), unsupported arch rejection (i386, ppc64le, arm64), unsupported platform rejection (darwin, freebsd), BINARY_ENTRY_REL <-> ARCHIVE_NAME invariant, RELEASE_URL_TEMPLATE shape (https, placeholders, owner pointer). New e2e tests (marker `e2e`, excluded by default): clean venv install, fetch against live release, binary launch, real-site Playwright sanity. This is the test suite that would have caught #15 end-to-end before publish. Stats: 275 -> 327 unit tests (+52), 0 -> 6 e2e tests. Controprova: rolling back the parser fix makes 9 of the new tests fail with the exact "no SHA256 for ..." error from #15.
834 lines
32 KiB
Python
834 lines
32 KiB
Python
import hashlib
|
|
import io
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import requests
|
|
import responses
|
|
|
|
from invisible_playwright.constants import BINARY_VERSION, RELEASE_URL_TEMPLATE
|
|
from invisible_playwright.download import (
|
|
_download_file,
|
|
_extract,
|
|
_github_token,
|
|
_parse_checksums,
|
|
_parse_owner_repo,
|
|
_resolve_asset_url,
|
|
_sha256_file,
|
|
cache_dir_for_version,
|
|
cache_root,
|
|
ensure_binary,
|
|
)
|
|
|
|
|
|
def _make_zip(path: Path, inner_name: str, payload: bytes) -> bytes:
|
|
import zipfile
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zf.writestr(inner_name, payload)
|
|
data = buf.getvalue()
|
|
path.write_bytes(data)
|
|
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):
|
|
"""Full path: cache miss -> HTTP GET -> SHA256 check -> extract -> return path."""
|
|
cache = tmp_path / "cache"
|
|
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
|
|
|
archive_path = tmp_path / "archive.zip"
|
|
archive_bytes = _make_zip(archive_path, "firefox.exe", b"PEX!")
|
|
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,
|
|
content_type="application/zip")
|
|
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")
|
|
|
|
path = ensure_binary()
|
|
assert Path(path).exists()
|
|
assert Path(path).name == "firefox.exe"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_ensure_binary_rejects_sha_mismatch(tmp_path, monkeypatch):
|
|
cache = tmp_path / "cache"
|
|
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
|
archive_path = tmp_path / "archive.zip"
|
|
archive_bytes = _make_zip(archive_path, "firefox.exe", b"PEX!")
|
|
wrong_sha = "0" * 64
|
|
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"{wrong_sha} {asset}\n", status=200)
|
|
|
|
monkeypatch.setattr("sys.platform", "win32")
|
|
import platform
|
|
monkeypatch.setattr(platform, "machine", lambda: "AMD64")
|
|
|
|
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
|
|
|
|
|
|
# DL3 regression — issue #15 (LostBoxArt).
|
|
# GNU coreutils `sha256sum` (and `shasum -b`) print filenames in BINARY MODE
|
|
# with a leading `*`: "hash *filename". The parser used parts[-1] verbatim
|
|
# so the key became "*filename" and lookups by bare filename returned None,
|
|
# raising `RuntimeError: no SHA256 for {asset}` on every first-time fetch.
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_strips_star_prefix_binary_mode():
|
|
"""`sha256sum -b` format (default on Linux when reading actual files)."""
|
|
text = "abc123 *firefox.tar.gz\n"
|
|
out = _parse_checksums(text)
|
|
assert out == {"firefox.tar.gz": "abc123"}, (
|
|
"binary-mode '*' prefix must be stripped from the filename key"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_handles_mixed_binary_and_text_mode():
|
|
"""A single checksums.txt with one binary-mode line and one text-mode line.
|
|
Both keys must be normalized (no `*` prefix) so consumers can use the bare
|
|
filename as the lookup key regardless of how each line was produced."""
|
|
text = (
|
|
"aaa111 *firefox-win.zip\n"
|
|
"bbb222 firefox-linux.tar.gz\n"
|
|
)
|
|
out = _parse_checksums(text)
|
|
assert out == {"firefox-win.zip": "aaa111", "firefox-linux.tar.gz": "bbb222"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_handles_multiple_leading_stars():
|
|
"""`.lstrip("*")` strips any run of leading asterisks. Not a real sha256sum
|
|
format but defensive — guarantees no `*` survives in any key."""
|
|
text = "abc123 **doubled.zip\n"
|
|
out = _parse_checksums(text)
|
|
assert "doubled.zip" in out
|
|
assert "**doubled.zip" not in out
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_handles_crlf_line_endings():
|
|
"""sha256sum.exe on Windows writes CRLF. The .strip() on each line should
|
|
consume the \\r so the key doesn't end up as 'firefox.zip\\r'."""
|
|
text = "abc123 *firefox.zip\r\ndef456 other.tar.gz\r\n"
|
|
out = _parse_checksums(text)
|
|
assert out == {"firefox.zip": "abc123", "other.tar.gz": "def456"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_handles_utf8_bom_at_start():
|
|
"""Some Windows tools prepend a UTF-8 BOM. The first line shouldn't be lost."""
|
|
text = "abc123 *firefox.zip\n"
|
|
out = _parse_checksums(text)
|
|
# The BOM stays attached to the hash field as a non-fatal artifact;
|
|
# what matters is that the FILENAME key is parsed and normalized.
|
|
keys = list(out.keys())
|
|
assert "firefox.zip" in keys, f"BOM caused first line to be lost: keys={keys}"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_handles_indented_lines():
|
|
"""Leading whitespace on a data line must not break parsing."""
|
|
text = " abc123 *indented.zip\n"
|
|
out = _parse_checksums(text)
|
|
assert out == {"indented.zip": "abc123"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_handles_trailing_whitespace():
|
|
"""Trailing spaces on a line shouldn't end up in the key."""
|
|
text = "abc123 *trailing.zip \n"
|
|
out = _parse_checksums(text)
|
|
# After .strip() the trailing spaces are gone, so the key is clean
|
|
assert out == {"trailing.zip": "abc123"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_real_world_sha256sum_b_output(tmp_path):
|
|
"""End-to-end: invoke the actual `sha256sum` (or its Python equivalent)
|
|
on a real file and verify the parser handles that output verbatim.
|
|
|
|
We can't depend on sha256sum being on PATH on Windows, so we synthesize
|
|
the exact byte sequence that GNU coreutils 9.x produces."""
|
|
fake_archive = tmp_path / "release.tar.gz"
|
|
fake_archive.write_bytes(b"some content")
|
|
sha = hashlib.sha256(fake_archive.read_bytes()).hexdigest()
|
|
# Exact format coreutils prints in binary mode (default for files):
|
|
# "<hash><SP>*<filename>\n"
|
|
coreutils_output = f"{sha} *{fake_archive.name}\n"
|
|
|
|
out = _parse_checksums(coreutils_output)
|
|
assert out == {"release.tar.gz": sha}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_text_mode_two_space_separator():
|
|
"""`sha256sum --text` format uses two spaces. Must also parse cleanly
|
|
and the key must be identical to the binary-mode case."""
|
|
text = "abc123 textmode.zip\n"
|
|
out = _parse_checksums(text)
|
|
assert out == {"textmode.zip": "abc123"}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_empty_file_returns_empty_dict():
|
|
assert _parse_checksums("") == {}
|
|
assert _parse_checksums("\n\n\n") == {}
|
|
assert _parse_checksums(" \n\t\n") == {}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_checksums_all_comment_file_returns_empty_dict():
|
|
"""A file with only comments shouldn't crash and shouldn't produce keys."""
|
|
text = "# generated by release script\n# 2026-05-20\n"
|
|
assert _parse_checksums(text) == {}
|
|
|
|
|
|
# DL3 regression — full integration via ensure_binary: confirm the parser
|
|
# bug from #15 cannot regress when the live release format is mimicked exactly.
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_ensure_binary_accepts_binary_mode_checksums(tmp_path, monkeypatch):
|
|
"""Reproduce the EXACT format the GitHub release ships:
|
|
<sha> *<filename>
|
|
Before the #15 fix this raised
|
|
RuntimeError: no SHA256 for {asset} in checksums.txt
|
|
even though the asset and SHA were both present."""
|
|
cache = tmp_path / "cache"
|
|
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
|
|
|
archive_path = tmp_path / "archive.zip"
|
|
archive_bytes = _make_zip(archive_path, "firefox.exe", b"PEX!")
|
|
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/"
|
|
f"{BINARY_VERSION}/{asset}"
|
|
)
|
|
url_sums = (
|
|
f"https://github.com/feder-cr/invisible_playwright/releases/download/"
|
|
f"{BINARY_VERSION}/checksums.txt"
|
|
)
|
|
|
|
responses.add(responses.GET, url_archive, body=archive_bytes, status=200,
|
|
content_type="application/zip")
|
|
# Binary-mode format (note the `*`): regression sentinel for #15.
|
|
responses.add(
|
|
responses.GET, url_sums,
|
|
body=f"{archive_sha} *{asset}\n",
|
|
status=200,
|
|
)
|
|
|
|
# Force the platform branch the test mocks:
|
|
monkeypatch.setattr("sys.platform", "win32")
|
|
out = ensure_binary()
|
|
# No RuntimeError means the parser accepted the `*`-prefixed key.
|
|
assert out.exists()
|
|
|
|
|
|
# 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()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Linux platform tests — exercise the tar.gz extraction path. Mirrors
|
|
# the Windows .zip tests above so both archive formats are covered.
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_ensure_binary_downloads_and_verifies_linux(tmp_path, monkeypatch):
|
|
"""Linux happy path: tar.gz download → SHA256 check → extract → return path."""
|
|
cache = tmp_path / "cache"
|
|
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
|
|
|
archive_path = tmp_path / "archive.tar.gz"
|
|
archive_bytes = _make_targz(archive_path, "firefox", b"ELF!")
|
|
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
|
from invisible_playwright.constants import ARCHIVE_NAME
|
|
asset = ARCHIVE_NAME("linux", "x86_64")
|
|
|
|
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,
|
|
content_type="application/gzip")
|
|
responses.add(responses.GET, url_sums,
|
|
body=f"{archive_sha} {asset}\n", status=200)
|
|
|
|
monkeypatch.setattr("sys.platform", "linux")
|
|
import platform
|
|
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
|
|
|
|
path = ensure_binary()
|
|
assert Path(path).exists()
|
|
assert Path(path).name == "firefox"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_ensure_binary_rejects_sha_mismatch_linux(tmp_path, monkeypatch):
|
|
"""Linux SHA mismatch must raise — the tar.gz path runs the same
|
|
verifier as the .zip path, so a corrupted archive is rejected before
|
|
extraction regardless of platform."""
|
|
cache = tmp_path / "cache"
|
|
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
|
archive_path = tmp_path / "archive.tar.gz"
|
|
archive_bytes = _make_targz(archive_path, "firefox", b"ELF!")
|
|
wrong_sha = "0" * 64
|
|
from invisible_playwright.constants import ARCHIVE_NAME
|
|
asset = ARCHIVE_NAME("linux", "x86_64")
|
|
|
|
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"{wrong_sha} {asset}\n", status=200)
|
|
|
|
monkeypatch.setattr("sys.platform", "linux")
|
|
import platform
|
|
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
|
|
|
|
with pytest.raises(RuntimeError, match="SHA256"):
|
|
ensure_binary()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_ensure_binary_cache_hit_skips_http_linux(tmp_path, monkeypatch):
|
|
"""Linux cache hit short-circuits before any HTTP. Looks for the
|
|
``firefox`` entry (not ``firefox.exe``) per ``BINARY_ENTRY_REL``."""
|
|
cache = tmp_path / "cache"
|
|
version_dir = cache / BINARY_VERSION
|
|
version_dir.mkdir(parents=True)
|
|
pre_cached = version_dir / "firefox"
|
|
pre_cached.write_text("cached-content")
|
|
|
|
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
|
monkeypatch.setattr("sys.platform", "linux")
|
|
import platform
|
|
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
|
|
|
|
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"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_ensure_binary_missing_entry_after_extract_raises_linux(tmp_path, monkeypatch):
|
|
"""Linux post-extract sanity check: if the tar.gz lacks a ``firefox``
|
|
entry, raise rather than returning a non-existent path. Mirrors the
|
|
Windows test and guards against an upstream release artifact regression."""
|
|
cache = tmp_path / "cache"
|
|
monkeypatch.setattr("invisible_playwright.download.cache_root", lambda: cache)
|
|
|
|
archive_path = tmp_path / "archive.tar.gz"
|
|
# tar.gz without ``firefox`` inside
|
|
archive_bytes = _make_targz(archive_path, "other.bin", b"X")
|
|
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
|
from invisible_playwright.constants import ARCHIVE_NAME
|
|
asset = ARCHIVE_NAME("linux", "x86_64")
|
|
|
|
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", "linux")
|
|
import platform
|
|
monkeypatch.setattr(platform, "machine", lambda: "x86_64")
|
|
|
|
with pytest.raises(RuntimeError, match="binary not found after extraction"):
|
|
ensure_binary()
|
|
|
|
|
|
# ========================================================================== #
|
|
# _resolve_asset_url — public-repo direct URL vs private-repo API resolution
|
|
# ========================================================================== #
|
|
# This function chooses between two code paths based on whether a GitHub
|
|
# token is set. Both paths produce a downloadable URL but via different
|
|
# mechanisms, and a regression here would surface as 404 / 403 / wrong
|
|
# binary downloaded.
|
|
|
|
@pytest.mark.unit
|
|
def test_resolve_asset_url_public_returns_direct_url(monkeypatch):
|
|
"""No token → return the direct releases/download URL verbatim."""
|
|
monkeypatch.delenv("STEALTHFOX_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
url = _resolve_asset_url("firefox-4", "thing.zip")
|
|
assert url == RELEASE_URL_TEMPLATE.format(tag="firefox-4", asset="thing.zip")
|
|
assert "api.github.com" not in url # public path must skip the API
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_resolve_asset_url_public_url_format_is_stable(monkeypatch):
|
|
"""The exact URL shape is what GitHub clients have learned to cache.
|
|
Changing it without bumping BINARY_VERSION would 404 on first fetch
|
|
for every existing user — guard against accidental drift."""
|
|
monkeypatch.delenv("STEALTHFOX_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
url = _resolve_asset_url("firefox-4", "abc.tar.gz")
|
|
assert url == (
|
|
"https://github.com/feder-cr/invisible_playwright/releases/"
|
|
"download/firefox-4/abc.tar.gz"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_resolve_asset_url_private_uses_api_with_token(monkeypatch):
|
|
"""Token set → hit the API and return the asset.url (which 302s with
|
|
Accept: application/octet-stream). The direct release URL would 404
|
|
for a private repo even with the token in headers."""
|
|
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_fake")
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
|
|
api_url = (
|
|
"https://api.github.com/repos/feder-cr/invisible_playwright"
|
|
"/releases/tags/firefox-4"
|
|
)
|
|
responses.add(
|
|
responses.GET, api_url,
|
|
json={"assets": [
|
|
{"name": "other.zip", "url": "https://api.github.com/.../1"},
|
|
{"name": "wanted.zip", "url": "https://api.github.com/.../2"},
|
|
]},
|
|
status=200,
|
|
)
|
|
url = _resolve_asset_url("firefox-4", "wanted.zip")
|
|
assert url == "https://api.github.com/.../2"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_resolve_asset_url_private_raises_when_asset_missing(monkeypatch):
|
|
"""If the asset name isn't on the release, raise — better to fail fast
|
|
with the asset name in the message than to download something else."""
|
|
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_fake")
|
|
api_url = (
|
|
"https://api.github.com/repos/feder-cr/invisible_playwright"
|
|
"/releases/tags/firefox-4"
|
|
)
|
|
responses.add(
|
|
responses.GET, api_url,
|
|
json={"assets": [{"name": "other.zip", "url": "x"}]},
|
|
status=200,
|
|
)
|
|
with pytest.raises(RuntimeError, match="not-here.zip"):
|
|
_resolve_asset_url("firefox-4", "not-here.zip")
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_resolve_asset_url_private_propagates_api_4xx(monkeypatch):
|
|
"""If the API returns 404 (release doesn't exist) or 401 (bad token),
|
|
don't swallow it silently — raise so the user sees the real reason."""
|
|
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_fake")
|
|
api_url = (
|
|
"https://api.github.com/repos/feder-cr/invisible_playwright"
|
|
"/releases/tags/firefox-99"
|
|
)
|
|
responses.add(responses.GET, api_url, status=404)
|
|
with pytest.raises(requests.HTTPError):
|
|
_resolve_asset_url("firefox-99", "thing.zip")
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_resolve_asset_url_private_sends_auth_header(monkeypatch):
|
|
"""The API call MUST include `Authorization: token <ghp_...>`, otherwise
|
|
a private repo returns 404 and the user thinks the release is missing."""
|
|
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_secret")
|
|
api_url = (
|
|
"https://api.github.com/repos/feder-cr/invisible_playwright"
|
|
"/releases/tags/firefox-4"
|
|
)
|
|
|
|
captured = {}
|
|
def callback(request):
|
|
captured["auth"] = request.headers.get("Authorization")
|
|
return (200, {}, '{"assets":[{"name":"x.zip","url":"https://x/y"}]}')
|
|
responses.add_callback(responses.GET, api_url, callback=callback,
|
|
content_type="application/json")
|
|
_resolve_asset_url("firefox-4", "x.zip")
|
|
assert captured["auth"] == "token ghp_secret"
|
|
|
|
|
|
# ========================================================================== #
|
|
# _download_file — file streaming + error propagation
|
|
# ========================================================================== #
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_download_file_writes_full_payload_to_disk(tmp_path):
|
|
"""A 200 OK returns the full body; the file on disk matches byte-for-byte."""
|
|
url = "https://example.com/some-large.bin"
|
|
payload = bytes(range(256)) * 1024 # 256 KB, varied bytes
|
|
responses.add(responses.GET, url, body=payload, status=200)
|
|
|
|
dst = tmp_path / "downloaded.bin"
|
|
_download_file(url, dst)
|
|
assert dst.exists()
|
|
assert dst.read_bytes() == payload
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_download_file_creates_parent_directories(tmp_path):
|
|
"""The dst's parent may not exist yet — _download_file is expected to
|
|
mkdir -p before writing. Without this, the first fetch on a clean
|
|
machine raises FileNotFoundError because the cache dir doesn't exist."""
|
|
url = "https://example.com/x.bin"
|
|
responses.add(responses.GET, url, body=b"data", status=200)
|
|
|
|
deep = tmp_path / "a" / "b" / "c" / "x.bin"
|
|
_download_file(url, deep)
|
|
assert deep.exists()
|
|
assert deep.read_bytes() == b"data"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_download_file_propagates_http_404(tmp_path):
|
|
"""404s from the CDN must raise — silent 404 → empty file → SHA mismatch
|
|
is a much worse failure mode."""
|
|
url = "https://example.com/missing.bin"
|
|
responses.add(responses.GET, url, status=404)
|
|
with pytest.raises(requests.HTTPError):
|
|
_download_file(url, tmp_path / "out.bin")
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_download_file_propagates_http_500(tmp_path):
|
|
"""Server errors must surface, not be swallowed as 'empty download'."""
|
|
url = "https://example.com/broken.bin"
|
|
responses.add(responses.GET, url, status=500)
|
|
with pytest.raises(requests.HTTPError):
|
|
_download_file(url, tmp_path / "out.bin")
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_download_file_adds_auth_for_api_urls(monkeypatch, tmp_path):
|
|
"""When downloading from api.github.com (private-repo flow), the
|
|
request MUST include `Authorization: token <...>` and
|
|
`Accept: application/octet-stream` — otherwise the API returns the
|
|
asset JSON instead of the binary."""
|
|
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_secret")
|
|
url = "https://api.github.com/repos/x/y/releases/assets/123"
|
|
|
|
captured = {}
|
|
def callback(request):
|
|
captured["auth"] = request.headers.get("Authorization")
|
|
captured["accept"] = request.headers.get("Accept")
|
|
return (200, {}, b"BIN!")
|
|
responses.add_callback(responses.GET, url, callback=callback)
|
|
|
|
_download_file(url, tmp_path / "out.bin")
|
|
assert captured["auth"] == "token ghp_secret"
|
|
assert captured["accept"] == "application/octet-stream"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@responses.activate
|
|
def test_download_file_does_not_send_auth_for_non_api_urls(monkeypatch, tmp_path):
|
|
"""Public-repo flow hits github.com/.../releases/download/... directly.
|
|
Sending an auth header to that URL is unnecessary and would leak the
|
|
token in CDN access logs."""
|
|
monkeypatch.setenv("STEALTHFOX_GITHUB_TOKEN", "ghp_secret")
|
|
url = "https://github.com/feder-cr/invisible_playwright/releases/download/firefox-4/x.zip"
|
|
|
|
captured = {}
|
|
def callback(request):
|
|
captured["auth"] = request.headers.get("Authorization")
|
|
return (200, {}, b"BIN!")
|
|
responses.add_callback(responses.GET, url, callback=callback)
|
|
|
|
_download_file(url, tmp_path / "out.bin")
|
|
assert captured["auth"] is None, (
|
|
"Auth header leaked to a public CDN URL — would expose the token "
|
|
"in GitHub's access logs."
|
|
)
|
|
|
|
|
|
# ========================================================================== #
|
|
# cache_root + cache_dir_for_version — path resolution
|
|
# ========================================================================== #
|
|
|
|
@pytest.mark.unit
|
|
def test_cache_root_returns_path():
|
|
"""Must return a Path, not a string — downstream code uses .mkdir() etc."""
|
|
p = cache_root()
|
|
assert isinstance(p, Path)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cache_root_contains_package_name():
|
|
"""The cache dir should be identifiable as ours so users can `rm -rf`
|
|
it without nuking other tools' caches."""
|
|
p = cache_root()
|
|
assert "invisible-playwright" in str(p).lower()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cache_dir_for_version_appends_version_segment():
|
|
"""Each binary version gets its own subdir so multiple versions can
|
|
coexist (useful for downgrade / A-B testing)."""
|
|
p = cache_dir_for_version("firefox-99")
|
|
assert p.name == "firefox-99"
|
|
assert p.parent == cache_root()
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cache_dir_for_version_defaults_to_current_binary_version():
|
|
"""No-arg call uses the pinned BINARY_VERSION."""
|
|
p = cache_dir_for_version()
|
|
assert p.name == BINARY_VERSION
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_cache_dir_isolation_between_versions():
|
|
"""firefox-3 and firefox-4 must NEVER share a directory — extraction
|
|
would clobber one with the other and break downgrade."""
|
|
a = cache_dir_for_version("firefox-3")
|
|
b = cache_dir_for_version("firefox-4")
|
|
assert a != b
|
|
assert a.parent == b.parent # but they share the same root
|
|
|
|
|
|
# ========================================================================== #
|
|
# _parse_owner_repo — more edge cases
|
|
# ========================================================================== #
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_owner_repo_extracts_from_canonical_template():
|
|
"""Must work against the exact template stored in constants.py."""
|
|
owner, repo = _parse_owner_repo(RELEASE_URL_TEMPLATE)
|
|
assert owner and repo # something extracted
|
|
assert "/" not in owner and "/" not in repo # no slashes in either segment
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.parametrize("bad_template", [
|
|
"http://github.com/x/y/releases/", # http, not https
|
|
"https://gitlab.com/x/y/releases/", # wrong host
|
|
"https://github.com/onlyone/releases/", # missing repo segment
|
|
"", # empty
|
|
"github.com/x/y/releases/", # missing scheme
|
|
])
|
|
def test_parse_owner_repo_rejects_malformed_urls(bad_template):
|
|
"""Any URL that doesn't match the canonical shape must raise — silent
|
|
None/empty extraction would build broken API URLs and confuse the user."""
|
|
with pytest.raises(RuntimeError, match="cannot parse"):
|
|
_parse_owner_repo(bad_template)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_parse_owner_repo_handles_repos_with_dashes_and_underscores():
|
|
"""Repo names with -, _, . are valid on GitHub; the regex must accept them."""
|
|
owner, repo = _parse_owner_repo(
|
|
"https://github.com/my-org/my_cool.repo/releases/download/x/y.zip"
|
|
)
|
|
assert owner == "my-org"
|
|
assert repo == "my_cool.repo"
|