test: fortress coverage for download + constants + e2e
#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.
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
"""End-to-end release tests.
|
||||
|
||||
These exercise the FULL user install path against the LIVE GitHub release.
|
||||
They are slow (download a ~110 MB binary, launch Firefox) and require network
|
||||
access — marked `e2e` so they're excluded from the default suite. Run them
|
||||
BEFORE announcing a release:
|
||||
|
||||
pytest tests/test_release_e2e.py -m e2e -v
|
||||
|
||||
Or to target a specific git revision (default is current HEAD on origin/main):
|
||||
|
||||
INVPW_E2E_REV=v0.1.5 pytest tests/test_release_e2e.py -m e2e -v
|
||||
|
||||
What each test verifies and why it exists:
|
||||
|
||||
test_clean_install_from_git_main:
|
||||
Spawns a fresh venv and pip-installs the wrapper from git HEAD. Confirms
|
||||
the package has no broken metadata, missing deps, or import errors in a
|
||||
pristine environment. Catches the "works on my machine because I already
|
||||
have the dev deps" class of bug.
|
||||
|
||||
test_fetch_against_live_release:
|
||||
After the install, runs `python -m invisible_playwright fetch --force`,
|
||||
which downloads the live tarball + checksums.txt for the pinned
|
||||
BINARY_VERSION from the production GitHub release. This is THE test that
|
||||
would have caught LostBoxArt's #15 — the checksums.txt parser bug only
|
||||
manifested against the real binary-mode format the release ships, not
|
||||
against unit-test mocks.
|
||||
|
||||
test_version_command_after_fetch:
|
||||
Confirms `python -m invisible_playwright --version` resolves the binary
|
||||
and reports the expected `firefox-N` tag. Sanity check that the binary
|
||||
landed in the cache and the wrapper can find it.
|
||||
|
||||
test_playwright_launch_against_real_site (linux-only by default):
|
||||
Launches the patched Firefox under the wrapper, navigates to a stable
|
||||
public URL, and reads a known DOM property. This is the full stack:
|
||||
wrapper init → Firefox launch → Juggler handshake → page.goto →
|
||||
page.evaluate. If anything along the way regresses (Juggler protocol
|
||||
schema drift, prefs typo, sandbox issue, …) this fails loudly.
|
||||
|
||||
The tests use a temp cache dir per run (env var
|
||||
`INVISIBLE_PLAYWRIGHT_CACHE_DIR`) so they never poison the developer's real
|
||||
cache and never get false positives from a previously-cached binary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_URL = "https://github.com/feder-cr/invisible_playwright.git"
|
||||
REV = os.environ.get("INVPW_E2E_REV", "main")
|
||||
|
||||
|
||||
# ---------- helpers --------------------------------------------------------- #
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict | None = None, cwd: Path | None = None,
|
||||
timeout: int = 300, check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a subprocess with full output captured. Fail with both streams shown."""
|
||||
result = subprocess.run(
|
||||
cmd, env=env, cwd=cwd, timeout=timeout,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"{' '.join(cmd)} exited {result.returncode}\n"
|
||||
f"--- stdout ---\n{result.stdout[-3000:]}\n"
|
||||
f"--- stderr ---\n{result.stderr[-3000:]}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _venv_python(venv: Path) -> Path:
|
||||
if os.name == "nt":
|
||||
return venv / "Scripts" / "python.exe"
|
||||
return venv / "bin" / "python"
|
||||
|
||||
|
||||
# ---------- fixtures -------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def workspace() -> Path:
|
||||
"""A single temp dir reused across the module so we don't re-create the
|
||||
venv + re-download the 110 MB tarball for every individual test."""
|
||||
root = Path(tempfile.mkdtemp(prefix="invpw-e2e-"))
|
||||
yield root
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def clean_venv(workspace: Path) -> Path:
|
||||
"""A fresh venv, pip upgraded. Returns its python executable path."""
|
||||
venv_dir = workspace / "venv"
|
||||
_run([sys.executable, "-m", "venv", str(venv_dir)], timeout=180)
|
||||
py = _venv_python(venv_dir)
|
||||
assert py.exists(), f"venv python not found at {py}"
|
||||
_run([str(py), "-m", "pip", "install", "--upgrade", "pip", "--quiet"], timeout=180)
|
||||
return py
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def isolated_cache_env(workspace: Path) -> dict:
|
||||
"""Environment dict pointing the wrapper at a private cache dir so this
|
||||
test never reads or pollutes the developer's real cache."""
|
||||
cache = workspace / "cache"
|
||||
cache.mkdir(exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
env["INVISIBLE_PLAYWRIGHT_CACHE_DIR"] = str(cache)
|
||||
env["XDG_CACHE_HOME"] = str(cache)
|
||||
return env
|
||||
|
||||
|
||||
# ---------- tests ----------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_clean_install_from_git_main(clean_venv: Path):
|
||||
"""The package installs cleanly from git+HTTPS in a pristine venv."""
|
||||
url = f"git+{REPO_URL}@{REV}"
|
||||
_run([str(clean_venv), "-m", "pip", "install", url], timeout=600)
|
||||
|
||||
# Importability check — catches missing __init__ exports, broken syntax,
|
||||
# missing runtime deps.
|
||||
out = _run(
|
||||
[str(clean_venv), "-c",
|
||||
"import invisible_playwright as ip; "
|
||||
"print('OK', ip.__name__)"],
|
||||
timeout=30,
|
||||
)
|
||||
assert "OK invisible_playwright" in out.stdout
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_version_command_reports_wrapper_and_binary(clean_venv: Path):
|
||||
"""`python -m invisible_playwright --version` runs and reports both the
|
||||
wrapper version and the BINARY_VERSION it'll try to fetch."""
|
||||
out = _run(
|
||||
[str(clean_venv), "-m", "invisible_playwright", "--version"],
|
||||
timeout=30,
|
||||
)
|
||||
text = out.stdout + out.stderr
|
||||
assert "firefox-" in text, f"BINARY_VERSION not reported: {text!r}"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_fetch_against_live_release(clean_venv: Path, isolated_cache_env: dict):
|
||||
"""Hit the LIVE GitHub release: download tarball + checksums.txt, parse,
|
||||
SHA256-verify, extract. This is the regression sentinel for #15.
|
||||
|
||||
If checksums.txt is shipped in `*`-prefixed (binary) format and the parser
|
||||
keeps the `*` in the key, this raises
|
||||
RuntimeError: no SHA256 for {asset} in checksums.txt
|
||||
"""
|
||||
out = _run(
|
||||
[str(clean_venv), "-m", "invisible_playwright", "fetch", "--force"],
|
||||
env=isolated_cache_env,
|
||||
timeout=900, # 110 MB download + extract on slow connections
|
||||
)
|
||||
output = out.stdout + out.stderr
|
||||
# Anti-regression for #15: this exact string would surface if the parser
|
||||
# broke again. Spell it out so a future failure is grep-able to the issue.
|
||||
assert "no SHA256 for" not in output, (
|
||||
"Issue #15 regression: parser couldn't find SHA for the asset.\n"
|
||||
f"Output:\n{output[-2000:]}"
|
||||
)
|
||||
assert "SHA256 mismatch" not in output, (
|
||||
"Tarball SHA doesn't match the published checksums.txt — "
|
||||
"either the upload was corrupted or the release was re-packed "
|
||||
"without updating checksums.txt."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_binary_executes_after_fetch(clean_venv: Path, isolated_cache_env: dict):
|
||||
"""After fetch, the binary cache contains a launchable Firefox."""
|
||||
out = _run(
|
||||
[str(clean_venv), "-c",
|
||||
"from invisible_playwright.download import ensure_binary; "
|
||||
"p = ensure_binary(); print('BINARY', p)"],
|
||||
env=isolated_cache_env,
|
||||
timeout=60,
|
||||
)
|
||||
binary_line = [l for l in out.stdout.splitlines() if l.startswith("BINARY ")]
|
||||
assert binary_line, f"ensure_binary() didn't print path: {out.stdout!r}"
|
||||
binary_path = Path(binary_line[0].split(" ", 1)[1])
|
||||
assert binary_path.exists(), f"binary missing: {binary_path}"
|
||||
|
||||
# `firefox --version` exit code is enough; output format differs across
|
||||
# platforms (Win shows nothing on stdout, Linux prints to stdout).
|
||||
# On Linux invoke via WSL when running from Windows.
|
||||
if os.name == "nt" and binary_path.suffix == "":
|
||||
# Linux binary path on Windows host — skip launch, the previous
|
||||
# ensure_binary() already proved cache landed correctly.
|
||||
pytest.skip("Cross-platform binary launch from Windows requires WSL.")
|
||||
r = subprocess.run([str(binary_path), "--version"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
text = (r.stdout + r.stderr).lower()
|
||||
assert "firefox" in text and "150." in text, (
|
||||
f"binary --version didn't report Firefox 150: rc={r.returncode} "
|
||||
f"out={r.stdout!r} err={r.stderr!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.linux_only
|
||||
def test_playwright_launch_against_real_site(clean_venv: Path,
|
||||
isolated_cache_env: dict):
|
||||
"""Full stack: launch the patched Firefox via the wrapper, navigate to a
|
||||
real URL, evaluate JS. Catches Juggler protocol drift, profile-generation
|
||||
bugs, locale handling regressions, prefs typos."""
|
||||
if sys.platform.startswith("win"):
|
||||
pytest.skip("Headless launch path requires display server (skip on Win).")
|
||||
|
||||
script = (
|
||||
"from invisible_playwright import InvisiblePlaywright\n"
|
||||
"with InvisiblePlaywright(headless=True, seed=42) as browser:\n"
|
||||
" ctx = browser.new_context()\n"
|
||||
" page = ctx.new_page()\n"
|
||||
" page.goto('https://example.com', timeout=30000)\n"
|
||||
" title = page.title()\n"
|
||||
" ua = page.evaluate('navigator.userAgent')\n"
|
||||
" print('TITLE=' + title)\n"
|
||||
" print('UA=' + ua)\n"
|
||||
)
|
||||
out = _run([str(clean_venv), "-c", script],
|
||||
env=isolated_cache_env, timeout=180)
|
||||
assert "TITLE=Example Domain" in out.stdout, (
|
||||
f"page.title() didn't return expected text:\n{out.stdout[-1000:]}"
|
||||
)
|
||||
assert "UA=" in out.stdout and "Firefox/150" in out.stdout, (
|
||||
"navigator.userAgent doesn't report Firefox/150 — UA spoofing "
|
||||
f"regression?\n{out.stdout[-1000:]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- meta: verify the test markers themselves work ------------------- #
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_e2e_marker_is_excluded_by_default():
|
||||
"""Sanity check on pyproject.toml's `addopts = '-m not e2e'` — this test
|
||||
only runs when `-m e2e` is passed explicitly. If you're reading this in
|
||||
a normal pytest run, the addopts filter is broken."""
|
||||
assert True
|
||||
Reference in New Issue
Block a user