Merge pull request 'Gate releases on a real mobile offline filing journey' (#864) from timmy/863-mobile-offline-release-gate into main
This commit is contained in:
commit
cf9a02ae38
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
2
requirements-e2e.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-r requirements.txt
|
||||
playwright==1.55.0
|
||||
82
tests/e2e/fake_gitea.py
Normal file
82
tests/e2e/fake_gitea.py
Normal 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",
|
||||
},
|
||||
)
|
||||
251
tests/e2e/test_mobile_offline_issue_release.py
Normal file
251
tests/e2e/test_mobile_offline_issue_release.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user