from __future__ import annotations import os import re 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"), [(390, 844)]) def test_release_artifact_guides_an_empty_mobile_account_to_first_work( 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.assigned_issue_numbers = [] fake_thread = threading.Thread(target=fake.serve_forever, daemon=True) fake_thread.start() fake_url = f"http://127.0.0.1:{fake.server_port}" 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.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("First task 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( "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" ) expect(page.locator("#my-work-status")).to_contain_text("No assigned work") page.locator('[data-mobile-task="work"]').click() sheet = page.locator("#mobile-first-task") expect(sheet).to_be_visible() expect(page.locator("#mobile-first-task-find")).to_be_focused() for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"): bounds = page.locator(selector).bounding_box() assert bounds and bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#mobile-first-task-find").click() expect(sheet).to_be_hidden() expect(page.locator("#find-work-sheet")).to_be_visible() page.locator("#close-find-work").click() context.set_offline(True) page.locator('[data-mobile-task="work"]').click() expect(sheet).to_be_visible() expect(page.locator("#mobile-first-task-find")).to_be_disabled() expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now") expect(page.locator("#mobile-first-task-create")).to_be_enabled() expect(page.locator("#mobile-first-task-create")).to_be_focused() page.locator("#close-mobile-first-task").click() context.set_offline(False) page.evaluate( """() => { document.querySelector('[data-mobile-today-hud]').hidden = false; localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching'); const productionCoach = document.querySelector('[data-mobile-first-task-coach]'); const isolatedCoach = productionCoach.cloneNode(true); productionCoach.replaceWith(isolatedCoach); window.firstTaskOutcomeProbe = createMobileFirstTask({ getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true, coach: isolatedCoach, }); return window.firstTaskOutcomeProbe.refresh(); }""" ) coach = page.locator("[data-mobile-first-task-coach]") expect(coach).to_be_visible() expect(coach).to_contain_text("Complete your first task") assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True expect(page.locator("#mobile-first-task-receipt")).to_be_visible() expect(coach).to_be_hidden() expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account") assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") fresh_context = browser.new_context( viewport={"width": width, "height": height}, ignore_https_errors=True ) fresh_page = fresh_context.new_page() fresh_page.goto(origin + "/", wait_until="networkidle") fresh_page.locator('input[name="device_label"]').fill("Replacement release phone") fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) fresh_page.locator("#submit-sign-in").click() fresh_page.wait_for_url(origin + "/", wait_until="networkidle") fresh_page.evaluate( "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" ) expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work") fresh_page.wait_for_load_state("networkidle") assert fresh_page.evaluate( "localStorage.getItem('stackchain.first-task.v1:timmy')" ) == "complete" fresh_page.locator('[data-mobile-task="work"]').click() expect(fresh_page.locator("#mobile-first-task")).to_be_hidden() fresh_context.close() 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_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] = [] optional_workspace_requests: list[str] = [] launch_transfer_events: 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() cdp = context.new_cdp_session(page) cdp.send("Network.enable") cdp.send( "Network.emulateNetworkConditions", { "offline": False, "latency": 100, "downloadThroughput": 48 * 1024, "uploadThroughput": 48 * 1024, "connectionType": "cellular3g", }, ) 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), launch_transfer_events.append("workspace-requested"), ) if "feature-work-core-" in request.url else None, ) page.on( "request", lambda request: optional_workspace_requests.append(request.url) if "feature-today-timer-" in request.url or "feature-planning-" in request.url else None, ) page.on( "requestfinished", lambda request: launch_transfer_events.append("core-finished") if "/runtime-" 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") assert optional_workspace_requests == [] page.locator('input[name="device_label"]').fill("Home bootstrap release phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) live_requests.clear() page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") assert launch_transfer_events.index("workspace-requested") < ( launch_transfer_events.index("core-finished") ), launch_transfer_events expect(page.locator("#my-work-status")).to_contain_text("2") assert optional_workspace_requests == [] revisionless_live_requests = [url for url in live_requests if "?" not in url] assert len(revisionless_live_requests) == 1, live_requests initial_live_requests = len(live_requests) page.locator("#work-settings-toggle").click() tomorrow = page.locator("#plan-tomorrow") expect(tomorrow).to_be_visible() assert len(optional_workspace_requests) == 2, optional_workspace_requests tomorrow_bounds = tomorrow.bounding_box() assert tomorrow_bounds and tomorrow_bounds["height"] >= 44 tomorrow.click() expect(page.locator("#plan-today-sheet")).to_be_visible() expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow") expect(page.locator("#save-and-start-today")).to_be_hidden() assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#cancel-plan-today").click() 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() page.locator(".mobile-queue-all summary").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") expect(page.locator("body > header")).to_have_attribute("inert", "") expect(page.locator("main")).to_have_attribute("inert", "") expect(dock).to_have_attribute("inert", "") expect(page.locator("#close-device-setup")).to_be_focused() page.keyboard.press("Shift+Tab") expect(page.locator("#clear-private-device-data")).to_be_focused() page.keyboard.press("Tab") expect(page.locator("#close-device-setup")).to_be_focused() 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.go_back() expect(page.locator("#device-setup-sheet")).to_be_hidden() expect(page.locator("#open-device-setup")).to_be_focused() expect(page.locator("body > header")).not_to_have_attribute("inert", "") expect(page.locator("main")).not_to_have_attribute("inert", "") expect(dock).not_to_have_attribute("inert", "") page.locator("#active-devices").click() expect(page.locator("#active-devices-sheet")).to_be_visible() expect(page.locator("#security-activity-section")).to_be_visible() expect(page.locator("body > header")).to_have_attribute("inert", "") expect(page.locator("main")).to_have_attribute("inert", "") expect(dock).to_have_attribute("inert", "") expect(page.locator("#close-active-devices")).to_be_focused() expect(page.locator("#active-devices-status")).to_contain_text("active device") expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading") page.keyboard.press("Shift+Tab") assert page.evaluate( """ () => { const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll( 'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])' )].filter(control => !control.hidden); return document.activeElement === controls.at(-1); } """ ) page.keyboard.press("Tab") expect(page.locator("#close-active-devices")).to_be_focused() 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") expect(page.locator("body > header")).to_have_attribute("inert", "") expect(page.locator("main")).to_have_attribute("inert", "") expect(dock).to_have_attribute("inert", "") page.go_back() expect(page.locator("#active-devices-sheet")).to_be_hidden() expect(page.locator("#active-devices")).to_be_focused() expect(page.locator("body > header")).not_to_have_attribute("inert", "") expect(page.locator("main")).not_to_have_attribute("inert", "") expect(dock).not_to_have_attribute("inert", "") 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_recovers_a_transient_workspace_request_in_place(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}" attempts: 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.goto(origin + "/", wait_until="networkidle") def interrupt_once(route): attempts.append(route.request.url) if len(attempts) == 1: route.abort("failed") else: route.continue_() page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once) page.locator('input[name="device_label"]').fill("Workspace 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.locator('[data-mobile-task="find"]').click() page.wait_for_timeout(1500) resources = page.evaluate( "performance.getEntriesByType('resource').map(entry => entry.name)" ) state = { "attempts": attempts, "resources": resources, "action": page.locator("#my-work-action-status").text_content(), "retry_hidden": page.locator("#retry-workspace").is_hidden(), "work": page.locator("#my-work-status").text_content(), } assert len(attempts) == 2, state assert "2" in (state["work"] or ""), state expect(page.locator("#retry-workspace")).to_be_hidden() expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable") assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") 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( "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" ) 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.evaluate( "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" ) 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)