Merge pull request 'Gate releases on a real mobile offline filing journey' (#864) from timmy/863-mobile-offline-release-gate into main
Some checks failed
CI / lint (push) Failing after 1m52s
CI / build-release (push) Has been skipped
CI / browser-journey (push) Has been skipped
CI / release-candidate (push) Has been skipped

This commit is contained in:
timmy 2026-08-15 02:17:33 +00:00
commit cf9a02ae38
8 changed files with 381 additions and 7 deletions

View File

@ -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

View File

@ -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();

View File

@ -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();

2
requirements-e2e.txt Normal file
View File

@ -0,0 +1,2 @@
-r requirements.txt
playwright==1.55.0

82
tests/e2e/fake_gitea.py Normal file
View File

@ -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",
},
)

View File

@ -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)

View File

@ -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

View File

@ -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