from __future__ import annotations import json import os import socket import ssl import subprocess import sys import tarfile import threading import time import urllib.request from contextlib import contextmanager from pathlib import Path import pytest if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": pytest.skip("release artifact browser journey runs only in its gated CI job", allow_module_level=True) pytest.importorskip("playwright.sync_api") from playwright.sync_api import Page, expect, sync_playwright from fake_gitea import FakeGiteaServer ROOT = Path(__file__).resolve().parents[2] TITLE = "Offline artifact journey 863" BODY = "Captured on a phone, retained offline, delivered exactly once." ACCESS_TOKEN = "artifact-browser-access-token-863" def free_port() -> int: with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) def wait_for_health(url: str, process: subprocess.Popen[str]) -> None: deadline = time.monotonic() + 20 tls = ssl._create_unverified_context() while time.monotonic() < deadline: if process.poll() is not None: stdout, stderr = process.communicate() raise AssertionError(f"release server exited early\nstdout:\n{stdout}\nstderr:\n{stderr}") try: with urllib.request.urlopen(url, timeout=0.5, context=tls) as response: if response.status == 200: return except OSError: time.sleep(0.05) raise AssertionError("release server did not become healthy") @contextmanager def release_server(archive: Path, tmp_path: Path, gitea_url: str): release_root = tmp_path / "release" release_root.mkdir() with tarfile.open(archive, "r:gz") as bundle: bundle.extractall(release_root, filter="data") assert (release_root / "release-manifest.json").is_file() assert not (release_root / "tests").exists(), "journey must run the packaged artifact, not checkout code" port = free_port() state_dir = tmp_path / "state" state_dir.mkdir() key_file = tmp_path / "localhost.key" certificate_file = tmp_path / "localhost.crt" subprocess.run( [ "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", str(key_file), "-out", str(certificate_file), "-days", "1", "-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1", ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) origin = f"https://127.0.0.1:{port}" env = { **os.environ, "PYTHONPATH": str(release_root), "GITEA_URL": gitea_url, "GITEA_TOKEN": "deterministic-e2e-token", "STACKCHAIN_DASHBOARD_AUTH_MODE": "operator", "STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN, "STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863", "STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin, "STACKCHAIN_STATE_DIR": str(tmp_path / "state"), } process = subprocess.Popen( [ sys.executable, "-m", "uvicorn", "src.main:app", "--host", "127.0.0.1", "--port", str(port), "--ssl-keyfile", str(key_file), "--ssl-certfile", str(certificate_file), ], cwd=release_root, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) try: wait_for_health(origin + "/healthz", process) yield origin finally: process.terminate() try: process.communicate(timeout=10) except subprocess.TimeoutExpired: process.kill() process.communicate() def indexed_issue_records(page: Page) -> list[dict]: return page.evaluate( """async () => { const db = await new Promise((resolve, reject) => { const request = indexedDB.open('stackchain-background-outbox-v1', 1); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return await new Promise((resolve, reject) => { const request = db.transaction('issues', 'readonly').objectStore('issues').getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }""" ) def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_reload(tmp_path: Path): archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) assert len(archives) == 1, "browser job must download exactly one assembled release archive" fake = FakeGiteaServer(("127.0.0.1", 0)) fake_thread = threading.Thread(target=fake.serve_forever, daemon=True) fake_thread.start() fake_url = f"http://127.0.0.1:{fake.server_port}" browser_errors: list[str] = [] failed_responses: list[str] = [] try: with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright: browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) context = browser.new_context( viewport={"width": 390, "height": 844}, service_workers="allow", ignore_https_errors=True, ) page = context.new_page() page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error))) page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None) page.on("response", lambda response: failed_responses.append( f"{response.status} {response.url}" ) if response.status >= 400 else None) page.goto(origin + "/", wait_until="networkidle") expect(page.locator("#sign-in")).to_be_visible() page.locator('input[name="device_label"]').fill("Release journey phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") new_action = page.locator('[data-mobile-task="new"]') expect(new_action).to_be_visible() service_worker_ready = page.evaluate( """() => Promise.race([ navigator.serviceWorker.ready.then(() => true), new Promise(resolve => setTimeout(() => resolve(false), 10000)), ])""" ) assert service_worker_ready, ( "release artifact did not register its service worker; browser errors=" + repr(browser_errors) + "; failed responses=" + repr(failed_responses) ) if not page.evaluate("() => navigator.serviceWorker.controller !== null"): page.reload(wait_until="networkidle") new_action = page.locator('[data-mobile-task="new"]') assert page.evaluate("() => navigator.serviceWorker.controller !== null") new_action.click() page.locator("#create-issue-title").fill(TITLE) page.locator("#create-issue-body").fill(BODY) page.locator("#file-new-issue").click() expect(page.locator("#create-issue-filing")).to_be_visible() page.locator("#create-issue-repository").select_option("acme/mobile") expect(page.locator("#submit-new-issue")).to_be_enabled() context.set_offline(True) page.locator("#submit-new-issue").click() expect(page.locator("#issue-filing-review")).to_be_visible() page.locator("#confirm-issue-filing").click() page.wait_for_timeout(1_000) filing_statuses = page.locator( "#my-work-action-status, #create-issue-status, #issue-filing-review-status" ).all_inner_texts() assert any( marker in " ".join(filing_statuses).lower() for marker in ("queued", "saved for", "background delivery") ), (filing_statuses, browser_errors, failed_responses) local_record = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")) assert [(item["title"], item["body"], item["repository"]) for item in local_record["items"]] == [ (TITLE, BODY, "acme/mobile") ] durable = indexed_issue_records(page) assert [(item["title"], item["body"], item["repository"]) for item in durable] == [ (TITLE, BODY, "acme/mobile") ] operation_id = durable[0]["operationId"] assert operation_id and operation_id == local_record["items"][0]["operationId"] assert fake.created_issues == [] page.reload(wait_until="domcontentloaded") expect(page.locator('[data-mobile-task="new"]')).to_be_visible() durable_after_reload = indexed_issue_records(page) assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)] assert durable_after_reload[0]["operationId"] == operation_id assert fake.created_issues == [] browser_errors.clear() # Chromium reports expected network errors while the context is offline. failed_responses.clear() fake.issue_creation_enabled = True context.set_offline(False) page.evaluate("window.dispatchEvent(new Event('online'))") for _ in range(80): if fake.created_issues: break page.wait_for_timeout(250) assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}] for _ in range(40): durable_completion = indexed_issue_records(page) if durable_completion and all(item.get("status") == "sent" for item in durable_completion): break page.wait_for_timeout(100) assert durable_completion and all(item.get("status") == "sent" for item in durable_completion) page.reload(wait_until="networkidle") page.evaluate("window.dispatchEvent(new Event('online'))") for _ in range(40): completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")) if completed_local["items"] == []: break page.wait_for_timeout(100) assert completed_local["items"] == [] page.wait_for_timeout(500) assert len(fake.created_issues) == 1 assert browser_errors == [] assert failed_responses == [] assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") browser.close() finally: fake.shutdown() fake.server_close() fake_thread.join(timeout=5)