From 9f5aa33e9509a77ffd6293bb450eaf57549ab14c Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 02:13:15 +0000 Subject: [PATCH] test: gate releases on mobile offline filing Closes #863 --- .gitea/workflows/ci.yml | 21 +- frontend/dashboard.js | 7 +- frontend/draft-capacity-dialog.js | 7 +- requirements-e2e.txt | 2 + tests/e2e/fake_gitea.py | 82 ++++++ .../e2e/test_mobile_offline_issue_release.py | 251 ++++++++++++++++++ tests/test_ci_workflow.py | 15 +- tests/test_unfiled_captures.py | 3 +- 8 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 requirements-e2e.txt create mode 100644 tests/e2e/fake_gitea.py create mode 100644 tests/e2e/test_mobile_offline_issue_release.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8e1dd5c..2b3a965 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,9 +36,28 @@ jobs: name: release-bundle path: dist/ + browser-journey: + runs-on: ubuntu-latest + needs: build-release + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: "3.11" } + - name: Download assembled release bundle + uses: actions/download-artifact@v3 + with: + name: release-bundle + path: dist + - name: Install browser test dependencies + run: | + pip install -r requirements-e2e.txt + python3 -m playwright install --with-deps chromium + - name: Exercise mobile offline filing from the release artifact + run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q + release-candidate: runs-on: ubuntu-latest - needs: [lint, build-release] + needs: [lint, build-release, browser-journey] if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: write diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c754870..9421298 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -5055,7 +5055,7 @@ const evidence = await createIssueAttachmentController.serialize(); const captureDraft = currentIssueCaptureDraft(); Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}); - if (showDraftCapacityDialog(unfiledCaptures)) return; + if (showDraftCapacityDialog(unfiledCaptures, qs)) return; const savedCapture = await unfiledCaptures.save(captureDraft); if (rUC) { await unfiledCaptures.completeResume(rUC); @@ -5080,7 +5080,10 @@ qs('#create-issue-title').focus(); } }); - bindDraftCapacityDialog(); + bindDraftCapacityDialog({ + qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView, + unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture, + }); qs('#use-shared-content').addEventListener('click', () => { if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp(); else issueCapture.acceptSharedContent(); diff --git a/frontend/draft-capacity-dialog.js b/frontend/draft-capacity-dialog.js index 0401cc5..5fddb38 100644 --- a/frontend/draft-capacity-dialog.js +++ b/frontend/draft-capacity-dialog.js @@ -1,4 +1,4 @@ -function showDraftCapacityDialog(captures) { +function showDraftCapacityDialog(captures, qs) { const state = captures.capacity(); if (!state.full) return false; const oldest = state.oldest; @@ -11,7 +11,10 @@ function showDraftCapacityDialog(captures) { return true; } -function bindDraftCapacityDialog() { +function bindDraftCapacityDialog({ + qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView, + unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture, +}) { qs('#keep-editing-draft').addEventListener('click', () => { qs('#draft-capacity-sheet').hidden = true; qs('#save-unfiled-issue').focus(); diff --git a/requirements-e2e.txt b/requirements-e2e.txt new file mode 100644 index 0000000..ccf9bb8 --- /dev/null +++ b/requirements-e2e.txt @@ -0,0 +1,2 @@ +-r requirements.txt +playwright==1.55.0 diff --git a/tests/e2e/fake_gitea.py b/tests/e2e/fake_gitea.py new file mode 100644 index 0000000..c346056 --- /dev/null +++ b/tests/e2e/fake_gitea.py @@ -0,0 +1,82 @@ +"""Deterministic Gitea double used by the release-artifact browser journey.""" + +from __future__ import annotations + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + + +REPOSITORY = { + "id": 7, + "name": "mobile", + "full_name": "acme/mobile", + "description": "Mobile filing acceptance repository", + "html_url": "http://127.0.0.1/acme/mobile", + "owner": {"login": "acme"}, +} +USER = {"id": 1, "login": "timmy", "full_name": "Timmy"} + + +class FakeGiteaServer(ThreadingHTTPServer): + def __init__(self, address: tuple[str, int]): + super().__init__(address, FakeGiteaHandler) + self.created_issues: list[dict] = [] + + +class FakeGiteaHandler(BaseHTTPRequestHandler): + server: FakeGiteaServer + + def log_message(self, _format: str, *_args: object) -> None: + return + + def _json(self, status: int, payload: object, **headers: str) -> None: + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + for name, value in headers.items(): + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + path = urlsplit(self.path).path + if path == "/api/v1/user": + self._json(200, USER) + elif path == "/api/v1/user/repos": + self._json(200, [REPOSITORY], **{"X-Total-Count": "1"}) + elif path in {"/api/v1/repos/acme/mobile", "/api/v1/repos/acme/mobile/"}: + self._json(200, REPOSITORY) + elif path == "/api/v1/repos/search": + self._json(200, {"data": [REPOSITORY], "ok": True}) + elif path.startswith("/api/v1/"): + self._json(200, [], **{"X-Total-Count": "0"}) + else: + self._json(404, {"message": "not found"}) + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + path = urlsplit(self.path).path + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length) or b"{}") + if path != "/api/v1/repos/acme/mobile/issues": + self._json(404, {"message": "not found"}) + return + self.server.created_issues.append(payload) + assignee = payload.get("assignee") + self._json( + 201, + { + "id": 101, + "number": 41, + "title": payload.get("title", ""), + "body": payload.get("body", ""), + "state": "open", + "html_url": "http://127.0.0.1/acme/mobile/issues/41", + "assignees": [{"login": assignee}] if assignee else [], + "labels": [], + "milestone": None, + "due_date": payload.get("due_date"), + "updated_at": "2026-08-15T12:00:00Z", + }, + ) diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py new file mode 100644 index 0000000..baa2db6 --- /dev/null +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -0,0 +1,251 @@ +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 + +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() + 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) diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 49ad107..3689d34 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -21,8 +21,21 @@ def test_release_promotion_waits_for_tests_and_bundle(): text = WORKFLOW.read_text() release = text[text.index(" release-candidate:") :] - assert "needs: [lint, build-release]" in release + assert "needs: [lint, build-release, browser-journey]" in release assert "github.event_name == 'push'" in release assert "actions/download-artifact@v3" in release assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release assert release.index("sha256sum -c") < release.index("curl --fail-with-body") + + +def test_release_promotion_waits_for_artifact_mobile_offline_journey(): + text = WORKFLOW.read_text() + browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")] + release = text[text.index(" release-candidate:") :] + + assert "needs: build-release" in browser + assert "actions/download-artifact@v3" in browser + assert "pip install -r requirements-e2e.txt" in browser + assert "python3 -m playwright install --with-deps chromium" in browser + assert "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q" in browser + assert "needs: [lint, build-release, browser-journey]" in release diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index eaf8b59..58029a3 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -739,7 +739,8 @@ async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision( assert 'id="review-full-drafts"' in html assert 'id="replace-oldest-draft"' in html assert 'id="keep-editing-draft"' in html - assert "showDraftCapacityDialog(unfiledCaptures)" in html + assert "showDraftCapacityDialog(unfiledCaptures, qs)" in html + assert "bindDraftCapacityDialog({" in html feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text() assert "const draft = currentIssueCaptureDraft();" in feature assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature