stackchain-dashboard/tests/e2e/test_mobile_offline_issue_release.py
timmy cf76bc3636
All checks were successful
CI / lint (pull_request) Successful in 2m40s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m3s
CI / release-candidate (pull_request) Has been skipped
test: model worker transport outage deterministically
2026-08-17 02:46:42 +00:00

326 lines
15 KiB
Python

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,
)
context.add_init_script("""
class DeterministicSpeechRecognition {
start() { window.__voiceRecognition = this; }
stop() { if (this.onend) this.onend(); }
abort() { if (this.onend) this.onend(); }
}
window.SpeechRecognition = DeterministicSpeechRecognition;
""")
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()
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
assert page.evaluate("document.activeElement?.id") != "create-issue-title"
create_navigation = page.locator(".mobile-create-issue-nav")
expect(create_navigation).to_be_visible()
create_navigation_buttons = create_navigation.locator("button")
assert create_navigation_buttons.count() == 3
for control in create_navigation_buttons.all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
expect(create_navigation.locator('[data-create-issue-section="describe"]')).to_have_attribute(
"aria-current", "location"
)
expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
"aria-disabled", "true"
)
create_navigation.locator('[data-create-issue-section="evidence"]').click()
expect(create_navigation.locator('[data-create-issue-section="evidence"]')).to_have_attribute(
"aria-current", "location"
)
for control in page.locator(".photo-evidence-actions .issue-attachment-trigger").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#voice-issue-capture")).to_be_visible()
page.locator("#start-voice-issue-capture").click()
page.evaluate("""([title, body]) => {
const result = [{ transcript: title + ' ' + body }];
result.isFinal = true;
window.__voiceRecognition.onresult({ results: [result] });
}""", [TITLE, BODY])
expect(page.locator("#voice-issue-review")).to_be_visible()
expect(page.locator("#append-voice-issue-transcript")).to_have_text("Use transcript")
page.locator("#append-voice-issue-transcript").click()
expect(page.locator("#create-issue-title")).to_have_value(TITLE)
expect(page.locator("#create-issue-body")).to_have_value(BODY)
page.locator("#file-new-issue").click()
expect(page.locator("#create-issue-filing")).to_be_visible()
expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
"aria-current", "location"
)
expect(create_navigation.locator('[data-create-issue-section="file"]')).not_to_have_attribute(
"aria-disabled", "true"
)
page.locator("#create-issue-repository").select_option("acme/mobile")
expect(page.locator("#submit-new-issue")).to_be_enabled()
# Page offline emulation can leave service-worker requests connected.
# Abort the mutation at the browser boundary too, matching a real
# transport outage without poisoning server-side idempotency state.
context.route("**/api/v1/repos/acme/mobile/issues", lambda route: route.abort())
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
fake.issue_creation_ready.set()
context.unroute("**/api/v1/repos/acme/mobile/issues")
context.set_offline(False)
page.evaluate("window.dispatchEvent(new Event('online'))")
# Containerized Actions runners can take longer than a local browser to wake the
# service worker and complete its identity handshake after reconnect.
for _ in range(160):
if fake.created_issues:
break
page.wait_for_timeout(250)
local_debug = page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")
durable_debug = indexed_issue_records(page)
assert fake.created_issues == [
{"title": TITLE, "body": BODY, "assignee": "timmy"}
], (
f"url={page.url} ready={page.evaluate('document.readyState')} "
f"status={page.locator('#my-work-action-status').inner_text()!r} "
f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} "
f"local={local_debug!r} durable={durable_debug!r} "
f"requests={fake.requests[-20:]!r}"
)
# Deferred workspace startup can report its already-issued offline request
# after the pre-reconnect clear; retain every non-offline browser error.
browser_errors[:] = [
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
]
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.issue_creation_ready.set()
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)