diff --git a/backend/src/test/e2e/runtime/test_fake_qb_contract.py b/backend/src/test/e2e/runtime/test_fake_qb_contract.py
new file mode 100644
index 00000000..e50e659c
--- /dev/null
+++ b/backend/src/test/e2e/runtime/test_fake_qb_contract.py
@@ -0,0 +1,154 @@
+"""Contract tests for the hermetic qBittorrent HTTP fake."""
+
+import runpy
+import threading
+from pathlib import Path
+
+import httpx
+import pytest
+
+pytestmark = pytest.mark.e2e
+
+
+SERVER_PATH = Path(__file__).resolve().parents[5] / "e2e" / "fake-qb" / "server.py"
+SERVER_MODULE = runpy.run_path(str(SERVER_PATH))
+create_server = SERVER_MODULE["create_server"]
+MATCHING_HASH = SERVER_MODULE["MATCHING_HASH"]
+NEIGHBOUR_HASH = SERVER_MODULE["NEIGHBOUR_HASH"]
+DELETE_FAILURE_HASH = SERVER_MODULE["DELETE_FAILURE_HASH"]
+
+
+@pytest.fixture
+def fake_qb_url():
+ server = create_server("127.0.0.1", 0)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ host, port = server.server_address
+ try:
+ yield f"http://{host}:{port}"
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+
+
+def login(client: httpx.Client) -> None:
+ response = client.post(
+ "/api/v2/auth/login",
+ data={"username": "admin", "password": "adminadmin"},
+ )
+ assert response.status_code == 200
+ assert response.text == "Ok."
+
+
+def test_qb_endpoints_and_successful_delete_update_state(fake_qb_url):
+ with httpx.Client(base_url=fake_qb_url) as client:
+ login(client)
+
+ assert client.get("/api/v2/app/version").text == "v5.2.3"
+ preferences = client.get("/api/v2/app/preferences").json()
+ assert preferences["save_path"] == "D:\\Downloads\\Bangumi"
+ assert {key: preferences[key] for key in ("dht", "pex", "lsd", "upnp")} == {
+ "dht": False,
+ "pex": False,
+ "lsd": False,
+ "upnp": False,
+ }
+
+ torrents = client.get("/api/v2/torrents/info").json()
+ by_hash = {torrent["hash"]: torrent for torrent in torrents}
+ assert by_hash[MATCHING_HASH]["save_path"].endswith("Season 1\\")
+ assert by_hash[NEIGHBOUR_HASH]["save_path"].endswith("Season 10")
+
+ response = client.post(
+ "/api/v2/torrents/delete",
+ data={"hashes": MATCHING_HASH, "deleteFiles": "true"},
+ )
+ assert response.status_code == 200
+
+ state = client.get("/__admin/state").json()
+ remaining = {torrent["hash"] for torrent in state["torrents"]}
+ assert MATCHING_HASH not in remaining
+ assert NEIGHBOUR_HASH in remaining
+
+ assert client.post("/api/v2/auth/logout").status_code == 200
+ assert client.get("/api/v2/torrents/info").status_code == 403
+
+
+def test_forced_delete_failure_is_atomic(fake_qb_url):
+ with httpx.Client(base_url=fake_qb_url) as client:
+ login(client)
+
+ response = client.post(
+ "/api/v2/torrents/delete",
+ data={
+ "hashes": f"{MATCHING_HASH}|{DELETE_FAILURE_HASH}",
+ "deleteFiles": "false",
+ },
+ )
+ assert response.status_code == 500
+
+ state = client.get("/__admin/state").json()
+ remaining = {torrent["hash"] for torrent in state["torrents"]}
+ assert MATCHING_HASH in remaining
+ assert DELETE_FAILURE_HASH in remaining
+
+
+def test_request_journal_preserves_form_shape_and_redacts_secrets(fake_qb_url):
+ with httpx.Client(base_url=fake_qb_url) as client:
+ login(client)
+
+ response = client.post(
+ "/api/v2/unsupported?token=query-secret&plain=query-value",
+ data={
+ "hashes": [MATCHING_HASH, NEIGHBOUR_HASH],
+ "password": "form-secret",
+ "auth_token": "form-token",
+ "plain": "form-value",
+ },
+ headers={
+ "Authorization": "Bearer header-secret",
+ "X-Api-Token": "header-token",
+ },
+ )
+ assert response.status_code == 501
+
+ journal = client.get("/__admin/requests").json()["requests"]
+ login_request = journal[0]
+ assert login_request["form"] == {
+ "username": ["admin"],
+ "password": ["[REDACTED]"],
+ }
+
+ unsupported = journal[-1]
+ assert unsupported["query"] == {
+ "token": ["[REDACTED]"],
+ "plain": ["query-value"],
+ }
+ assert unsupported["form"] == {
+ "hashes": [MATCHING_HASH, NEIGHBOUR_HASH],
+ "password": ["[REDACTED]"],
+ "auth_token": ["[REDACTED]"],
+ "plain": ["form-value"],
+ }
+ assert unsupported["headers"]["Authorization"] == "[REDACTED]"
+ assert unsupported["headers"]["Cookie"] == "[REDACTED]"
+ assert unsupported["headers"]["X-Api-Token"] == "[REDACTED]"
+
+
+def test_admin_reset_restores_presets_and_clears_journal(fake_qb_url):
+ with httpx.Client(base_url=fake_qb_url) as client:
+ login(client)
+ client.post(
+ "/api/v2/torrents/delete",
+ data={"hashes": MATCHING_HASH, "deleteFiles": "true"},
+ )
+
+ response = client.post("/__admin/reset")
+ assert response.status_code == 200
+ assert response.json() == {"ok": True}
+
+ state = client.get("/__admin/state").json()
+ hashes = {torrent["hash"] for torrent in state["torrents"]}
+ assert {MATCHING_HASH, NEIGHBOUR_HASH, DELETE_FAILURE_HASH} <= hashes
+ assert client.get("/__admin/requests").json() == {"requests": []}
diff --git a/backend/src/test/e2e/runtime/test_mock_upstream_contract.py b/backend/src/test/e2e/runtime/test_mock_upstream_contract.py
new file mode 100644
index 00000000..73bb7c99
--- /dev/null
+++ b/backend/src/test/e2e/runtime/test_mock_upstream_contract.py
@@ -0,0 +1,285 @@
+"""Contract tests for the hermetic mock-upstream HTTP service."""
+
+import importlib.util
+import json
+import shutil
+import threading
+from pathlib import Path
+
+import httpx
+import pytest
+
+pytestmark = pytest.mark.e2e
+
+REPO_ROOT = Path(__file__).resolve().parents[5]
+SERVER_PATH = REPO_ROOT / "e2e" / "mock-upstream" / "server.py"
+
+
+def _load_create_server():
+ spec = importlib.util.spec_from_file_location("e2e_mock_upstream", SERVER_PATH)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"Cannot load mock-upstream from {SERVER_PATH}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module.create_server
+
+
+@pytest.fixture
+def fixture_root(tmp_path: Path) -> Path:
+ root = tmp_path / "fixtures"
+ shutil.copytree(REPO_ROOT / "e2e" / "fixtures", root)
+ images = root / "images"
+ images.mkdir()
+ (images / "poster.jpg").write_bytes(b"mock-poster-bytes")
+ return root
+
+
+@pytest.fixture
+def upstream(fixture_root: Path):
+ server = _load_create_server()("127.0.0.1", 0, fixture_root)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ host, port = server.server_address[:2]
+ with httpx.Client(base_url=f"http://{host}:{port}", timeout=5.0) as client:
+ yield client, fixture_root
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+ assert not thread.is_alive()
+
+
+def _activate(client: httpx.Client, name: str) -> None:
+ response = client.put(f"/__admin/scenario/{name}")
+ assert response.status_code == 200
+ assert response.json()["scenario"] == name
+
+
+def test_health_and_admin_lifecycle(upstream):
+ client, _ = upstream
+
+ response = client.get("/health")
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+ response = client.post("/__admin/reset")
+ assert response.status_code == 200
+ assert response.json() == {"ok": True}
+ assert client.get("/__admin/requests").json() == {"requests": []}
+
+ _activate(client, "localized-tv-zh")
+ state = client.get("/__admin/state").json()
+ assert state == {
+ "scenario": "localized-tv-zh",
+ "notifications": [],
+ "request_count": 0,
+ }
+
+ missing = client.put("/__admin/scenario/does-not-exist")
+ assert missing.status_code == 404
+ assert client.get("/__admin/state").json()["scenario"] == "localized-tv-zh"
+
+ reset = client.post("/__admin/reset")
+ assert reset.status_code == 200
+ assert client.get("/__admin/state").json() == {
+ "scenario": None,
+ "notifications": [],
+ "request_count": 0,
+ }
+
+
+def test_tmdb_tv_movie_and_retry_scenarios(upstream):
+ client, _ = upstream
+ client.post("/__admin/reset")
+
+ _activate(client, "localized-tv-zh")
+ tv = client.get(
+ "/tmdb/3/search/tv",
+ params={
+ "query": "Localized Show",
+ "language": "zh-CN",
+ "api_key": "tmdb-secret",
+ },
+ )
+ assert tv.status_code == 200
+ assert tv.json()["results"][0]["name"] == "本地化动画"
+
+ wrong_language = client.get(
+ "/tmdb/3/search/tv",
+ params={"query": "Localized Show", "language": "en-US"},
+ )
+ assert wrong_language.status_code == 200
+ assert wrong_language.json() == {"results": []}
+
+ detail = client.get(
+ "/tmdb/3/tv/1001",
+ params={"language": "zh-CN", "api_key": "tmdb-secret"},
+ )
+ assert detail.status_code == 200
+ assert detail.json()["genres"] == [{"id": 16, "name": "Animation"}]
+ assert (
+ client.get("/tmdb/3/tv/1001", params={"language": "en-US"}).status_code == 404
+ )
+
+ season = client.get(
+ "/tmdb/3/tv/1001/season/1",
+ params={"language": "zh-CN", "api_key": "tmdb-secret"},
+ )
+ assert season.status_code == 200
+ assert season.json()["episodes"][0] == {
+ "episode_number": 1,
+ "air_date": "2026-07-01",
+ }
+ assert (
+ client.get("/tmdb/3/tv/1001/season/1", params={"language": "en-US"}).status_code
+ == 404
+ )
+
+ _activate(client, "localized-movie-jp")
+ movie = client.get(
+ "/tmdb/3/search/movie",
+ params={"query": "Localized Movie", "language": "ja-JP"},
+ )
+ assert movie.status_code == 200
+ assert movie.json()["results"][0]["title"] == "ローカライズ映画"
+ assert client.get(
+ "/tmdb/3/search/tv",
+ params={"query": "Localized Movie", "language": "ja-JP"},
+ ).json() == {"results": []}
+
+ _activate(client, "localized-retry-jp")
+ initial = client.get(
+ "/tmdb/3/search/tv",
+ params={"query": "Retry Localized Show", "language": "ja-JP"},
+ )
+ retried = client.get(
+ "/tmdb/3/search/tv",
+ params={"query": "RetryLocalizedShow", "language": "ja-JP"},
+ )
+ assert initial.json() == {"results": []}
+ assert retried.json()["results"][0]["name"] == "再試行アニメ"
+
+
+def test_static_routes_head_and_single_ranges(upstream):
+ client, fixture_root = upstream
+ media = (fixture_root / "files" / "tiny-media.mkv").read_bytes()
+
+ rss = client.get("/rss/tmdb-tv.xml")
+ assert rss.status_code == 200
+ assert rss.headers["content-type"].startswith("application/rss+xml")
+ assert b"Localized Show" in rss.content
+
+ image = client.get("/images/poster.jpg")
+ assert image.status_code == 200
+ assert image.content == b"mock-poster-bytes"
+
+ player = client.get("/player")
+ assert player.status_code == 200
+ assert player.headers["content-type"].startswith("text/html")
+
+ torrent = client.get("/torrents/tiny-media.torrent")
+ assert torrent.status_code == 200
+ assert torrent.headers["content-type"].startswith("application/x-bittorrent")
+
+ head = client.head("/files/tiny-media.mkv")
+ assert head.status_code == 200
+ assert head.content == b""
+ assert int(head.headers["content-length"]) == len(media)
+ assert head.headers["accept-ranges"] == "bytes"
+
+ partial = client.get("/files/tiny-media.mkv", headers={"Range": "bytes=7-23"})
+ assert partial.status_code == 206
+ assert partial.content == media[7:24]
+ assert partial.headers["content-range"] == f"bytes 7-23/{len(media)}"
+ assert partial.headers["content-length"] == "17"
+
+ suffix = client.get("/files/tiny-media.mkv", headers={"Range": "bytes=-11"})
+ assert suffix.status_code == 206
+ assert suffix.content == media[-11:]
+
+ unsatisfiable = client.get(
+ "/files/tiny-media.mkv", headers={"Range": f"bytes={len(media)}-"}
+ )
+ assert unsatisfiable.status_code == 416
+ assert unsatisfiable.headers["content-range"] == f"bytes */{len(media)}"
+ assert unsatisfiable.content == b""
+
+ multiple = client.get("/files/tiny-media.mkv", headers={"Range": "bytes=0-1,4-5"})
+ assert multiple.status_code == 416
+
+
+def test_notification_capture_and_journal_redact_secrets(upstream):
+ client, _ = upstream
+ client.post("/__admin/reset")
+
+ response = client.post(
+ "/notifications/webhook",
+ params={"token": "query-secret", "message": "visible-query"},
+ headers={
+ "Authorization": "Bearer header-secret",
+ "Cookie": "session=cookie-secret",
+ "X-Api-Key": "header-api-secret",
+ },
+ json={
+ "message": "visible-body",
+ "password": "body-password",
+ "nested": {"api_token": "body-token", "value": "kept"},
+ },
+ )
+ assert response.status_code == 204
+
+ form_response = client.post(
+ "/notifications/form",
+ data={"password": "form-password", "message": "visible-form"},
+ )
+ assert form_response.status_code == 204
+
+ requests = client.get("/__admin/requests").json()["requests"]
+ webhook = requests[0]
+ assert webhook["path"] == "/notifications/webhook"
+ assert webhook["query"] == {
+ "token": ["[REDACTED]"],
+ "message": ["visible-query"],
+ }
+ assert webhook["headers"]["authorization"] == "[REDACTED]"
+ assert webhook["headers"]["cookie"] == "[REDACTED]"
+ assert webhook["headers"]["x-api-key"] == "[REDACTED]"
+ assert webhook["body"] == {
+ "message": "visible-body",
+ "password": "[REDACTED]",
+ "nested": {"api_token": "[REDACTED]", "value": "kept"},
+ }
+ assert requests[1]["body"] == {
+ "password": ["[REDACTED]"],
+ "message": ["visible-form"],
+ }
+
+ state = client.get("/__admin/state").json()
+ assert len(state["notifications"]) == 2
+ serialized = json.dumps({"requests": requests, "state": state}, ensure_ascii=False)
+ for secret in (
+ "query-secret",
+ "header-secret",
+ "cookie-secret",
+ "header-api-secret",
+ "body-password",
+ "body-token",
+ "form-password",
+ ):
+ assert secret not in serialized
+ assert "visible-body" in serialized
+ assert "visible-form" in serialized
+
+
+def test_unknown_route_returns_501_and_is_journaled(upstream):
+ client, _ = upstream
+ client.post("/__admin/reset")
+
+ response = client.get("/not-a-contract-route", params={"api_key": "must-not-leak"})
+
+ assert response.status_code == 501
+ journal = client.get("/__admin/requests").json()["requests"]
+ assert len(journal) == 1
+ assert journal[0]["path"] == "/not-a-contract-route"
+ assert journal[0]["query"]["api_key"] == ["[REDACTED]"]
+ assert journal[0]["response_status"] == 501
+ assert "must-not-leak" not in json.dumps(journal)
diff --git a/backend/src/test/test_e2e_fixture_generation.py b/backend/src/test/test_e2e_fixture_generation.py
new file mode 100644
index 00000000..e388c441
--- /dev/null
+++ b/backend/src/test/test_e2e_fixture_generation.py
@@ -0,0 +1,99 @@
+"""Contract tests for deterministic hermetic E2E fixture generation."""
+
+from __future__ import annotations
+
+import hashlib
+import importlib.util
+import json
+import re
+from pathlib import Path
+from types import ModuleType
+from urllib.parse import urlparse
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+FIXTURE_ROOT = REPO_ROOT / "e2e" / "fixtures"
+GENERATOR_PATH = REPO_ROOT / "e2e" / "scripts" / "generate_fixtures.py"
+
+
+def _load_generator() -> ModuleType:
+ spec = importlib.util.spec_from_file_location(
+ "e2e_generate_fixtures", GENERATOR_PATH
+ )
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _bdecode(data: bytes, index: int = 0):
+ token = data[index : index + 1]
+ if token == b"i":
+ end = data.index(b"e", index)
+ return int(data[index + 1 : end]), end + 1
+ if token == b"l":
+ result = []
+ index += 1
+ while data[index : index + 1] != b"e":
+ value, index = _bdecode(data, index)
+ result.append(value)
+ return result, index + 1
+ if token == b"d":
+ result = {}
+ index += 1
+ while data[index : index + 1] != b"e":
+ key, index = _bdecode(data, index)
+ value, index = _bdecode(data, index)
+ result[key] = value
+ return result, index + 1
+ colon = data.index(b":", index)
+ length = int(data[index:colon])
+ start = colon + 1
+ end = start + length
+ return data[start:end], end
+
+
+def test_generated_binary_fixtures_match_committed_bytes(tmp_path: Path):
+ generator = _load_generator()
+ generated_root = tmp_path / "fixtures"
+
+ generator.write_fixtures(generated_root)
+
+ for relative_path in generator.GENERATED_PATHS:
+ assert (generated_root / relative_path).read_bytes() == (
+ FIXTURE_ROOT / relative_path
+ ).read_bytes()
+
+ checksums = json.loads((FIXTURE_ROOT / "checksums.json").read_text())
+ assert checksums == {
+ str(path): hashlib.sha256((FIXTURE_ROOT / path).read_bytes()).hexdigest()
+ for path in generator.GENERATED_PATHS
+ }
+
+
+def test_torrent_contains_only_local_web_seed_and_valid_piece_hashes():
+ torrent, end = _bdecode(
+ (FIXTURE_ROOT / "torrents" / "tiny-media.torrent").read_bytes()
+ )
+ media = (FIXTURE_ROOT / "files" / "tiny-media.mkv").read_bytes()
+
+ assert end == len((FIXTURE_ROOT / "torrents" / "tiny-media.torrent").read_bytes())
+ assert torrent[b"url-list"] == (b"http://mock-upstream:18888/files/tiny-media.mkv")
+ info = torrent[b"info"]
+ assert info[b"name"] == b"Tiny Show - 01 [E2E].mkv"
+ assert info[b"length"] == len(media) == 64 * 1024
+ assert info[b"piece length"] == 16 * 1024
+ assert info[b"pieces"] == b"".join(
+ hashlib.sha1(media[offset : offset + info[b"piece length"]]).digest()
+ for offset in range(0, len(media), info[b"piece length"])
+ )
+
+
+def test_text_fixtures_reference_only_hermetic_hosts():
+ allowed_hosts = {"mock-upstream", "127.0.0.1", "localhost"}
+ urls: list[str] = []
+ for pattern in ("*.xml", "*.json", "*.html"):
+ for path in FIXTURE_ROOT.rglob(pattern):
+ urls.extend(re.findall(r"https?://[^\s\"'<>]+", path.read_text()))
+
+ assert urls
+ assert {urlparse(url).hostname for url in urls} <= allowed_hosts
diff --git a/backend/src/test/test_e2e_stack_contract.py b/backend/src/test/test_e2e_stack_contract.py
new file mode 100644
index 00000000..0bf11ea6
--- /dev/null
+++ b/backend/src/test/test_e2e_stack_contract.py
@@ -0,0 +1,365 @@
+"""Pure contract tests for the hermetic E2E stack infrastructure."""
+
+from __future__ import annotations
+
+import importlib.util
+import re
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[3]
+E2E = ROOT / "e2e"
+PYTHON_IMAGE = (
+ "python:3.13.14-alpine3.23@sha256:"
+ "9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76"
+)
+QB_IMAGE = (
+ "linuxserver/qbittorrent:5.2.3@sha256:"
+ "2e074403c7b72e6d89cee3d0d41a47f7b5708c6a9e5316f3958c90765cbe12ce"
+)
+
+
+def _load_script(name: str):
+ path = E2E / "scripts" / f"{name}.py"
+ spec = importlib.util.spec_from_file_location(f"e2e_{name}", path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_compose_files_are_isolated_and_use_random_loopback_ports():
+ browser = (E2E / "compose" / "browser.yml").read_text()
+ downloader = (E2E / "compose" / "downloader.yml").read_text()
+
+ assert "internal: true" in browser
+ assert "container_name" not in browser + downloader
+ assert ":latest" not in browser + downloader
+ assert '"127.0.0.1::7892"' in browser
+ assert '"127.0.0.1::18888"' in browser
+ assert '"127.0.0.1::8080"' in browser
+ assert '"127.0.0.1::8080"' in downloader
+ assert not re.search(r"127\.0\.0\.1:\d+:\d+", browser + downloader)
+ assert QB_IMAGE in downloader
+
+
+def test_support_service_images_are_pinned_and_dependency_free():
+ for relative in (
+ "mock-upstream/Dockerfile",
+ "fake-qb/Dockerfile",
+ ):
+ dockerfile = (E2E / relative).read_text()
+ assert f"FROM {PYTHON_IMAGE}" in dockerfile
+ assert "pip install" not in dockerfile
+ assert "apk add" not in dockerfile
+ assert "server.py" in dockerfile
+
+
+def test_qbittorrent_fixture_has_fixed_credentials_and_disables_peer_discovery():
+ config = (E2E / "fixtures/qbittorrent/qBittorrent.conf").read_text()
+
+ assert "WebUI\\Username=admin" in config
+ assert (
+ 'WebUI\\Password_PBKDF2="@ByteArray('
+ "ARQ77eY1NUZaQsuDHbIMCA==:"
+ "0WMRkYTUWVT9wVvdDtHAjU9b3b7uB8NR1Gur2hmQCvCDpm39Q+PsJRJPaCU51dEiz+"
+ 'dTzh8qbPsL8WkFljQYFQ==)"'
+ ) in config
+ assert "DHTEnabled=false" in config
+ assert "PeXEnabled=false" in config
+ assert "LSDEnabled=false" in config
+ assert "PortForwardingEnabled=false" in config
+ assert "CheckForUpdates=false" in config
+
+
+def test_build_context_stages_dist_and_version_without_touching_source(tmp_path):
+ build = _load_script("build_test_image")
+ repo = tmp_path / "repo"
+ context = tmp_path / "context"
+ dist = tmp_path / "dist"
+ tracked = (
+ Path(".dockerignore"),
+ Path("Dockerfile"),
+ Path("entrypoint.sh"),
+ Path("backend/pyproject.toml"),
+ Path("backend/uv.lock"),
+ Path("backend/src/main.py"),
+ Path("backend/src/module/example.py"),
+ )
+ for relative in tracked:
+ target = repo / relative
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(f"fixture:{relative}\n")
+ (dist / "assets").mkdir(parents=True)
+ (dist / "index.html").write_text("e2e")
+ (dist / "assets/app.js").write_text("console.log('e2e')")
+
+ build.prepare_context(
+ repo,
+ dist,
+ context,
+ version="3.3.999-e2e.1",
+ tracked_files=tracked,
+ )
+
+ assert (context / "backend/src/dist/index.html").is_file()
+ assert (context / "backend/src/dist/assets/app.js").is_file()
+ assert (context / "backend/src/module/__version__.py").read_text() == (
+ 'VERSION = "3.3.999-e2e.1"\n'
+ )
+ assert not (repo / "backend/src/dist").exists()
+ assert not (repo / "backend/src/module/__version__.py").exists()
+
+
+def test_build_command_uses_only_the_temporary_context(tmp_path):
+ build = _load_script("build_test_image")
+
+ command = build.build_command(
+ tmp_path / "context",
+ image="auto-bangumi:e2e",
+ version="3.3.999-e2e.1",
+ )
+
+ assert command[:2] == ["docker", "build"]
+ assert ["--build-arg", "VERSION=3.3.999-e2e.1"] == command[2:4]
+ assert ["--tag", "auto-bangumi:e2e"] == command[4:6]
+ assert command[-1] == str(tmp_path / "context")
+
+
+def test_stack_builds_unique_compose_commands_and_environment(tmp_path):
+ stack_module = _load_script("stack")
+ first_name = stack_module.unique_project_name("browser")
+ second_name = stack_module.unique_project_name("browser")
+ assert first_name != second_name
+ assert re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", first_name)
+
+ stack = stack_module.Stack(
+ profile="downloader",
+ repo_root=ROOT,
+ work_dir=tmp_path / "work",
+ project_name="ab-e2e-downloader-test",
+ )
+ command = stack.compose_command("config")
+
+ assert command[:4] == [
+ "docker",
+ "compose",
+ "--project-name",
+ "ab-e2e-downloader-test",
+ ]
+ assert str(E2E / "compose/browser.yml") in command
+ assert str(E2E / "compose/downloader.yml") in command
+ assert command[-1] == "config"
+ assert stack.environment["AB_E2E_CONFIG_DIR"] == str(
+ (tmp_path / "work/config").resolve()
+ )
+ assert stack.environment["AB_E2E_DOWNLOAD_DIR"] == str(
+ (tmp_path / "work/downloads").resolve()
+ )
+ assert stack.environment["AB_E2E_LOG_FILE"] == str(
+ (tmp_path / "work/logs/log.txt").resolve()
+ )
+ assert stack.environment["AB_E2E_USERNAME"] == "testadmin"
+ assert stack.environment["AB_E2E_PASSWORD"] == "testpassword123"
+ stack._publish_urls(
+ {"app": 40001, "mock-upstream": 40002, "fake-qb": 40003, "qbittorrent": 40004}
+ )
+ assert stack.environment["AB_E2E_REAL_QB_URL"] == "http://127.0.0.1:40004"
+ assert stack.environment["AB_E2E_QB_URL"] == "http://127.0.0.1:40004"
+
+
+@pytest.mark.parametrize(
+ ("output", "expected"),
+ [
+ ("127.0.0.1:49152", 49152),
+ ("0.0.0.0:32768", 32768),
+ ("[::1]:60000", 60000),
+ ],
+)
+def test_stack_parses_dynamic_compose_ports(output, expected):
+ stack_module = _load_script("stack")
+ assert stack_module.parse_published_port(output) == expected
+
+
+def test_qb_runtime_contract_checks_credentials_and_deterministic_preferences():
+ stack_module = _load_script("stack")
+ preferences = {
+ "dht": False,
+ "pex": False,
+ "lsd": False,
+ "upnp": False,
+ "save_path": "/downloads/Bangumi",
+ "rss_processing_enabled": False,
+ "rss_auto_downloading_enabled": False,
+ }
+
+ stack_module.validate_qb_preferences(preferences)
+ with pytest.raises(AssertionError, match="unsafe or non-deterministic"):
+ stack_module.validate_qb_preferences({**preferences, "dht": True})
+
+
+def test_qb_runtime_contract_accepts_login_204_and_checks_preferences(monkeypatch):
+ stack_module = _load_script("stack")
+ preferences = {
+ "dht": False,
+ "pex": False,
+ "lsd": False,
+ "upnp": False,
+ "save_path": "/downloads/Bangumi",
+ "rss_processing_enabled": False,
+ "rss_auto_downloading_enabled": False,
+ }
+
+ class Response:
+ def __init__(self, status, payload):
+ self.status = status
+ self.payload = payload
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def read(self):
+ return self.payload
+
+ class Opener:
+ def __init__(self):
+ self.calls = []
+
+ def open(self, request, timeout):
+ self.calls.append((request, timeout))
+ if not isinstance(request, str):
+ return Response(204, b"")
+ return Response(200, __import__("json").dumps(preferences).encode())
+
+ opener = Opener()
+ monkeypatch.setattr(
+ stack_module.urllib.request,
+ "build_opener",
+ lambda *_handlers: opener,
+ )
+
+ assert stack_module.verify_qb_runtime("http://127.0.0.1:12345") == preferences
+ assert len(opener.calls) == 2
+
+
+def test_run_with_stack_always_stops_after_command_failure():
+ stack_module = _load_script("stack")
+
+ class FakeStack:
+ started = False
+ stopped = False
+ environment = {"AB_E2E_BASE_URL": "http://127.0.0.1:1"}
+
+ def start(self):
+ self.started = True
+
+ def stop(self, *, collect=True):
+ assert collect is True
+ self.stopped = True
+
+ fake = FakeStack()
+
+ def fail(_command, _environment):
+ raise RuntimeError("boom")
+
+ with pytest.raises(RuntimeError, match="boom"):
+ stack_module.run_with_stack(fake, ["pytest"], runner=fail)
+
+ assert fake.started is True
+ assert fake.stopped is True
+
+
+def test_run_with_stack_handles_sigterm_and_restores_handlers(monkeypatch):
+ stack_module = _load_script("stack")
+ installed = {}
+ signal_calls = []
+ monkeypatch.setattr(
+ stack_module.signal,
+ "getsignal",
+ lambda signum: f"previous-{signum}",
+ )
+
+ def install(signum, handler):
+ installed[signum] = handler
+ signal_calls.append((signum, handler))
+
+ monkeypatch.setattr(stack_module.signal, "signal", install)
+
+ class FakeStack:
+ environment = {}
+
+ def __init__(self):
+ self.stop_count = 0
+
+ def start(self):
+ return None
+
+ def stop(self, *, collect=True):
+ assert collect is True
+ self.stop_count += 1
+
+ fake = FakeStack()
+
+ def terminate(_command, _environment):
+ installed[stack_module.signal.SIGTERM](stack_module.signal.SIGTERM, None)
+
+ with pytest.raises(SystemExit):
+ stack_module.run_with_stack(fake, ["pytest"], runner=terminate)
+
+ assert fake.stop_count >= 1
+ assert signal_calls[-2:] == [
+ (
+ stack_module.signal.SIGINT,
+ f"previous-{stack_module.signal.SIGINT}",
+ ),
+ (
+ stack_module.signal.SIGTERM,
+ f"previous-{stack_module.signal.SIGTERM}",
+ ),
+ ]
+
+
+def test_artifact_redaction_removes_credentials_and_tokens():
+ artifacts = _load_script("collect_artifacts")
+ raw = (
+ "password=adminadmin Authorization: Bearer api-secret "
+ "Cookie: SID=session-secret token=raw-token"
+ )
+
+ redacted = artifacts.redact_text(raw, secrets=("adminadmin",))
+
+ for secret in ("adminadmin", "api-secret", "session-secret", "raw-token"):
+ assert secret not in redacted
+ assert redacted.count("[REDACTED]") >= 4
+
+
+def test_static_audit_accepts_the_real_e2e_tree():
+ audit = _load_script("audit_sources")
+ assert audit.audit_repository(ROOT) == []
+
+
+def test_static_audit_reports_fixed_ports_floating_images_and_blind_waits(tmp_path):
+ audit = _load_script("audit_sources")
+ (tmp_path / "e2e/compose").mkdir(parents=True)
+ (tmp_path / "e2e/scripts").mkdir(parents=True)
+ (tmp_path / "e2e/compose/bad.yml").write_text(
+ "services:\n"
+ " bad:\n"
+ " container_name: fixed\n"
+ " image: example/tool:latest\n"
+ ' ports: ["127.0.0.1:8080:8080"]\n'
+ )
+ (tmp_path / "e2e/scripts/bad.py").write_text("time.sleep(10)\n")
+
+ messages = [finding.message for finding in audit.audit_repository(tmp_path)]
+
+ assert any("container_name" in message for message in messages)
+ assert any("floating image" in message for message in messages)
+ assert any("fixed host port" in message for message in messages)
+ assert any("blind sleep" in message for message in messages)
diff --git a/e2e/compose/browser.yml b/e2e/compose/browser.yml
new file mode 100644
index 00000000..96cc01e7
--- /dev/null
+++ b/e2e/compose/browser.yml
@@ -0,0 +1,115 @@
+services:
+ app:
+ image: ${AB_E2E_APP_IMAGE:-auto-bangumi:e2e}
+ pull_policy: never
+ environment:
+ TZ: UTC
+ PUID: ${AB_E2E_PUID:-1000}
+ PGID: ${AB_E2E_PGID:-1000}
+ UMASK: "022"
+ AB_WEBUI_PORT: "7892"
+ AB_DOWNLOADER_HOST: fake-qb:8080
+ AB_DOWNLOADER_USERNAME: admin
+ AB_DOWNLOADER_PASSWORD: adminadmin
+ AB_DOWNLOAD_PATH: /downloads/Bangumi
+ AB_INTERVAL_TIME: "3600"
+ AB_RENAME_FREQ: "1"
+ AB_DEBUG_MODE: "true"
+ AB_DEV_NO_AUTH: "0"
+ depends_on:
+ mock-upstream:
+ condition: service_healthy
+ fake-qb:
+ condition: service_healthy
+ ports:
+ - "127.0.0.1::7892"
+ volumes:
+ - type: bind
+ source: ${AB_E2E_CONFIG_DIR:?AB_E2E_CONFIG_DIR must be set}
+ target: /app/config
+ - type: bind
+ source: ${AB_E2E_DATA_DIR:?AB_E2E_DATA_DIR must be set}
+ target: /app/data
+ - type: bind
+ source: ${AB_E2E_DOWNLOAD_DIR:?AB_E2E_DOWNLOAD_DIR must be set}
+ target: /downloads
+ - type: bind
+ source: ${AB_E2E_LOG_FILE:?AB_E2E_LOG_FILE must be set}
+ target: /app/data/log.txt
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7892/health"]
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ start_period: 5s
+ stop_grace_period: 20s
+ networks:
+ - e2e
+
+ mock-upstream:
+ image: ${AB_E2E_MOCK_IMAGE:-auto-bangumi-e2e/mock-upstream:local}
+ pull_policy: never
+ build:
+ context: ../..
+ dockerfile: e2e/mock-upstream/Dockerfile
+ environment:
+ HOST: 0.0.0.0
+ PORT: "18888"
+ FIXTURES_ROOT: /fixtures
+ init: true
+ read_only: true
+ ports:
+ - "127.0.0.1::18888"
+ volumes:
+ - type: bind
+ source: ${AB_E2E_FIXTURES_DIR:?AB_E2E_FIXTURES_DIR must be set}
+ target: /fixtures
+ read_only: true
+ tmpfs:
+ - /tmp
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - import urllib.request; urllib.request.urlopen('http://127.0.0.1:18888/health', timeout=2)
+ interval: 2s
+ timeout: 3s
+ retries: 20
+ start_period: 2s
+ networks:
+ - e2e
+
+ fake-qb:
+ image: ${AB_E2E_FAKE_QB_IMAGE:-auto-bangumi-e2e/fake-qb:local}
+ pull_policy: never
+ build:
+ context: ../..
+ dockerfile: e2e/fake-qb/Dockerfile
+ environment:
+ HOST: 0.0.0.0
+ PORT: "8080"
+ QB_USERNAME: admin
+ QB_PASSWORD: adminadmin
+ init: true
+ read_only: true
+ ports:
+ - "127.0.0.1::8080"
+ tmpfs:
+ - /tmp
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/__admin/state', timeout=2)
+ interval: 2s
+ timeout: 3s
+ retries: 20
+ start_period: 2s
+ networks:
+ - e2e
+
+networks:
+ e2e:
+ internal: true
diff --git a/e2e/compose/downloader.yml b/e2e/compose/downloader.yml
new file mode 100644
index 00000000..4c7cc8fb
--- /dev/null
+++ b/e2e/compose/downloader.yml
@@ -0,0 +1,36 @@
+services:
+ app:
+ environment:
+ AB_DOWNLOADER_HOST: qbittorrent:8080
+ AB_DOWNLOADER_USERNAME: admin
+ AB_DOWNLOADER_PASSWORD: adminadmin
+ depends_on:
+ qbittorrent:
+ condition: service_healthy
+
+ qbittorrent:
+ image: linuxserver/qbittorrent:5.2.3@sha256:2e074403c7b72e6d89cee3d0d41a47f7b5708c6a9e5316f3958c90765cbe12ce
+ environment:
+ TZ: UTC
+ PUID: ${AB_E2E_PUID:-1000}
+ PGID: ${AB_E2E_PGID:-1000}
+ WEBUI_PORT: "8080"
+ TORRENTING_PORT: "6881"
+ ports:
+ - "127.0.0.1::8080"
+ volumes:
+ - type: bind
+ source: ${AB_E2E_QB_CONFIG_DIR:?AB_E2E_QB_CONFIG_DIR must be set}
+ target: /config
+ - type: bind
+ source: ${AB_E2E_DOWNLOAD_DIR:?AB_E2E_DOWNLOAD_DIR must be set}
+ target: /downloads
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/"]
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ start_period: 10s
+ stop_grace_period: 20s
+ networks:
+ - e2e
diff --git a/e2e/fake-qb/Dockerfile b/e2e/fake-qb/Dockerfile
new file mode 100644
index 00000000..351ad61a
--- /dev/null
+++ b/e2e/fake-qb/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ HOST=0.0.0.0 \
+ PORT=8080
+
+WORKDIR /app
+COPY e2e/fake-qb/server.py /app/server.py
+
+EXPOSE 8080
+HEALTHCHECK --interval=2s --timeout=3s --start-period=2s --retries=20 \
+ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/__admin/state', timeout=2)"]
+
+CMD ["python", "/app/server.py"]
diff --git a/e2e/fake-qb/server.py b/e2e/fake-qb/server.py
new file mode 100644
index 00000000..9d45bdc6
--- /dev/null
+++ b/e2e/fake-qb/server.py
@@ -0,0 +1,441 @@
+#!/usr/bin/env python3
+"""Small hermetic qBittorrent Web API fake used by end-to-end tests."""
+
+from __future__ import annotations
+
+import copy
+import json
+import os
+import secrets
+import threading
+from http.cookies import SimpleCookie
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from typing import Any
+from urllib.parse import parse_qs, urlsplit
+
+MATCHING_HASH = "a" * 40
+NEIGHBOUR_HASH = "b" * 40
+DELETE_FAILURE_HASH = "f" * 40
+REDACTED = "[REDACTED]"
+
+_SENSITIVE_FRAGMENTS = (
+ "password",
+ "passwd",
+ "secret",
+ "token",
+ "auth",
+ "cookie",
+ "sid",
+)
+
+_PRESET_TORRENTS = (
+ {
+ "hash": MATCHING_HASH,
+ "name": "Target Anime - 01",
+ "save_path": "D:\\Downloads\\Bangumi\\Test Anime (2024)\\Season 1\\",
+ "category": "Bangumi",
+ "tags": "ab:1",
+ "state": "pausedUP",
+ "progress": 1.0,
+ "size": 65_536,
+ },
+ {
+ "hash": NEIGHBOUR_HASH,
+ "name": "Nearby Anime - 10",
+ "save_path": "D:\\Downloads\\Bangumi\\Test Anime (2024)\\Season 10",
+ "category": "Bangumi",
+ "tags": "ab:10",
+ "state": "pausedUP",
+ "progress": 1.0,
+ "size": 65_536,
+ },
+ {
+ "hash": DELETE_FAILURE_HASH,
+ "name": "Forced Delete Failure",
+ "save_path": "D:\\Downloads\\Bangumi\\Delete Failure\\Season 1",
+ "category": "Bangumi",
+ "tags": "ab:failure",
+ "state": "pausedUP",
+ "progress": 1.0,
+ "size": 65_536,
+ },
+)
+
+_PRESET_PREFERENCES = {
+ "save_path": "D:\\Downloads\\Bangumi",
+ "temp_path_enabled": False,
+ "web_ui_port": 8080,
+ "dht": False,
+ "pex": False,
+ "lsd": False,
+ "upnp": False,
+}
+
+
+def _is_sensitive(name: str) -> bool:
+ normalized = name.lower().replace("-", "_")
+ return any(fragment in normalized for fragment in _SENSITIVE_FRAGMENTS)
+
+
+def _redact_object(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {
+ key: REDACTED if _is_sensitive(str(key)) else _redact_object(item)
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [_redact_object(item) for item in value]
+ return value
+
+
+def _redact_values(name: str, values: list[str]) -> list[str]:
+ if _is_sensitive(name):
+ return [REDACTED for _ in values]
+ if name.lower() == "json":
+ sanitized: list[str] = []
+ for value in values:
+ try:
+ parsed = json.loads(value)
+ except (TypeError, ValueError):
+ sanitized.append(value)
+ else:
+ sanitized.append(
+ json.dumps(_redact_object(parsed), separators=(",", ":"))
+ )
+ return sanitized
+ return list(values)
+
+
+def _redact_mapping(values: dict[str, list[str]]) -> dict[str, list[str]]:
+ return {key: _redact_values(key, items) for key, items in values.items()}
+
+
+class FakeQbState:
+ def __init__(self, username: str, password: str) -> None:
+ self.username = username
+ self.password = password
+ self.lock = threading.RLock()
+ self.reset()
+
+ def reset(self) -> None:
+ with self.lock:
+ self.torrents = {
+ torrent["hash"]: copy.deepcopy(torrent) for torrent in _PRESET_TORRENTS
+ }
+ self.preferences = copy.deepcopy(_PRESET_PREFERENCES)
+ self.sessions: set[str] = set()
+ self.requests: list[dict[str, Any]] = []
+
+ def record(
+ self,
+ *,
+ method: str,
+ path: str,
+ query: dict[str, list[str]],
+ form: dict[str, list[str]],
+ headers: dict[str, str],
+ ) -> None:
+ sanitized_headers = {
+ key: REDACTED if _is_sensitive(key) else value
+ for key, value in headers.items()
+ }
+ request = {
+ "method": method,
+ "path": path,
+ "query": _redact_mapping(query),
+ "form": _redact_mapping(form),
+ "headers": sanitized_headers,
+ }
+ with self.lock:
+ self.requests.append(request)
+
+ def snapshot(self) -> dict[str, Any]:
+ with self.lock:
+ return {
+ "torrents": copy.deepcopy(list(self.torrents.values())),
+ "preferences": _redact_object(copy.deepcopy(self.preferences)),
+ "session_count": len(self.sessions),
+ }
+
+
+class FakeQbServer(ThreadingHTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+ def __init__(
+ self,
+ address: tuple[str, int],
+ username: str,
+ password: str,
+ ) -> None:
+ self.state = FakeQbState(username, password)
+ super().__init__(address, FakeQbHandler)
+
+
+class FakeQbHandler(BaseHTTPRequestHandler):
+ server: FakeQbServer
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, format: str, *args: Any) -> None:
+ return
+
+ def _send_bytes(
+ self,
+ status: int,
+ payload: bytes,
+ content_type: str,
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(payload)))
+ for key, value in (headers or {}).items():
+ self.send_header(key, value)
+ self.end_headers()
+ self.wfile.write(payload)
+
+ def _send_json(self, status: int, body: Any) -> None:
+ payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
+ self._send_bytes(status, payload, "application/json; charset=utf-8")
+
+ def _send_text(
+ self,
+ status: int,
+ body: str = "",
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ self._send_bytes(
+ status,
+ body.encode("utf-8"),
+ "text/plain; charset=utf-8",
+ headers,
+ )
+
+ def _read_form(self) -> dict[str, list[str]]:
+ length = int(self.headers.get("Content-Length", "0") or 0)
+ payload = self.rfile.read(length) if length else b""
+ content_type = self.headers.get("Content-Type", "")
+ if not content_type.startswith("application/x-www-form-urlencoded"):
+ return {}
+ return parse_qs(payload.decode("utf-8"), keep_blank_values=True)
+
+ def _record(
+ self,
+ method: str,
+ path: str,
+ query: dict[str, list[str]],
+ form: dict[str, list[str]],
+ ) -> None:
+ self.server.state.record(
+ method=method,
+ path=path,
+ query=query,
+ form=form,
+ headers=dict(self.headers.items()),
+ )
+
+ def _session_id(self) -> str | None:
+ cookie_header = self.headers.get("Cookie")
+ if not cookie_header:
+ return None
+ cookies = SimpleCookie()
+ try:
+ cookies.load(cookie_header)
+ except Exception:
+ return None
+ sid = cookies.get("SID")
+ return sid.value if sid else None
+
+ def _authenticated(self) -> bool:
+ session_id = self._session_id()
+ with self.server.state.lock:
+ return session_id is not None and session_id in self.server.state.sessions
+
+ def _require_auth(self) -> bool:
+ if self._authenticated():
+ return True
+ self._send_text(403, "Forbidden")
+ return False
+
+ @staticmethod
+ def _one(form: dict[str, list[str]], key: str, default: str = "") -> str:
+ values = form.get(key)
+ return values[0] if values else default
+
+ def do_GET(self) -> None:
+ target = urlsplit(self.path)
+ query = parse_qs(target.query, keep_blank_values=True)
+
+ if target.path == "/__admin/state":
+ self._send_json(200, self.server.state.snapshot())
+ return
+ if target.path == "/__admin/requests":
+ with self.server.state.lock:
+ requests = copy.deepcopy(self.server.state.requests)
+ self._send_json(200, {"requests": requests})
+ return
+
+ self._record("GET", target.path, query, {})
+ if target.path.startswith("/api/v2/") and not self._require_auth():
+ return
+
+ if target.path == "/api/v2/app/version":
+ self._send_text(200, "v5.2.3")
+ return
+ if target.path == "/api/v2/app/preferences":
+ with self.server.state.lock:
+ preferences = copy.deepcopy(self.server.state.preferences)
+ self._send_json(200, preferences)
+ return
+ if target.path == "/api/v2/torrents/info":
+ self._send_json(200, self._filtered_torrents(query))
+ return
+
+ self._send_json(
+ 501,
+ {"error": "unsupported endpoint", "method": "GET", "path": target.path},
+ )
+
+ def _filtered_torrents(self, query: dict[str, list[str]]) -> list[dict[str, Any]]:
+ with self.server.state.lock:
+ torrents = copy.deepcopy(list(self.server.state.torrents.values()))
+
+ hashes = query.get("hashes", [""])[0]
+ if hashes:
+ selected_hashes = set(hashes.split("|"))
+ torrents = [item for item in torrents if item["hash"] in selected_hashes]
+ category = query.get("category", [""])[0]
+ if category:
+ torrents = [item for item in torrents if item["category"] == category]
+ tag = query.get("tag", [""])[0]
+ if tag:
+ torrents = [
+ item
+ for item in torrents
+ if tag in {value.strip() for value in item["tags"].split(",")}
+ ]
+ status_filter = query.get("filter", [""])[0]
+ if status_filter == "completed":
+ torrents = [item for item in torrents if item["progress"] >= 1]
+ return torrents
+
+ def do_POST(self) -> None:
+ target = urlsplit(self.path)
+ query = parse_qs(target.query, keep_blank_values=True)
+ form = self._read_form()
+
+ if target.path == "/__admin/reset":
+ self.server.state.reset()
+ self._send_json(200, {"ok": True})
+ return
+
+ self._record("POST", target.path, query, form)
+
+ if target.path == "/api/v2/auth/login":
+ self._login(form)
+ return
+ if target.path.startswith("/api/v2/") and not self._require_auth():
+ return
+ if target.path == "/api/v2/auth/logout":
+ self._logout()
+ return
+ if target.path == "/api/v2/app/setPreferences":
+ self._set_preferences(form)
+ return
+ if target.path == "/api/v2/torrents/delete":
+ self._delete_torrents(form)
+ return
+
+ self._send_json(
+ 501,
+ {"error": "unsupported endpoint", "method": "POST", "path": target.path},
+ )
+
+ def _login(self, form: dict[str, list[str]]) -> None:
+ username = self._one(form, "username")
+ password = self._one(form, "password")
+ if (
+ username != self.server.state.username
+ or password != self.server.state.password
+ ):
+ self._send_text(200, "Fails.")
+ return
+ session_id = secrets.token_hex(16)
+ with self.server.state.lock:
+ self.server.state.sessions.add(session_id)
+ self._send_text(
+ 200,
+ "Ok.",
+ {"Set-Cookie": f"SID={session_id}; HttpOnly; SameSite=Strict; Path=/"},
+ )
+
+ def _logout(self) -> None:
+ session_id = self._session_id()
+ if session_id:
+ with self.server.state.lock:
+ self.server.state.sessions.discard(session_id)
+ self._send_text(
+ 200,
+ "Ok.",
+ {"Set-Cookie": "SID=; Max-Age=0; HttpOnly; SameSite=Strict; Path=/"},
+ )
+
+ def _set_preferences(self, form: dict[str, list[str]]) -> None:
+ try:
+ preferences = json.loads(self._one(form, "json"))
+ except (TypeError, ValueError):
+ self._send_text(400, "Invalid preferences")
+ return
+ if not isinstance(preferences, dict):
+ self._send_text(400, "Invalid preferences")
+ return
+ with self.server.state.lock:
+ self.server.state.preferences.update(preferences)
+ self._send_text(200)
+
+ def _delete_torrents(self, form: dict[str, list[str]]) -> None:
+ hashes_value = self._one(form, "hashes")
+ if not hashes_value:
+ self._send_text(400, "Missing hashes")
+ return
+ with self.server.state.lock:
+ hashes = (
+ set(self.server.state.torrents)
+ if hashes_value == "all"
+ else set(hashes_value.split("|"))
+ )
+ if DELETE_FAILURE_HASH in hashes:
+ self._send_text(500, "Forced delete failure")
+ return
+ for torrent_hash in hashes:
+ self.server.state.torrents.pop(torrent_hash, None)
+ self._send_text(200)
+
+
+def create_server(
+ host: str = "127.0.0.1",
+ port: int = 0,
+ *,
+ username: str = "admin",
+ password: str = "adminadmin",
+) -> FakeQbServer:
+ """Create an unstarted server; callers own ``serve_forever`` and shutdown."""
+ return FakeQbServer((host, port), username, password)
+
+
+def main() -> None:
+ host = os.environ.get("HOST", "0.0.0.0")
+ port = int(os.environ.get("PORT", "8080"))
+ username = os.environ.get("QB_USERNAME", "admin")
+ password = os.environ.get("QB_PASSWORD", "adminadmin")
+ server = create_server(host, port, username=username, password=password)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/e2e/fixtures/checksums.json b/e2e/fixtures/checksums.json
new file mode 100644
index 00000000..6eae72fe
--- /dev/null
+++ b/e2e/fixtures/checksums.json
@@ -0,0 +1,4 @@
+{
+ "files/tiny-media.mkv": "7daca2095d0438260fa849183dfc67faa459fdf4936e1bc91eec6b281b27e4c2",
+ "torrents/tiny-media.torrent": "3ef510e2e5f87bd3fcef4a9ffdf16330d87e163a820866600384eb0863f86615"
+}
diff --git a/e2e/fixtures/files/tiny-media.mkv b/e2e/fixtures/files/tiny-media.mkv
new file mode 100644
index 00000000..9f442b09
Binary files /dev/null and b/e2e/fixtures/files/tiny-media.mkv differ
diff --git a/e2e/fixtures/player/index.html b/e2e/fixtures/player/index.html
new file mode 100644
index 00000000..c445ab79
--- /dev/null
+++ b/e2e/fixtures/player/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+ AutoBangumi E2E Player
+
+
+ Hermetic player fixture loaded
+
+
diff --git a/e2e/fixtures/qbittorrent/qBittorrent.conf b/e2e/fixtures/qbittorrent/qBittorrent.conf
new file mode 100644
index 00000000..890f5765
--- /dev/null
+++ b/e2e/fixtures/qbittorrent/qBittorrent.conf
@@ -0,0 +1,43 @@
+[Application]
+FileLogger\Age=1
+FileLogger\AgeType=1
+FileLogger\Backup=true
+FileLogger\DeleteOld=true
+FileLogger\Enabled=true
+FileLogger\MaxSizeBytes=1048576
+FileLogger\Path=/config/qBittorrent/logs
+
+[BitTorrent]
+Session\AddTorrentStopped=false
+Session\DHTEnabled=false
+Session\DefaultSavePath=/downloads/Bangumi
+Session\LSDEnabled=false
+Session\PeXEnabled=false
+Session\Port=6881
+Session\PortForwardingEnabled=false
+Session\QueueingSystemEnabled=false
+Session\TempPath=/downloads/.incomplete
+Session\TempPathEnabled=true
+
+[LegalNotice]
+Accepted=true
+
+[Preferences]
+Connection\PortRangeMin=6881
+Connection\UPnP=false
+Downloads\SavePath=/downloads/Bangumi
+Downloads\TempPath=/downloads/.incomplete
+Downloads\TempPathEnabled=true
+General\CheckForUpdates=false
+General\Locale=en
+RSS\AutoDownloaderEnabled=false
+RSS\Enabled=false
+WebUI\Address=*
+WebUI\AlternativeUIEnabled=false
+WebUI\CSRFProtection=false
+WebUI\HostHeaderValidation=false
+WebUI\LocalHostAuth=false
+WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZaQsuDHbIMCA==:0WMRkYTUWVT9wVvdDtHAjU9b3b7uB8NR1Gur2hmQCvCDpm39Q+PsJRJPaCU51dEiz+dTzh8qbPsL8WkFljQYFQ==)"
+WebUI\Port=8080
+WebUI\ServerDomains=*
+WebUI\Username=admin
diff --git a/e2e/fixtures/rss/offset-review.xml b/e2e/fixtures/rss/offset-review.xml
new file mode 100644
index 00000000..bef13719
--- /dev/null
+++ b/e2e/fixtures/rss/offset-review.xml
@@ -0,0 +1,20 @@
+
+
+
+ Offset Review Controls
+ http://mock-upstream:18888/rss/offset-review.xml
+ False-positive and true-positive offset fixtures
+ -
+ [SubsPlease] Haibara-kun no Tsuyokute Seishun New Game - 06v2 (1080p) [E931DD98].mkv
+ http://mock-upstream:18888/items/haibara-06v2
+ Wed, 01 Jul 2026 12:00:00 GMT
+
+
+ -
+ [SubsPlease] Haibara-kun no Tsuyokute Seishun New Game - 13 (1080p) [ABCDEF12].mkv
+ http://mock-upstream:18888/items/haibara-13
+ Wed, 08 Jul 2026 12:00:00 GMT
+
+
+
+
diff --git a/e2e/fixtures/rss/tiny-download.xml b/e2e/fixtures/rss/tiny-download.xml
new file mode 100644
index 00000000..b5f64ae3
--- /dev/null
+++ b/e2e/fixtures/rss/tiny-download.xml
@@ -0,0 +1,14 @@
+
+
+
+ AutoBangumi Tiny Download
+ http://mock-upstream:18888/rss/tiny-download.xml
+ Hermetic qBittorrent web-seed fixture
+ -
+ [E2E] Tiny Show - 01 [1080p][CHS].mkv
+ http://mock-upstream:18888/items/tiny-show-01
+ Wed, 01 Jul 2026 12:00:00 GMT
+
+
+
+
diff --git a/e2e/fixtures/rss/tmdb-movie.xml b/e2e/fixtures/rss/tmdb-movie.xml
new file mode 100644
index 00000000..a2b56b48
--- /dev/null
+++ b/e2e/fixtures/rss/tmdb-movie.xml
@@ -0,0 +1,14 @@
+
+
+
+ Localized Movie Search
+ http://mock-upstream:18888/rss/tmdb-movie.xml
+ TMDB movie language fixture
+ -
+ [E2E] Localized Movie Movie [1080p][CHS].mkv
+ http://mock-upstream:18888/items/localized-movie
+ Wed, 01 Jul 2026 12:00:00 GMT
+
+
+
+
diff --git a/e2e/fixtures/rss/tmdb-retry.xml b/e2e/fixtures/rss/tmdb-retry.xml
new file mode 100644
index 00000000..173dd1a6
--- /dev/null
+++ b/e2e/fixtures/rss/tmdb-retry.xml
@@ -0,0 +1,14 @@
+
+
+
+ Localized Retry Search
+ http://mock-upstream:18888/rss/tmdb-retry.xml
+ TMDB whitespace-removal retry fixture
+ -
+ [E2E] Retry Localized Show - 01 [1080p][CHS].mkv
+ http://mock-upstream:18888/items/retry-localized-show-01
+ Wed, 01 Jul 2026 12:00:00 GMT
+
+
+
+
diff --git a/e2e/fixtures/rss/tmdb-tv.xml b/e2e/fixtures/rss/tmdb-tv.xml
new file mode 100644
index 00000000..1f845cb8
--- /dev/null
+++ b/e2e/fixtures/rss/tmdb-tv.xml
@@ -0,0 +1,14 @@
+
+
+
+ Localized TV Search
+ http://mock-upstream:18888/rss/tmdb-tv.xml
+ TMDB television language fixture
+ -
+ [E2E] Localized Show - 01 [1080p][CHS].mkv
+ http://mock-upstream:18888/items/localized-show-01
+ Wed, 01 Jul 2026 12:00:00 GMT
+
+
+
+
diff --git a/e2e/fixtures/tmdb/scenarios.json b/e2e/fixtures/tmdb/scenarios.json
new file mode 100644
index 00000000..171874fe
--- /dev/null
+++ b/e2e/fixtures/tmdb/scenarios.json
@@ -0,0 +1,105 @@
+{
+ "scenarios": {
+ "localized-tv-zh": {
+ "search": {
+ "kind": "tv",
+ "query": "Localized Show",
+ "language": "zh-CN",
+ "results": [{ "id": 1001, "name": "本地化动画" }]
+ },
+ "detail": {
+ "id": 1001,
+ "name": "本地化动画",
+ "original_name": "Localized Show",
+ "first_air_date": "2026-07-01",
+ "poster_path": null,
+ "status": "Returning Series",
+ "genres": [{ "id": 16, "name": "Animation" }],
+ "seasons": [
+ {
+ "season_number": 1,
+ "name": "第 1 季",
+ "air_date": "2026-07-01",
+ "episode_count": 12,
+ "poster_path": null
+ }
+ ]
+ }
+ },
+ "localized-tv-jp": {
+ "search": {
+ "kind": "tv",
+ "query": "Localized Show",
+ "language": "ja-JP",
+ "results": [{ "id": 1001, "name": "ローカライズアニメ" }]
+ },
+ "detail": {
+ "id": 1001,
+ "name": "ローカライズアニメ",
+ "original_name": "Localized Show",
+ "first_air_date": "2026-07-01",
+ "poster_path": null,
+ "status": "Returning Series",
+ "genres": [{ "id": 16, "name": "Animation" }],
+ "seasons": [
+ {
+ "season_number": 1,
+ "name": "第 1 季",
+ "air_date": "2026-07-01",
+ "episode_count": 12,
+ "poster_path": null
+ }
+ ]
+ }
+ },
+ "localized-movie-jp": {
+ "search": {
+ "kind": "movie",
+ "query": "Localized Movie",
+ "language": "ja-JP",
+ "results": [
+ {
+ "id": 2001,
+ "title": "ローカライズ映画",
+ "original_title": "Localized Movie",
+ "release_date": "2026-07-01",
+ "poster_path": null
+ }
+ ]
+ }
+ },
+ "localized-retry-jp": {
+ "search": {
+ "kind": "tv",
+ "query": "RetryLocalizedShow",
+ "initial_query": "Retry Localized Show",
+ "language": "ja-JP",
+ "results": [{ "id": 3001, "name": "再試行アニメ" }]
+ },
+ "detail": {
+ "id": 3001,
+ "name": "再試行アニメ",
+ "original_name": "Retry Localized Show",
+ "first_air_date": "2026-07-01",
+ "poster_path": null,
+ "status": "Returning Series",
+ "genres": [{ "id": 16, "name": "Animation" }],
+ "seasons": [
+ {
+ "season_number": 1,
+ "name": "第 1 季",
+ "air_date": "2026-07-01",
+ "episode_count": 12,
+ "poster_path": null
+ }
+ ]
+ }
+ }
+ },
+ "season": {
+ "episodes": [
+ { "episode_number": 1, "air_date": "2026-07-01" },
+ { "episode_number": 2, "air_date": "2026-07-08" }
+ ]
+ }
+}
diff --git a/e2e/fixtures/torrents/tiny-media.torrent b/e2e/fixtures/torrents/tiny-media.torrent
new file mode 100644
index 00000000..18450f40
--- /dev/null
+++ b/e2e/fixtures/torrents/tiny-media.torrent
@@ -0,0 +1 @@
+d10:created by24:AutoBangumi hermetic E2E13:creation datei0e4:infod6:lengthi65536e4:name24:Tiny Show - 01 [E2E].mkv12:piece lengthi16384e6:pieces80:€˜C
€ÃFIö^¢]«¿ûb€˜C
€ÃFIö^¢]«¿ûb€˜C
€ÃFIö^¢]«¿ûb€˜C
€ÃFIö^¢]«¿ûbe8:url-list47:http://mock-upstream:18888/files/tiny-media.mkve
\ No newline at end of file
diff --git a/e2e/mock-upstream/Dockerfile b/e2e/mock-upstream/Dockerfile
new file mode 100644
index 00000000..afec13c8
--- /dev/null
+++ b/e2e/mock-upstream/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ HOST=0.0.0.0 \
+ PORT=18888 \
+ FIXTURES_ROOT=/fixtures
+
+WORKDIR /app
+COPY e2e/mock-upstream/server.py /app/server.py
+COPY e2e/fixtures /fixtures
+
+EXPOSE 18888
+HEALTHCHECK --interval=2s --timeout=3s --start-period=2s --retries=20 \
+ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18888/health', timeout=2)"]
+
+ENTRYPOINT ["python", "/app/server.py"]
+CMD ["--host", "0.0.0.0", "--port", "18888", "--fixture-root", "/fixtures"]
diff --git a/e2e/mock-upstream/server.py b/e2e/mock-upstream/server.py
new file mode 100644
index 00000000..e6d1ceb3
--- /dev/null
+++ b/e2e/mock-upstream/server.py
@@ -0,0 +1,616 @@
+"""Hermetic HTTP upstream for AutoBangumi end-to-end tests.
+
+The implementation intentionally uses only the Python standard library so the
+same server can run directly from a checkout or in a tiny test container.
+"""
+
+from __future__ import annotations
+
+import argparse
+import copy
+import json
+import mimetypes
+import os
+import re
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+from urllib.parse import parse_qs, unquote, urlsplit
+
+REDACTED = "[REDACTED]"
+_SENSITIVE_NAMES = (
+ "authorization",
+ "cookie",
+ "password",
+ "passwd",
+ "secret",
+ "api_key",
+ "api-key",
+ "apikey",
+ "access_token",
+ "refresh_token",
+ "token",
+)
+_TEXT_SECRET_RE = re.compile(
+ r"(?i)(authorization|cookie|password|passwd|secret|api[_-]?key|"
+ r"access[_-]?token|refresh[_-]?token|token)(\s*[=:]\s*)"
+ r'([^&\s,;}"]+)'
+)
+
+
+def _is_sensitive(name: str) -> bool:
+ lowered = name.lower()
+ return any(part in lowered for part in _SENSITIVE_NAMES)
+
+
+def _redact(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {
+ str(key): REDACTED if _is_sensitive(str(key)) else _redact(item)
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [_redact(item) for item in value]
+ return value
+
+
+def _redact_named_values(values: dict[str, Any]) -> dict[str, Any]:
+ def redact_secret(value: Any) -> Any:
+ if isinstance(value, list):
+ return [REDACTED for _ in value]
+ return REDACTED
+
+ return {
+ key: redact_secret(value) if _is_sensitive(key) else _redact(value)
+ for key, value in values.items()
+ }
+
+
+def _content_type(path: Path, route_prefix: str) -> str:
+ if route_prefix == "/rss/":
+ return "application/rss+xml; charset=utf-8"
+ if route_prefix == "/torrents/":
+ return "application/x-bittorrent"
+ if path.suffix.lower() == ".mkv":
+ return "video/x-matroska"
+ guessed, _ = mimetypes.guess_type(path.name)
+ return guessed or "application/octet-stream"
+
+
+def _parse_range(value: str, size: int) -> tuple[int, int]:
+ if size <= 0 or not value.startswith("bytes=") or "," in value:
+ raise ValueError("unsupported byte range")
+ spec = value[6:].strip()
+ if "-" not in spec:
+ raise ValueError("malformed byte range")
+ start_text, end_text = spec.split("-", 1)
+ try:
+ if not start_text:
+ suffix_length = int(end_text)
+ if suffix_length <= 0:
+ raise ValueError("empty suffix range")
+ start = max(size - suffix_length, 0)
+ end = size - 1
+ else:
+ start = int(start_text)
+ if start < 0 or start >= size:
+ raise ValueError("range starts outside resource")
+ end = size - 1 if not end_text else min(int(end_text), size - 1)
+ if end < start:
+ raise ValueError("range end precedes start")
+ except ValueError as exc:
+ raise ValueError("malformed byte range") from exc
+ return start, end
+
+
+class _MockState:
+ def __init__(self, fixture_root: Path):
+ scenario_file = fixture_root / "tmdb" / "scenarios.json"
+ scenario_data = json.loads(scenario_file.read_text(encoding="utf-8"))
+ self.scenarios: dict[str, dict[str, Any]] = scenario_data["scenarios"]
+ self.season: dict[str, Any] = scenario_data["season"]
+ self.lock = threading.RLock()
+ self.scenario_name: str | None = None
+ self.requests: list[dict[str, Any]] = []
+ self.notifications: list[dict[str, Any]] = []
+
+ def reset(self) -> None:
+ with self.lock:
+ self.scenario_name = None
+ self.requests.clear()
+ self.notifications.clear()
+
+ def activate(self, name: str) -> bool:
+ with self.lock:
+ if name not in self.scenarios:
+ return False
+ self.scenario_name = name
+ return True
+
+ def scenario(self) -> dict[str, Any] | None:
+ with self.lock:
+ if self.scenario_name is None:
+ return None
+ return copy.deepcopy(self.scenarios[self.scenario_name])
+
+ def append_request(self, request: dict[str, Any]) -> dict[str, Any]:
+ with self.lock:
+ self.requests.append(request)
+ return request
+
+ def set_response_status(self, request: dict[str, Any], status: int) -> None:
+ with self.lock:
+ request["response_status"] = status
+
+ def append_notification(self, request: dict[str, Any]) -> None:
+ notification = {
+ key: copy.deepcopy(value)
+ for key, value in request.items()
+ if key != "response_status"
+ }
+ with self.lock:
+ self.notifications.append(notification)
+
+ def request_snapshot(self) -> list[dict[str, Any]]:
+ with self.lock:
+ return copy.deepcopy(self.requests)
+
+ def state_snapshot(self) -> dict[str, Any]:
+ with self.lock:
+ return {
+ "scenario": self.scenario_name,
+ "notifications": copy.deepcopy(self.notifications),
+ "request_count": len(self.requests),
+ }
+
+
+class _MockUpstreamServer(ThreadingHTTPServer):
+ daemon_threads = True
+
+ def __init__(self, address: tuple[str, int], fixture_root: Path):
+ super().__init__(address, _MockUpstreamHandler)
+ self.fixture_root = fixture_root
+ self.mock_state = _MockState(fixture_root)
+
+
+class _MockUpstreamHandler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+ server: _MockUpstreamServer
+
+ def log_message(self, format: str, *args: Any) -> None:
+ del format, args
+
+ def do_GET(self) -> None:
+ self._handle_request(head_only=False)
+
+ def do_HEAD(self) -> None:
+ self._handle_request(head_only=True)
+
+ def do_POST(self) -> None:
+ self._handle_request(head_only=False)
+
+ def do_PUT(self) -> None:
+ self._handle_request(head_only=False)
+
+ def _read_body(self) -> Any:
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ except ValueError:
+ length = 0
+ raw = self.rfile.read(length) if length > 0 else b""
+ if not raw:
+ return None
+
+ content_type = self.headers.get("Content-Type", "").split(";", 1)[0].lower()
+ if content_type == "application/json":
+ try:
+ return _redact(json.loads(raw.decode("utf-8")))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return ""
+ if content_type == "application/x-www-form-urlencoded":
+ try:
+ form = parse_qs(raw.decode("utf-8"), keep_blank_values=True)
+ except UnicodeDecodeError:
+ return f"<{len(raw)} bytes>"
+ return _redact_named_values(form)
+ if content_type.startswith("text/"):
+ try:
+ text = raw.decode("utf-8")
+ except UnicodeDecodeError:
+ return f"<{len(raw)} bytes>"
+ return _TEXT_SECRET_RE.sub(rf"\1\2{REDACTED}", text)
+ return f"<{len(raw)} bytes>"
+
+ def _journal(self, parsed, body: Any) -> dict[str, Any]:
+ query = parse_qs(parsed.query, keep_blank_values=True)
+ headers = {key.lower(): value for key, value in self.headers.items()}
+ request = {
+ "method": self.command,
+ "path": unquote(parsed.path),
+ "query": _redact_named_values(query),
+ "headers": _redact_named_values(headers),
+ "body": body,
+ "response_status": None,
+ }
+ return self.server.mock_state.append_request(request)
+
+ def _handle_request(self, *, head_only: bool) -> None:
+ parsed = urlsplit(self.path)
+ body = self._read_body()
+ if self._handle_admin(parsed, body, head_only=head_only):
+ return
+
+ journal = self._journal(parsed, body)
+ path = unquote(parsed.path)
+ if path == "/health":
+ if self.command not in {"GET", "HEAD"}:
+ self._send_method_not_allowed(journal, "GET, HEAD", head_only)
+ else:
+ self._send_json(journal, 200, {"status": "ok"}, head_only)
+ return
+
+ if path.startswith("/tmdb/"):
+ if self.command not in {"GET", "HEAD"}:
+ self._send_method_not_allowed(journal, "GET, HEAD", head_only)
+ else:
+ self._handle_tmdb(parsed, journal, head_only=head_only)
+ return
+
+ static_route = self._static_route(path)
+ if static_route is not None:
+ if self.command not in {"GET", "HEAD"}:
+ self._send_method_not_allowed(journal, "GET, HEAD", head_only)
+ else:
+ route_prefix, directory, relative = static_route
+ self._send_static(
+ journal,
+ route_prefix,
+ directory,
+ relative,
+ head_only=head_only,
+ )
+ return
+
+ if path.startswith("/notifications/"):
+ if self.command != "POST":
+ self._send_method_not_allowed(journal, "POST", head_only)
+ else:
+ self.server.mock_state.append_notification(journal)
+ self._send_empty(journal, 204)
+ return
+
+ self._send_json(
+ journal,
+ 501,
+ {"detail": f"Unexpected mock-upstream request: {self.command} {path}"},
+ head_only,
+ )
+
+ def _handle_admin(self, parsed, body: Any, *, head_only: bool) -> bool:
+ path = unquote(parsed.path)
+ state = self.server.mock_state
+ if path == "/__admin/reset":
+ if self.command != "POST":
+ self._send_unjournaled_json(405, {"detail": "Method not allowed"})
+ else:
+ state.reset()
+ self._send_unjournaled_json(200, {"ok": True})
+ return True
+ if path == "/__admin/requests":
+ if self.command not in {"GET", "HEAD"}:
+ self._send_unjournaled_json(405, {"detail": "Method not allowed"})
+ else:
+ self._send_unjournaled_json(
+ 200, {"requests": state.request_snapshot()}, head_only=head_only
+ )
+ return True
+ if path == "/__admin/state":
+ if self.command not in {"GET", "HEAD"}:
+ self._send_unjournaled_json(405, {"detail": "Method not allowed"})
+ else:
+ self._send_unjournaled_json(
+ 200, state.state_snapshot(), head_only=head_only
+ )
+ return True
+
+ scenario_match = re.fullmatch(r"/__admin/scenario/([^/]+)", path)
+ if scenario_match:
+ if self.command not in {"PUT", "POST"}:
+ self._send_unjournaled_json(405, {"detail": "Method not allowed"})
+ else:
+ name = scenario_match.group(1)
+ if state.activate(name):
+ self._send_unjournaled_json(200, {"scenario": name})
+ else:
+ self._send_unjournaled_json(
+ 404, {"detail": f"Unknown scenario: {name}"}
+ )
+ return True
+ if path == "/__admin/scenario" and self.command in {"PUT", "POST"}:
+ name = body.get("name") if isinstance(body, dict) else None
+ if isinstance(name, str) and state.activate(name):
+ self._send_unjournaled_json(200, {"scenario": name})
+ else:
+ self._send_unjournaled_json(
+ 404, {"detail": f"Unknown scenario: {name}"}
+ )
+ return True
+ return False
+
+ def _handle_tmdb(self, parsed, journal: dict[str, Any], *, head_only: bool) -> None:
+ scenario = self.server.mock_state.scenario()
+ if scenario is None:
+ self._send_json(
+ journal, 409, {"detail": "No active TMDB scenario"}, head_only
+ )
+ return
+
+ path = unquote(parsed.path)
+ query = parse_qs(parsed.query, keep_blank_values=True)
+ search_match = re.fullmatch(r"/tmdb/3/search/(tv|movie)", path)
+ if search_match:
+ expected = scenario.get("search", {})
+ kind = search_match.group(1)
+ supplied_query = (query.get("query") or [""])[0]
+ supplied_language = (query.get("language") or [""])[0]
+ expected_query = expected.get("query")
+ initial_query = expected.get("initial_query")
+ matches = (
+ expected.get("kind") == kind
+ and supplied_language == expected.get("language")
+ and supplied_query == expected_query
+ )
+ if supplied_query == initial_query:
+ matches = False
+ results = expected.get("results", []) if matches else []
+ self._send_json(journal, 200, {"results": results}, head_only)
+ return
+
+ season_match = re.fullmatch(r"/tmdb/3/tv/(\d+)/season/(\d+)", path)
+ if season_match:
+ detail = scenario.get("detail")
+ if not self._detail_matches(
+ detail,
+ query,
+ int(season_match.group(1)),
+ scenario.get("search", {}).get("language"),
+ ):
+ self._send_json(
+ journal, 404, {"detail": "TV season not found"}, head_only
+ )
+ return
+ season_number = int(season_match.group(2))
+ known_seasons = {
+ item.get("season_number") for item in detail.get("seasons", [])
+ }
+ if season_number not in known_seasons:
+ self._send_json(
+ journal, 404, {"detail": "TV season not found"}, head_only
+ )
+ return
+ self._send_json(journal, 200, self.server.mock_state.season, head_only)
+ return
+
+ detail_match = re.fullmatch(r"/tmdb/3/tv/(\d+)", path)
+ if detail_match:
+ detail = scenario.get("detail")
+ if not self._detail_matches(
+ detail,
+ query,
+ int(detail_match.group(1)),
+ scenario.get("search", {}).get("language"),
+ ):
+ self._send_json(
+ journal, 404, {"detail": "TV show not found"}, head_only
+ )
+ return
+ self._send_json(journal, 200, detail, head_only)
+ return
+
+ self._send_json(
+ journal,
+ 501,
+ {"detail": f"Unexpected TMDB request: {path}"},
+ head_only,
+ )
+
+ @staticmethod
+ def _detail_matches(
+ detail: dict[str, Any] | None,
+ query: dict[str, list[str]],
+ requested_id: int,
+ expected_language: str | None,
+ ) -> bool:
+ if detail is None or detail.get("id") != requested_id:
+ return False
+ supplied_language = (query.get("language") or [""])[0]
+ return bool(expected_language) and supplied_language == expected_language
+
+ def _static_route(self, path: str) -> tuple[str, Path, str] | None:
+ root = self.server.fixture_root
+ for route_prefix, directory_name in (
+ ("/rss/", "rss"),
+ ("/images/", "images"),
+ ("/files/", "files"),
+ ("/torrents/", "torrents"),
+ ):
+ if path.startswith(route_prefix):
+ return route_prefix, root / directory_name, path[len(route_prefix) :]
+ if path in {"/player", "/player/"}:
+ return "/player/", root / "player", "index.html"
+ if path.startswith("/player/"):
+ return "/player/", root / "player", path[len("/player/") :]
+ return None
+
+ def _send_static(
+ self,
+ journal: dict[str, Any],
+ route_prefix: str,
+ directory: Path,
+ relative: str,
+ *,
+ head_only: bool,
+ ) -> None:
+ base = directory.resolve()
+ candidate = (base / relative).resolve()
+ try:
+ candidate.relative_to(base)
+ except ValueError:
+ self._send_json(journal, 404, {"detail": "File not found"}, head_only)
+ return
+ if not candidate.is_file():
+ self._send_json(journal, 404, {"detail": "File not found"}, head_only)
+ return
+
+ size = candidate.stat().st_size
+ range_header = self.headers.get("Range")
+ headers = {"Accept-Ranges": "bytes"}
+ status = 200
+ start = 0
+ end = max(size - 1, 0)
+ if range_header:
+ try:
+ start, end = _parse_range(range_header, size)
+ except ValueError:
+ headers["Content-Range"] = f"bytes */{size}"
+ self._send_bytes(
+ journal,
+ 416,
+ b"",
+ "application/octet-stream",
+ head_only=head_only,
+ headers=headers,
+ )
+ return
+ status = 206
+ headers["Content-Range"] = f"bytes {start}-{end}/{size}"
+
+ length = end - start + 1 if size else 0
+ if head_only:
+ data = b""
+ else:
+ with candidate.open("rb") as file:
+ file.seek(start)
+ data = file.read(length)
+ self._send_bytes(
+ journal,
+ status,
+ data,
+ _content_type(candidate, route_prefix),
+ head_only=head_only,
+ headers=headers,
+ content_length=length,
+ )
+
+ def _send_method_not_allowed(
+ self, journal: dict[str, Any], allow: str, head_only: bool
+ ) -> None:
+ self._send_json(
+ journal,
+ 405,
+ {"detail": "Method not allowed"},
+ head_only,
+ headers={"Allow": allow},
+ )
+
+ def _send_json(
+ self,
+ journal: dict[str, Any],
+ status: int,
+ payload: Any,
+ head_only: bool,
+ headers: dict[str, str] | None = None,
+ ) -> None:
+ data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
+ "utf-8"
+ )
+ self._send_bytes(
+ journal,
+ status,
+ data,
+ "application/json; charset=utf-8",
+ head_only=head_only,
+ headers=headers,
+ )
+
+ def _send_empty(self, journal: dict[str, Any], status: int) -> None:
+ self._send_bytes(
+ journal,
+ status,
+ b"",
+ "application/octet-stream",
+ head_only=True,
+ )
+
+ def _send_bytes(
+ self,
+ journal: dict[str, Any],
+ status: int,
+ data: bytes,
+ content_type: str,
+ *,
+ head_only: bool,
+ headers: dict[str, str] | None = None,
+ content_length: int | None = None,
+ ) -> None:
+ self.server.mock_state.set_response_status(journal, status)
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header(
+ "Content-Length",
+ str(len(data) if content_length is None else content_length),
+ )
+ for key, value in (headers or {}).items():
+ self.send_header(key, value)
+ self.end_headers()
+ if not head_only and data:
+ self.wfile.write(data)
+
+ def _send_unjournaled_json(
+ self, status: int, payload: Any, *, head_only: bool = False
+ ) -> None:
+ data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode(
+ "utf-8"
+ )
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ if not head_only and data:
+ self.wfile.write(data)
+
+
+def create_server(
+ host: str, port: int, fixture_root: str | os.PathLike[str]
+) -> ThreadingHTTPServer:
+ """Create a configured server without starting its serve loop."""
+
+ root = Path(fixture_root).resolve()
+ if not root.is_dir():
+ raise FileNotFoundError(f"Fixture root does not exist: {root}")
+ return _MockUpstreamServer((host, port), root)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
+ parser.add_argument(
+ "--port", type=int, default=int(os.environ.get("PORT", "18888"))
+ )
+ parser.add_argument(
+ "--fixture-root",
+ default=os.environ.get("FIXTURES_ROOT", "/fixtures"),
+ )
+ args = parser.parse_args()
+ server = create_server(args.host, args.port, args.fixture_root)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/e2e/scripts/audit_sources.py b/e2e/scripts/audit_sources.py
new file mode 100644
index 00000000..94567a67
--- /dev/null
+++ b/e2e/scripts/audit_sources.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+"""Statically reject non-hermetic or collision-prone E2E source patterns."""
+
+from __future__ import annotations
+
+import argparse
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable, Sequence
+from urllib.parse import urlsplit
+
+_URL_RE = re.compile(r"https?://[^\s\"'<>]+")
+_FIXED_PORT_RE = re.compile(r"127\.0\.0\.1:\d+:\d+")
+_BLIND_SLEEP_RE = re.compile(r"\b(?:time\.)?sleep\(\s*\d+(?:\.\d+)?\s*\)")
+_ALLOWED_HOSTS = {
+ "127.0.0.1",
+ "0.0.0.0",
+ "::1",
+ "localhost",
+ "mock-upstream",
+ "fake-qb",
+ "qbittorrent",
+}
+
+
+@dataclass(frozen=True)
+class AuditFinding:
+ path: Path
+ line: int
+ message: str
+
+
+def _source_files(root: Path) -> Iterable[Path]:
+ e2e_root = root / "e2e"
+ if e2e_root.exists():
+ for path in e2e_root.rglob("*"):
+ if path.is_file() and (
+ path.name == "Dockerfile"
+ or path.suffix in {".py", ".yml", ".yaml", ".ts"}
+ ):
+ if path.name == "audit_sources.py":
+ continue
+ yield path
+ playwright = root / "webui/playwright.config.ts"
+ if playwright.is_file():
+ yield playwright
+
+
+def _add(
+ findings: list[AuditFinding],
+ path: Path,
+ line: int,
+ message: str,
+) -> None:
+ findings.append(AuditFinding(path=path, line=line, message=message))
+
+
+def _audit_line(
+ root: Path,
+ path: Path,
+ line_number: int,
+ line: str,
+ findings: list[AuditFinding],
+) -> None:
+ relative = path.relative_to(root)
+ if "container_name" in line:
+ _add(findings, relative, line_number, "container_name is forbidden")
+ if ":latest" in line:
+ _add(findings, relative, line_number, "floating image tag is forbidden")
+ if _FIXED_PORT_RE.search(line):
+ _add(findings, relative, line_number, "fixed host port is forbidden")
+ if path.name not in {"stack.py", "poll.py"} and _BLIND_SLEEP_RE.search(line):
+ _add(findings, relative, line_number, "blind sleep is forbidden")
+ if path.name == "playwright.config.ts":
+ retry = re.search(r"\bretries\s*:\s*(\d+)", line)
+ if retry and int(retry.group(1)) != 0:
+ _add(findings, relative, line_number, "Playwright retries must be zero")
+ for raw_url in _URL_RE.findall(line):
+ try:
+ hostname = urlsplit(raw_url.rstrip("),];")).hostname
+ except ValueError:
+ if "{" in raw_url: # A local f-string URL is checked after expansion.
+ continue
+ _add(findings, relative, line_number, "malformed URL in E2E source")
+ continue
+ if hostname and hostname not in _ALLOWED_HOSTS:
+ _add(
+ findings,
+ relative,
+ line_number,
+ f"public upstream host is forbidden: {hostname}",
+ )
+
+
+def _audit_image_references(
+ root: Path,
+ path: Path,
+ lines: list[str],
+ findings: list[AuditFinding],
+) -> None:
+ relative = path.relative_to(root)
+ if path.name == "Dockerfile":
+ for line_number, line in enumerate(lines, 1):
+ match = re.match(r"\s*FROM\s+(\S+)", line, re.I)
+ if match and "@sha256:" not in match.group(1):
+ _add(
+ findings,
+ relative,
+ line_number,
+ "external Dockerfile base image must use an immutable digest",
+ )
+ if path.suffix in {".yml", ".yaml"}:
+ for line_number, line in enumerate(lines, 1):
+ match = re.match(r"\s*image:\s*([^\s#]+)", line)
+ if not match:
+ continue
+ image = match.group(1).strip("\"'")
+ if image.startswith("${"):
+ continue
+ if "/" in image and "@sha256:" not in image:
+ _add(
+ findings,
+ relative,
+ line_number,
+ "external Compose image must use an immutable digest",
+ )
+
+
+def audit_repository(root: Path) -> list[AuditFinding]:
+ root = root.resolve()
+ findings: list[AuditFinding] = []
+ for path in sorted(set(_source_files(root))):
+ try:
+ text = path.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ continue
+ lines = text.splitlines()
+ for line_number, line in enumerate(lines, 1):
+ _audit_line(root, path, line_number, line, findings)
+ _audit_image_references(root, path, lines, findings)
+ return findings
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "root",
+ nargs="?",
+ type=Path,
+ default=Path(__file__).resolve().parents[2],
+ )
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = _parser().parse_args(argv)
+ findings = audit_repository(args.root)
+ for finding in findings:
+ print(f"{finding.path}:{finding.line}: {finding.message}")
+ return 1 if findings else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/e2e/scripts/build_test_image.py b/e2e/scripts/build_test_image.py
new file mode 100644
index 00000000..1cee57d3
--- /dev/null
+++ b/e2e/scripts/build_test_image.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""Build the native E2E image from an isolated temporary context."""
+
+from __future__ import annotations
+
+import argparse
+import re
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Iterable, Sequence
+
+DEFAULT_VERSION = "3.3.999-e2e.1"
+_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$")
+
+
+def repository_root() -> Path:
+ return Path(__file__).resolve().parents[2]
+
+
+def validate_version(version: str) -> str:
+ if not _VERSION_RE.fullmatch(version):
+ raise ValueError(f"E2E image version must be SemVer-compatible: {version!r}")
+ return version
+
+
+def tracked_backend_files(repo_root: Path) -> tuple[Path, ...]:
+ """Return tracked build inputs while excluding unrelated untracked files."""
+ result = subprocess.run(
+ [
+ "git",
+ "ls-files",
+ "-z",
+ "--",
+ ".dockerignore",
+ "Dockerfile",
+ "entrypoint.sh",
+ "backend",
+ ],
+ cwd=repo_root,
+ check=True,
+ capture_output=True,
+ )
+ return tuple(
+ Path(value.decode("utf-8")) for value in result.stdout.split(b"\0") if value
+ )
+
+
+def _safe_relative(path: Path) -> Path:
+ if path.is_absolute() or ".." in path.parts:
+ raise ValueError(f"Tracked path must stay inside the repository: {path}")
+ return path
+
+
+def prepare_context(
+ repo_root: Path,
+ dist_dir: Path,
+ destination: Path,
+ *,
+ version: str = DEFAULT_VERSION,
+ tracked_files: Iterable[Path] | None = None,
+) -> Path:
+ """Copy tracked build inputs, WebUI dist, and version into *destination*."""
+ version = validate_version(version)
+ repo_root = repo_root.resolve()
+ dist_dir = dist_dir.resolve()
+ destination = destination.resolve()
+ if not (dist_dir / "index.html").is_file():
+ raise FileNotFoundError(f"Built WebUI is missing index.html: {dist_dir}")
+ destination.mkdir(parents=True, exist_ok=True)
+
+ selected = tracked_files or tracked_backend_files(repo_root)
+ for relative in selected:
+ relative = _safe_relative(Path(relative))
+ source = repo_root / relative
+ if not source.is_file():
+ raise FileNotFoundError(f"Tracked build input is missing: {source}")
+ target = destination / relative
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, target)
+
+ staged_dist = destination / "backend/src/dist"
+ if staged_dist.exists():
+ shutil.rmtree(staged_dist)
+ shutil.copytree(dist_dir, staged_dist)
+
+ version_module = destination / "backend/src/module/__version__.py"
+ version_module.parent.mkdir(parents=True, exist_ok=True)
+ version_module.write_text(f'VERSION = "{version}"\n', encoding="utf-8")
+ return destination
+
+
+def build_command(context: Path, *, image: str, version: str) -> list[str]:
+ validate_version(version)
+ if not image or any(character.isspace() for character in image):
+ raise ValueError(f"Invalid Docker image name: {image!r}")
+ return [
+ "docker",
+ "build",
+ "--build-arg",
+ f"VERSION={version}",
+ "--tag",
+ image,
+ "--pull=false",
+ str(context.resolve()),
+ ]
+
+
+def verification_command(*, image: str, version: str) -> list[str]:
+ validate_version(version)
+ script = (
+ "import sys; "
+ "from pathlib import Path; "
+ "from module.__version__ import VERSION; "
+ "expected=sys.argv[1]; "
+ "image_version=Path('/app/IMAGE_VERSION').read_text().strip(); "
+ "assert VERSION == expected == image_version, "
+ "(VERSION, expected, image_version)"
+ )
+ return [
+ "docker",
+ "run",
+ "--rm",
+ "--entrypoint",
+ "python",
+ image,
+ "-c",
+ script,
+ version,
+ ]
+
+
+def build_image(
+ *,
+ repo_root: Path,
+ dist_dir: Path,
+ image: str,
+ version: str,
+ runner=subprocess.run,
+) -> None:
+ with tempfile.TemporaryDirectory(prefix="ab-e2e-build-") as temporary:
+ context = prepare_context(
+ repo_root,
+ dist_dir,
+ Path(temporary) / "context",
+ version=version,
+ )
+ runner(build_command(context, image=image, version=version), check=True)
+ runner(verification_command(image=image, version=version), check=True)
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--version", default=DEFAULT_VERSION)
+ parser.add_argument("--image", default="auto-bangumi:e2e")
+ parser.add_argument("--dist", type=Path)
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = _parser().parse_args(argv)
+ root = repository_root()
+ dist = args.dist or root / "webui/dist"
+ build_image(
+ repo_root=root,
+ dist_dir=dist,
+ image=args.image,
+ version=args.version,
+ )
+ print(f"Built and verified {args.image} at version {args.version}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/e2e/scripts/collect_artifacts.py b/e2e/scripts/collect_artifacts.py
new file mode 100644
index 00000000..c5c66150
--- /dev/null
+++ b/e2e/scripts/collect_artifacts.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""Collect and redact diagnostics from a running hermetic E2E stack."""
+
+from __future__ import annotations
+
+import argparse
+import http.cookiejar
+import json
+import re
+import shutil
+import subprocess
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Iterable, Mapping, Sequence
+
+REDACTED = "[REDACTED]"
+_AUTH_RE = re.compile(r"(?i)\bAuthorization\b\s*[:=]\s*(?:Bearer\s+)?[^\s,;]+")
+_SENSITIVE_RE = re.compile(
+ r"(?i)\b(password|passwd|secret|token|cookie|sid)\b"
+ r"(\s*[:=]\s*)(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)"
+)
+
+
+def redact_text(text: str, secrets: Iterable[str] = ()) -> str:
+ redacted = text
+ for secret in sorted({value for value in secrets if value}, key=len, reverse=True):
+ redacted = redacted.replace(secret, REDACTED)
+ redacted = _AUTH_RE.sub(f"Authorization: {REDACTED}", redacted)
+ redacted = _SENSITIVE_RE.sub(
+ lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}", redacted
+ )
+ return redacted
+
+
+def _write_redacted(path: Path, text: str, secrets: Iterable[str]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(redact_text(text, secrets), encoding="utf-8")
+
+
+def _capture(
+ command: Sequence[str],
+ path: Path,
+ *,
+ environment: Mapping[str, str] | None,
+ secrets: Iterable[str],
+) -> None:
+ try:
+ result = subprocess.run(
+ list(command),
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ env=dict(environment) if environment is not None else None,
+ )
+ output = (
+ f"$ {' '.join(command)}\nexit={result.returncode}\n"
+ f"{result.stdout}\n{result.stderr}"
+ )
+ except Exception as exc: # Diagnostics must never hide the test failure.
+ output = f"$ {' '.join(command)}\ncollection failed: {exc!r}\n"
+ _write_redacted(path, output, secrets)
+
+
+def _fetch_json(url: str, *, opener=None) -> str:
+ client = opener or urllib.request
+ with client.urlopen(url, timeout=5) as response:
+ payload = response.read().decode("utf-8", errors="replace")
+ try:
+ return json.dumps(json.loads(payload), indent=2, ensure_ascii=False)
+ except json.JSONDecodeError:
+ return payload
+
+
+def _collect_admin(
+ name: str,
+ base_url: str,
+ artifact_dir: Path,
+ secrets: Iterable[str],
+) -> None:
+ for endpoint in ("requests", "state"):
+ try:
+ payload = _fetch_json(f"{base_url.rstrip('/')}/__admin/{endpoint}")
+ except Exception as exc:
+ payload = f"request failed: {exc!r}\n"
+ _write_redacted(
+ artifact_dir / f"{name}-{endpoint}.json",
+ payload,
+ secrets,
+ )
+
+
+def _collect_qbittorrent(
+ base_url: str,
+ artifact_dir: Path,
+ secrets: Iterable[str],
+ *,
+ username: str = "admin",
+ password: str = "adminadmin",
+) -> None:
+ cookies = http.cookiejar.CookieJar()
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
+ login = urllib.request.Request(
+ f"{base_url.rstrip('/')}/api/v2/auth/login",
+ data=urllib.parse.urlencode(
+ {"username": username, "password": password}
+ ).encode("utf-8"),
+ )
+ try:
+ with opener.open(login, timeout=5) as response:
+ response.read()
+ payload = _fetch_json(
+ f"{base_url.rstrip('/')}/api/v2/torrents/info", opener=opener
+ )
+ except Exception as exc:
+ payload = f"qBittorrent state request failed: {exc!r}\n"
+ _write_redacted(artifact_dir / "qbittorrent-state.json", payload, secrets)
+
+
+def collect_artifacts(
+ *,
+ compose_command: Sequence[str],
+ artifact_dir: Path,
+ data_dir: Path,
+ log_file: Path,
+ admin_urls: Mapping[str, str] | None = None,
+ qb_url: str | None = None,
+ environment: Mapping[str, str] | None = None,
+ secrets: Iterable[str] = (),
+) -> Path:
+ """Best-effort collection; failures are written into the bundle."""
+ artifact_dir.mkdir(parents=True, exist_ok=True)
+ all_secrets = tuple(secrets) + ("adminadmin",)
+ _capture(
+ [*compose_command, "ps", "--all"],
+ artifact_dir / "compose-ps.txt",
+ environment=environment,
+ secrets=all_secrets,
+ )
+ _capture(
+ [*compose_command, "logs", "--no-color", "--timestamps"],
+ artifact_dir / "compose-logs.txt",
+ environment=environment,
+ secrets=all_secrets,
+ )
+
+ for name, base_url in (admin_urls or {}).items():
+ _collect_admin(name, base_url, artifact_dir, all_secrets)
+ if qb_url:
+ _collect_qbittorrent(qb_url, artifact_dir, all_secrets)
+
+ if log_file.is_file():
+ _write_redacted(
+ artifact_dir / "backend-log.txt",
+ log_file.read_text(encoding="utf-8", errors="replace"),
+ all_secrets,
+ )
+ for name in ("data.db", "data.db-wal", "data.db-shm"):
+ source = data_dir / name
+ if source.is_file():
+ shutil.copy2(source, artifact_dir / name)
+ return artifact_dir
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--artifact-dir", required=True, type=Path)
+ parser.add_argument("--data-dir", required=True, type=Path)
+ parser.add_argument("--log-file", required=True, type=Path)
+ parser.add_argument("compose", nargs=argparse.REMAINDER)
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = _parser().parse_args(argv)
+ if not args.compose:
+ raise SystemExit("compose command prefix is required after --")
+ command = args.compose[1:] if args.compose[0] == "--" else args.compose
+ collect_artifacts(
+ compose_command=command,
+ artifact_dir=args.artifact_dir,
+ data_dir=args.data_dir,
+ log_file=args.log_file,
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/e2e/scripts/generate_fixtures.py b/e2e/scripts/generate_fixtures.py
new file mode 100644
index 00000000..c52ea28d
--- /dev/null
+++ b/e2e/scripts/generate_fixtures.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""Generate deterministic binary fixtures for the hermetic E2E stack."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+from pathlib import Path
+
+FIXTURE_ROOT = Path(__file__).resolve().parents[1] / "fixtures"
+MEDIA_PATH = Path("files/tiny-media.mkv")
+TORRENT_PATH = Path("torrents/tiny-media.torrent")
+CHECKSUM_PATH = Path("checksums.json")
+GENERATED_PATHS = (MEDIA_PATH, TORRENT_PATH)
+
+MEDIA_NAME = "Tiny Show - 01 [E2E].mkv"
+WEB_SEED = "http://mock-upstream:18888/files/tiny-media.mkv"
+PIECE_LENGTH = 16 * 1024
+
+
+def bencode(value: bytes | str | int | list | tuple | dict) -> bytes:
+ """Encode the small bencode subset used by the single-file torrent."""
+ if isinstance(value, bytes):
+ return str(len(value)).encode() + b":" + value
+ if isinstance(value, str):
+ return bencode(value.encode())
+ if isinstance(value, int):
+ return b"i" + str(value).encode() + b"e"
+ if isinstance(value, (list, tuple)):
+ return b"l" + b"".join(bencode(item) for item in value) + b"e"
+ if isinstance(value, dict):
+ items: list[tuple[bytes, object]] = []
+ for key, item in value.items():
+ encoded_key = key if isinstance(key, bytes) else str(key).encode()
+ items.append((encoded_key, item))
+ return (
+ b"d"
+ + b"".join(bencode(key) + bencode(item) for key, item in sorted(items))
+ + b"e"
+ )
+ raise TypeError(f"Unsupported bencode value: {type(value)!r}")
+
+
+def build_media() -> bytes:
+ """Return a stable 64 KiB payload without random or timestamp input."""
+ return bytes(range(256)) * 256
+
+
+def build_torrent(media: bytes) -> bytes:
+ """Return trackerless single-file metainfo backed by the local web seed."""
+ pieces = b"".join(
+ hashlib.sha1(media[offset : offset + PIECE_LENGTH]).digest()
+ for offset in range(0, len(media), PIECE_LENGTH)
+ )
+ return bencode(
+ {
+ b"created by": b"AutoBangumi hermetic E2E",
+ b"creation date": 0,
+ b"info": {
+ b"length": len(media),
+ b"name": MEDIA_NAME,
+ b"piece length": PIECE_LENGTH,
+ b"pieces": pieces,
+ },
+ b"url-list": WEB_SEED,
+ }
+ )
+
+
+def generated_content() -> dict[Path, bytes]:
+ media = build_media()
+ return {MEDIA_PATH: media, TORRENT_PATH: build_torrent(media)}
+
+
+def _checksum_content(content: dict[Path, bytes]) -> bytes:
+ checksums = {
+ str(path): hashlib.sha256(data).hexdigest() for path, data in content.items()
+ }
+ return (json.dumps(checksums, indent=2, sort_keys=True) + "\n").encode()
+
+
+def write_fixtures(root: Path = FIXTURE_ROOT) -> None:
+ content = generated_content()
+ for relative_path, data in content.items():
+ destination = root / relative_path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_bytes(data)
+ (root / CHECKSUM_PATH).write_bytes(_checksum_content(content))
+
+
+def check_fixtures(root: Path = FIXTURE_ROOT) -> list[str]:
+ content = generated_content()
+ expected = {**content, CHECKSUM_PATH: _checksum_content(content)}
+ mismatches: list[str] = []
+ for relative_path, data in expected.items():
+ destination = root / relative_path
+ if not destination.exists():
+ mismatches.append(f"missing: {relative_path}")
+ elif destination.read_bytes() != data:
+ mismatches.append(f"out of date: {relative_path}")
+ return mismatches
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--check", action="store_true")
+ parser.add_argument("--output", type=Path, default=FIXTURE_ROOT)
+ args = parser.parse_args()
+
+ if args.check:
+ mismatches = check_fixtures(args.output)
+ if mismatches:
+ print("\n".join(mismatches))
+ return 1
+ print("E2E binary fixtures are deterministic and up to date.")
+ return 0
+
+ write_fixtures(args.output)
+ print(f"Wrote deterministic E2E fixtures to {args.output}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/e2e/scripts/stack.py b/e2e/scripts/stack.py
new file mode 100644
index 00000000..7746efae
--- /dev/null
+++ b/e2e/scripts/stack.py
@@ -0,0 +1,509 @@
+#!/usr/bin/env python3
+"""Lifecycle controller for isolated AutoBangumi E2E Compose stacks."""
+
+from __future__ import annotations
+
+import argparse
+import http.cookiejar
+import importlib.util
+import json
+import os
+import re
+import secrets
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Callable, Mapping, Sequence
+
+PROFILES = {"browser", "downloader"}
+DEFAULT_WAIT_SECONDS = 120
+QB_EXPECTED_PREFERENCES = {
+ "dht": False,
+ "pex": False,
+ "lsd": False,
+ "upnp": False,
+ "save_path": "/downloads/Bangumi",
+ "rss_processing_enabled": False,
+ "rss_auto_downloading_enabled": False,
+}
+
+
+def repository_root() -> Path:
+ return Path(__file__).resolve().parents[2]
+
+
+def unique_project_name(profile: str) -> str:
+ if profile not in PROFILES:
+ raise ValueError(f"Unknown E2E profile: {profile}")
+ raw = f"ab-e2e-{profile}-{os.getpid()}-{secrets.token_hex(4)}".lower()
+ normalized = re.sub(r"[^a-z0-9-]+", "-", raw).strip("-")
+ return normalized[:63].rstrip("-")
+
+
+def parse_published_port(output: str) -> int:
+ match = re.search(r":(\d+)\s*$", output.strip())
+ if not match:
+ raise ValueError(f"Cannot parse Docker Compose published port: {output!r}")
+ port = int(match.group(1))
+ if not 1 <= port <= 65535:
+ raise ValueError(f"Published port is out of range: {port}")
+ return port
+
+
+def validate_qb_preferences(preferences: Mapping[str, object]) -> None:
+ mismatches = {
+ key: {"expected": expected, "actual": preferences.get(key)}
+ for key, expected in QB_EXPECTED_PREFERENCES.items()
+ if preferences.get(key) != expected
+ }
+ if mismatches:
+ raise AssertionError(
+ "qBittorrent E2E preferences are unsafe or non-deterministic: "
+ f"{json.dumps(mismatches, sort_keys=True)}"
+ )
+
+
+def verify_qb_runtime(
+ base_url: str,
+ *,
+ username: str = "admin",
+ password: str = "adminadmin",
+) -> dict[str, object]:
+ cookies = http.cookiejar.CookieJar()
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
+ request = urllib.request.Request(
+ f"{base_url.rstrip('/')}/api/v2/auth/login",
+ data=urllib.parse.urlencode(
+ {"username": username, "password": password}
+ ).encode("utf-8"),
+ )
+ with opener.open(request, timeout=5) as response:
+ body = response.read().decode("utf-8", errors="replace").strip()
+ if response.status not in (200, 204) or (
+ response.status == 200 and body.lower() != "ok."
+ ):
+ raise AssertionError(
+ f"qBittorrent rejected fixed E2E credentials: "
+ f"HTTP {response.status} {body!r}"
+ )
+ with opener.open(
+ f"{base_url.rstrip('/')}/api/v2/app/preferences", timeout=5
+ ) as response:
+ preferences = json.loads(response.read().decode("utf-8"))
+ if not isinstance(preferences, dict):
+ raise AssertionError("qBittorrent preferences response is not an object")
+ validate_qb_preferences(preferences)
+ return preferences
+
+
+def _read_env_file(path: Path) -> dict[str, str]:
+ values: dict[str, str] = {}
+ if not path.is_file():
+ return values
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, raw_value = line.split("=", 1)
+ try:
+ value = json.loads(raw_value)
+ except json.JSONDecodeError:
+ value = raw_value
+ values[key] = str(value)
+ return values
+
+
+def _write_env_file(path: Path, values: Mapping[str, str]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ lines = []
+ for key, value in sorted(values.items()):
+ if "\n" in value or "\r" in value:
+ raise ValueError(f"Environment value for {key} contains a newline")
+ lines.append(f"{key}={json.dumps(value)}")
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def _load_artifact_module(repo_root: Path):
+ path = repo_root / "e2e/scripts/collect_artifacts.py"
+ spec = importlib.util.spec_from_file_location("ab_e2e_collect_artifacts", path)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"Cannot load artifact collector: {path}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+class Stack:
+ """One Compose project with isolated bind mounts and dynamic host ports."""
+
+ def __init__(
+ self,
+ *,
+ profile: str,
+ repo_root: Path | None = None,
+ work_dir: Path | None = None,
+ project_name: str | None = None,
+ runner=subprocess.run,
+ ) -> None:
+ if profile not in PROFILES:
+ raise ValueError(f"Unknown E2E profile: {profile}")
+ self.profile = profile
+ self.repo_root = (repo_root or repository_root()).resolve()
+ self.project_name = project_name or unique_project_name(profile)
+ self.work_dir = (
+ work_dir.resolve()
+ if work_dir is not None
+ else Path(tempfile.mkdtemp(prefix=f"{self.project_name}-")).resolve()
+ )
+ self.config_dir = self.work_dir / "config"
+ self.data_dir = self.work_dir / "data"
+ self.download_dir = self.work_dir / "downloads"
+ self.log_dir = self.work_dir / "logs"
+ self.log_file = self.log_dir / "log.txt"
+ self.qb_config_dir = self.work_dir / "qb-config"
+ self.artifact_dir = self.work_dir / "artifacts"
+ self.env_file = self.work_dir / "stack.env"
+ self._runner = runner
+ self._started = False
+
+ for directory in (
+ self.config_dir,
+ self.data_dir,
+ self.download_dir,
+ self.log_dir,
+ self.qb_config_dir,
+ self.artifact_dir,
+ ):
+ directory.mkdir(parents=True, exist_ok=True)
+ self.log_file.touch(exist_ok=True)
+ self._install_qb_config()
+
+ existing = _read_env_file(self.env_file)
+ self.environment: dict[str, str] = {
+ "AB_E2E_PROFILE": self.profile,
+ "AB_E2E_PROJECT": self.project_name,
+ "AB_E2E_CONFIG_DIR": str(self.config_dir),
+ "AB_E2E_DATA_DIR": str(self.data_dir),
+ "AB_E2E_DOWNLOAD_DIR": str(self.download_dir),
+ "AB_E2E_LOG_FILE": str(self.log_file),
+ "AB_E2E_QB_CONFIG_DIR": str(self.qb_config_dir),
+ "AB_E2E_FIXTURES_DIR": str(self.repo_root / "e2e/fixtures"),
+ "AB_E2E_ARTIFACT_DIR": str(self.artifact_dir),
+ "AB_E2E_USERNAME": "testadmin",
+ "AB_E2E_PASSWORD": "testpassword123",
+ "AB_E2E_APP_IMAGE": os.environ.get("AB_E2E_APP_IMAGE", "auto-bangumi:e2e"),
+ "AB_E2E_MOCK_IMAGE": os.environ.get(
+ "AB_E2E_MOCK_IMAGE", f"{self.project_name}/mock-upstream:local"
+ ),
+ "AB_E2E_FAKE_QB_IMAGE": os.environ.get(
+ "AB_E2E_FAKE_QB_IMAGE", f"{self.project_name}/fake-qb:local"
+ ),
+ "AB_E2E_PUID": str(getattr(os, "getuid", lambda: 1000)()),
+ "AB_E2E_PGID": str(getattr(os, "getgid", lambda: 1000)()),
+ "AB_E2E_MOCK_INTERNAL_URL": "http://mock-upstream:18888",
+ "AB_E2E_FAKE_QB_INTERNAL_URL": "http://fake-qb:8080",
+ "AB_E2E_QB_INTERNAL_URL": "http://qbittorrent:8080",
+ **existing,
+ }
+ self.write_environment()
+
+ @property
+ def compose_files(self) -> tuple[Path, ...]:
+ files = (self.repo_root / "e2e/compose/browser.yml",)
+ if self.profile == "downloader":
+ files += (self.repo_root / "e2e/compose/downloader.yml",)
+ return files
+
+ @property
+ def process_environment(self) -> dict[str, str]:
+ return {**os.environ, **self.environment}
+
+ def _install_qb_config(self) -> None:
+ source = self.repo_root / "e2e/fixtures/qbittorrent/qBittorrent.conf"
+ destination = self.qb_config_dir / "qBittorrent/qBittorrent.conf"
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if not destination.exists():
+ shutil.copy2(source, destination)
+
+ def write_environment(self) -> Path:
+ _write_env_file(self.env_file, self.environment)
+ return self.env_file
+
+ def compose_command(self, *arguments: str) -> list[str]:
+ command = [
+ "docker",
+ "compose",
+ "--project-name",
+ self.project_name,
+ "--env-file",
+ str(self.env_file),
+ ]
+ for compose_file in self.compose_files:
+ command.extend(("--file", str(compose_file)))
+ command.extend(arguments)
+ return command
+
+ def _run_compose(
+ self,
+ *arguments: str,
+ check: bool = True,
+ capture_output: bool = False,
+ ):
+ return self._runner(
+ self.compose_command(*arguments),
+ cwd=self.repo_root,
+ env=self.process_environment,
+ check=check,
+ capture_output=capture_output,
+ text=capture_output,
+ )
+
+ def validate(self) -> None:
+ self._run_compose("config", capture_output=True)
+
+ def _published_port(self, service: str, container_port: int) -> int:
+ result = self._run_compose(
+ "port",
+ "--index",
+ "1",
+ service,
+ str(container_port),
+ capture_output=True,
+ )
+ return parse_published_port(result.stdout)
+
+ def ports(self) -> dict[str, int]:
+ ports = {
+ "app": self._published_port("app", 7892),
+ "mock-upstream": self._published_port("mock-upstream", 18888),
+ "fake-qb": self._published_port("fake-qb", 8080),
+ }
+ if self.profile == "downloader":
+ ports["qbittorrent"] = self._published_port("qbittorrent", 8080)
+ return ports
+
+ def _publish_urls(self, ports: Mapping[str, int]) -> None:
+ self.environment.update(
+ {
+ "AB_E2E_BASE_URL": f"http://127.0.0.1:{ports['app']}",
+ "AB_E2E_MOCK_URL": (f"http://127.0.0.1:{ports['mock-upstream']}"),
+ "AB_E2E_FAKE_QB_URL": f"http://127.0.0.1:{ports['fake-qb']}",
+ }
+ )
+ if "qbittorrent" in ports:
+ real_qb_url = f"http://127.0.0.1:{ports['qbittorrent']}"
+ self.environment["AB_E2E_REAL_QB_URL"] = real_qb_url
+ self.environment["AB_E2E_QB_URL"] = real_qb_url
+ self.write_environment()
+
+ def start(self) -> dict[str, int]:
+ if self.profile == "downloader":
+ self._run_compose("pull", "qbittorrent")
+ self._run_compose("build", "mock-upstream", "fake-qb")
+ self._run_compose(
+ "up",
+ "--detach",
+ "--wait",
+ "--wait-timeout",
+ str(DEFAULT_WAIT_SECONDS),
+ )
+ self._started = True
+ published = self.ports()
+ self._publish_urls(published)
+ if self.profile == "downloader":
+ verify_qb_runtime(self.environment["AB_E2E_REAL_QB_URL"])
+ return published
+
+ def _wait_for_health(self, timeout: float = 30) -> None:
+ url = f"{self.environment['AB_E2E_BASE_URL'].rstrip('/')}/health"
+ deadline = time.monotonic() + timeout
+ last_error = "no request attempted"
+ while time.monotonic() < deadline:
+ try:
+ with urllib.request.urlopen(url, timeout=2) as response:
+ if response.status == 200:
+ return
+ last_error = f"HTTP {response.status}"
+ except (OSError, urllib.error.URLError) as exc:
+ last_error = repr(exc)
+ time.sleep(0.2)
+ raise TimeoutError(f"App did not become healthy at {url}: {last_error}")
+
+ def restart(self, service: str = "app") -> None:
+ self._run_compose("restart", service)
+ if service == "app" and "AB_E2E_BASE_URL" in self.environment:
+ self._wait_for_health()
+ else:
+ self._run_compose(
+ "up",
+ "--detach",
+ "--wait",
+ "--wait-timeout",
+ str(DEFAULT_WAIT_SECONDS),
+ service,
+ )
+
+ def collect(self) -> None:
+ collector = _load_artifact_module(self.repo_root)
+ admin_urls = {}
+ if "AB_E2E_MOCK_URL" in self.environment:
+ admin_urls["mock-upstream"] = self.environment["AB_E2E_MOCK_URL"]
+ if "AB_E2E_FAKE_QB_URL" in self.environment:
+ admin_urls["fake-qb"] = self.environment["AB_E2E_FAKE_QB_URL"]
+ collector.collect_artifacts(
+ compose_command=self.compose_command(),
+ artifact_dir=self.artifact_dir,
+ data_dir=self.data_dir,
+ log_file=self.log_file,
+ admin_urls=admin_urls,
+ qb_url=self.environment.get("AB_E2E_REAL_QB_URL"),
+ environment=self.process_environment,
+ secrets=(
+ self.environment.get("AB_E2E_SESSION_TOKEN", ""),
+ self.environment.get("AB_E2E_API_TOKEN", ""),
+ ),
+ )
+
+ def stop(self, *, collect: bool = True) -> None:
+ try:
+ if collect and self._started:
+ self.collect()
+ finally:
+ self._run_compose(
+ "down",
+ "--volumes",
+ "--remove-orphans",
+ check=False,
+ )
+ self._started = False
+
+
+CommandRunner = Callable[[Sequence[str], Mapping[str, str]], object]
+
+
+def _default_command_runner(
+ command: Sequence[str], environment: Mapping[str, str]
+) -> int:
+ return subprocess.run(list(command), env=dict(environment), check=False).returncode
+
+
+@contextmanager
+def scoped_signal_cleanup(stack):
+ """Ensure SIGINT/SIGTERM tear down this stack, then restore prior handlers."""
+ previous: dict[int, object] = {}
+ cleaning = False
+
+ def handle(signum, _frame):
+ nonlocal cleaning
+ if not cleaning:
+ cleaning = True
+ stack.stop(collect=True)
+ raise SystemExit(128 + signum)
+
+ try:
+ for signum in (signal.SIGINT, signal.SIGTERM):
+ previous[signum] = signal.getsignal(signum)
+ signal.signal(signum, handle)
+ yield
+ finally:
+ for signum, handler in previous.items():
+ signal.signal(signum, handler)
+
+
+def run_with_stack(
+ stack,
+ command: Sequence[str],
+ *,
+ runner: CommandRunner = _default_command_runner,
+):
+ with scoped_signal_cleanup(stack):
+ stack.start()
+ try:
+ return runner(list(command), {**os.environ, **stack.environment})
+ finally:
+ stack.stop(collect=True)
+
+
+def _add_stack_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("--profile", choices=sorted(PROFILES), default="browser")
+ parser.add_argument("--repo-root", type=Path, default=repository_root())
+ parser.add_argument("--work-dir", type=Path)
+ parser.add_argument("--project-name")
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest="action", required=True)
+ for action in ("validate", "start", "stop", "ports", "env", "smoke"):
+ subparser = subparsers.add_parser(action)
+ _add_stack_arguments(subparser)
+ restart = subparsers.add_parser("restart")
+ _add_stack_arguments(restart)
+ restart.add_argument("service", nargs="?", default="app")
+ run = subparsers.add_parser("run")
+ _add_stack_arguments(run)
+ run.add_argument("command", nargs=argparse.REMAINDER)
+ return parser
+
+
+def _stack_from_args(args) -> Stack:
+ return Stack(
+ profile=args.profile,
+ repo_root=args.repo_root,
+ work_dir=args.work_dir,
+ project_name=args.project_name,
+ )
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = _parser().parse_args(argv)
+ stack = _stack_from_args(args)
+ if args.action == "validate":
+ stack.validate()
+ return 0
+ if args.action == "start":
+ print(json.dumps(stack.start(), sort_keys=True))
+ print(stack.env_file)
+ return 0
+ if args.action == "stop":
+ stack.stop(collect=True)
+ return 0
+ if args.action == "ports":
+ ports = stack.ports()
+ stack._publish_urls(ports)
+ print(json.dumps(ports, sort_keys=True))
+ return 0
+ if args.action == "env":
+ print(stack.env_file.read_text(encoding="utf-8"), end="")
+ return 0
+ if args.action == "restart":
+ stack.restart(args.service)
+ return 0
+ if args.action == "smoke":
+ with scoped_signal_cleanup(stack):
+ stack.start()
+ try:
+ stack._wait_for_health()
+ finally:
+ stack.stop(collect=True)
+ return 0
+ if args.action == "run":
+ command = args.command[1:] if args.command[:1] == ["--"] else args.command
+ if not command:
+ raise SystemExit("A command is required after --")
+ result = run_with_stack(stack, command)
+ return int(result) if isinstance(result, int) else 0
+ raise AssertionError(f"Unhandled action: {args.action}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())