252 lines
11 KiB
Python
252 lines
11 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
|
|
|
|
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)
|