from __future__ import annotations import os import threading from pathlib import Path import pytest if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": pytest.skip("packaged mobile Home bootstrap runs only in its gated CI job", allow_module_level=True) pytest.importorskip("playwright.sync_api") from playwright.sync_api import expect, sync_playwright from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( tmp_path: Path, width: int, height: int ): 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] = [] workspace_requests: list[str] = [] live_requests: 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": width, "height": height}, 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.on( "request", lambda request: workspace_requests.append(request.url) if "feature-today-timer-" in request.url else None, ) page.on( "request", lambda request: live_requests.append(request.url) if "/api/v1/live" in request.url else None, ) page.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("Home bootstrap release phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") expect(page.locator("#my-work-status")).to_contain_text("2") initial_live_requests = len(live_requests) page.evaluate( """ () => { window.scrollTo(0, 0); const surface = document.querySelector('#my-work'); surface.setPointerCapture = () => {}; surface.releasePointerCapture = () => {}; const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, { bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y, })); pointer('pointerdown', 10); pointer('pointermove', 90); } """ ) expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh") page.evaluate( "document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', " "{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))" ) expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date") assert len(live_requests) == initial_live_requests + 1 dock = page.locator("#mobile-task-dock") expect(dock).to_be_visible() expect(dock.locator("button")).to_have_count(5) for control in dock.locator("button").all(): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 page.locator('[data-mobile-task="queues"]').click() delivery_queue = page.locator('[data-mobile-queue="delivery"]') expect(delivery_queue).to_be_visible() expect(delivery_queue).to_contain_text("Delivery") delivery_bounds = delivery_queue.bounding_box() assert delivery_bounds and delivery_bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#close-mobile-queues").click() page.locator("#app-menu-toggle").click() page.locator("#open-insights").click() expect(page.locator("#insights-sheet")).to_be_visible() expect(page.locator("#insights-heading")).to_have_text("Insights") page.locator("#close-insights").click() expect(page.locator("#insights-sheet")).to_be_hidden() expect(page.locator("#my-work")).to_be_visible() expect(dock).to_be_visible() page.locator("#app-menu-toggle").click() page.locator("#open-device-setup").click() expect(page.locator("#device-setup-sheet")).to_be_visible() expect(page.locator("#device-storage-heading")).to_have_text("Private device storage") expect(page.locator("#device-storage-detail")).to_contain_text("private work records") for selector in ("#clear-device-caches", "#clear-private-device-data"): bounds = page.locator(selector).bounding_box() assert bounds and bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#close-device-setup").click() expect(page.locator("#device-setup-sheet")).to_be_hidden() page.locator("#active-devices").click() expect(page.locator("#active-devices-sheet")).to_be_visible() expect(page.locator("#security-activity-section")).to_be_visible() security_nav = page.locator(".security-section-nav") expect(security_nav).to_be_visible() for control in security_nav.locator("button").all(): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 page.locator("#security-section-devices").click() expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page") page.go_back() expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page") page.go_back() expect(page.locator("#active-devices-sheet")).to_be_hidden() expect(page.locator("#active-devices")).to_be_focused() assert len(workspace_requests) == 1, workspace_requests assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert browser_errors == [] assert failed_responses == [] browser.close() finally: fake.shutdown() fake.server_close() fake_thread.join(timeout=5) def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(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] = [] try: with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright: browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) page = browser.new_page(viewport={"width": 390, "height": 844}) page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error))) page.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("Delivery recovery release phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") page.evaluate( """ () => { window.__deliveryAttempts = 0; window.__deliverySettled = new Promise(resolve => { window.__finishDelivery = resolve; }); window.__releaseRecovery = createMobileDeliveryRecovery({ getItems: () => [{outbox_id:'release-retry', delivery_state:'waiting', title:'Post release note'}], activate: async () => { window.__deliveryAttempts += 1; await window.__deliverySettled; }, }); window.__releaseRecovery.open(); window.__firstRecovery = window.__releaseRecovery.activate(); window.__secondRecovery = window.__releaseRecovery.activate(); } """ ) action = page.locator("#mobile-delivery-recovery-action") expect(page.locator("#mobile-delivery-recovery")).to_be_visible() expect(action).to_be_disabled() expect(page.locator("#mobile-delivery-recovery-status")).to_have_text("Working…") assert page.evaluate("window.__deliveryAttempts") == 1 bounds = action.bounding_box() assert bounds and bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.evaluate("window.__finishDelivery()") page.evaluate("Promise.all([window.__firstRecovery, window.__secondRecovery])") expect(action).to_be_enabled() assert page.evaluate("window.__deliveryAttempts") == 1 page.evaluate( """ () => { const destination = document.createElement('button'); destination.type = 'button'; destination.className = 'draft-resume'; destination.dataset.draftIndex = '0'; destination.textContent = 'Open current review'; window.__attentionAttempts = 0; window.__attentionSettled = new Promise(resolve => { window.__finishAttention = resolve; }); destination.onclick = async () => { window.__attentionAttempts += 1; await window.__attentionSettled; return true; }; document.querySelector('#my-work-list').append(destination); window.__attentionRecovery = createMobileDeliveryRecovery({ getItems: () => [{outbox_id:'review-attention', status:'attention', title:'Review feedback'}], getIndex: () => 0, }); window.__attentionRecovery.open(); window.__firstAttention = window.__attentionRecovery.activate(); window.__secondAttention = window.__attentionRecovery.activate(); } """ ) expect(page.locator("#mobile-delivery-recovery")).to_be_hidden() expect(page.locator("#my-work-list .draft-resume")).to_be_focused() assert page.evaluate("window.__attentionAttempts") == 1 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.evaluate("window.__finishAttention()") page.evaluate("Promise.all([window.__firstAttention, window.__secondAttention])") assert page.evaluate("window.__attentionAttempts") == 1 assert browser_errors == [] browser.close() finally: fake.shutdown() fake.server_close() fake_thread.join(timeout=5) @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot( tmp_path: Path, width: int, height: int ): archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) assert len(archives) == 1, "browser job must download exactly one assembled release archive" original_due_dates = [item.get("due_date") for item in AVAILABLE_ISSUES] AVAILABLE_ISSUES[0]["due_date"] = "2026-08-19" AVAILABLE_ISSUES[1]["due_date"] = "2026-08-20" 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"]) page = browser.new_page(viewport={"width": width, "height": height}) 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") page.locator('input[name="device_label"]').fill("Agenda export release phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") page.locator('[data-mobile-task="queues"]').click() page.locator('[data-mobile-queue="agenda"]').click() expect(page.locator("#issue-sheet")).to_be_visible() page.locator("#close-issue-sheet").click() expect(page.locator("#issue-sheet")).to_be_hidden() expect(page.locator("#agenda-export")).to_be_visible() trigger = page.locator("#open-agenda-export") expect(trigger).to_be_enabled() bounds = trigger.bounding_box() assert bounds and bounds["height"] >= 44 trigger.click() expect(page.locator("#agenda-export-sheet")).to_be_visible() expect(page.locator("#agenda-export-items input:checked")).to_have_count(2) page.locator("#agenda-export-items input").nth(1).uncheck() expect(page.locator("#agenda-export-status")).to_have_text("1 of 2 deadlines selected.") assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") with page.expect_download() as pending: page.locator("#share-agenda-export").click() download = pending.value text = Path(download.path()).read_text() assert download.suggested_filename.startswith("stackchain-agenda-") assert text.count("BEGIN:VEVENT") == 1 assert "SUMMARY:Ship mobile capture" in text assert "Polish desktop filters" not in text expect(trigger).to_be_focused() assert browser_errors == [] assert failed_responses == [] browser.close() finally: for item, due_date in zip(AVAILABLE_ISSUES, original_due_dates): if due_date is None: item.pop("due_date", None) else: item["due_date"] = due_date fake.shutdown() fake.server_close() fake_thread.join(timeout=5)