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:
+458
-1
@@ -4,15 +4,20 @@ import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import responses
|
||||
|
||||
from invisible_playwright.constants import BINARY_VERSION
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -161,6 +166,166 @@ def test_parse_checksums_uses_last_token_as_filename():
|
||||
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):
|
||||
@@ -375,3 +540,295 @@ def test_ensure_binary_missing_entry_after_extract_raises_linux(tmp_path, monkey
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user