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 Plan Today handoff journey 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 FakeGiteaServer from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server def open_today_action(page, selector: str): page.locator("[data-mobile-today-more]").click() expect(page.locator("#mobile-today-actions")).to_be_visible() page.locator(selector).click() expect(page.locator("#mobile-today-actions")).to_be_hidden() def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(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}, 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") page.locator('input[name="device_label"]').fill("Plan Today 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") try: expect(page.locator("#my-work-status")).to_contain_text("2") except AssertionError as error: raise AssertionError({"browser_errors": browser_errors, "failed_responses": failed_responses}) from error page.locator('[data-mobile-task="work"]').click() expect(page.locator("#plan-today-sheet")).to_be_visible() expect(page.locator("#plan-today-candidates .plan-today-item")).to_have_count(2) navigation = page.locator(".mobile-plan-today-nav") expect(navigation).to_be_visible() for control in navigation.locator("button").all(): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 page.locator("#plan-today-available").fill("90") page.locator("#plan-today-available").press("Tab") page.locator("#build-today-plan").click() missing_estimates = page.locator("[data-plan-missing-estimate]") expect(missing_estimates).to_have_count(2) for _ in range(2): missing_estimates.nth(0).fill("30") missing_estimates.nth(0).press("Tab") expect(page.locator("#plan-today-list .plan-today-item")).to_have_count(2) estimates = page.locator("[data-plan-estimate]") expect(estimates).to_have_count(2) page.locator("#save-and-start-today").click() try: expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=5_000) except AssertionError as error: raise AssertionError(page.evaluate("({hidden:document.querySelector('#plan-today-sheet').hidden, state:history.state, bodyClass:document.body.className})")) from error expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open") expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture") expect(page.locator("#send-issue-comment-next")).to_be_visible() assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") expect(page.locator("#plan-today-sheet")).to_be_hidden() page.locator("#close-issue-sheet").click() if page.locator("#plan-today-sheet").is_visible(): page.locator("#cancel-plan-today").click() expect(page.locator("[data-mobile-today-hud]")).to_be_visible() page.locator("[data-mobile-today-more]").click() expect(page.locator("#mobile-today-actions")).to_be_visible() blocked = page.locator("[data-mobile-today-blocked]") expect(blocked).to_be_visible() blocked_bounds = blocked.bounding_box() assert blocked_bounds and blocked_bounds["height"] >= 44 blocked.click() expect(page.locator("#mobile-today-actions")).to_be_hidden() expect(page.locator("#today-progress-sheet")).to_be_visible() expect(page.locator("#today-progress-title")).to_have_text("Report blocker") expect(page.locator("#today-progress-target")).to_contain_text("Ship mobile capture") expect(page.locator("#today-progress-body")).to_be_focused() expect(page.locator("#today-progress-activity")).to_be_hidden() expect(page.locator(".today-progress-evidence")).to_be_hidden() for control in page.locator(".today-blocker-presets button, #post-today-blocker").all(): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 page.locator('[data-today-blocker-preset="later-today"]').click() assert page.locator("#today-blocker-return-at").input_value() assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#cancel-today-progress").click() expect(page.locator("#today-progress-sheet")).to_be_hidden() expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Ship mobile capture") fake.activity_comments = { 41: [{ "id": 701, "user": {"login": "timmy"}, "body": "Wrong voice transcript", "issue_url": f"{fake_url}/api/v1/repos/acme/mobile/issues/41", "created_at": "2026-08-18T00:00:00Z", }] } open_today_action(page, "[data-mobile-today-update]") expect(page.locator("#today-progress-sheet")).to_be_visible() progress = page.locator("#today-progress-body") progress.fill("Pairing with @al") mention = page.locator('#today-progress-body-mentions [data-login="alex"]') try: expect(mention).to_be_visible() except AssertionError as error: raise AssertionError({ "status": page.locator("#today-progress-body-mention-status").text_content(), "requests": fake.requests[-20:], "browser_errors": browser_errors, "failed_responses": failed_responses, }) from error mention_bounds = mention.bounding_box() assert mention_bounds and mention_bounds["height"] >= 44 mention.click() expect(progress).to_have_value("Pairing with @alex ") page.locator("#post-today-progress").click() expect(page.locator("#today-progress-sheet")).to_be_hidden() admitted = page.evaluate("""() => { const record = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null'); return record?.items?.find(item => item.kind === 'issue-comment' && item.number === 41); }""") assert admitted and admitted["repository"] == "acme/mobile" assert admitted["body"] == "Pairing with @alex" open_today_action(page, "[data-mobile-today-update]") expect(page.locator("#today-progress-sheet")).to_be_visible() owned = page.locator('[data-comment-id="701"]') try: expect(owned.locator('[data-comment-action="edit"]')).to_be_visible() except AssertionError as error: direct = page.evaluate("""async () => { const response = await fetch('api/v1/repos/acme/mobile/issues/41/comments?limit=20'); return [response.status, await response.text()]; }""") raise AssertionError({ "activity": page.locator("#today-progress-activity").inner_html(), "direct": direct, "requests": fake.requests[-20:], "browser_errors": browser_errors, }) from error owned.locator('[data-comment-action="edit"]').click() correction = owned.locator(".comment-edit-textarea") correction.fill("Corrected voice transcript") owned.locator("[data-comment-edit-save]").click() try: expect(owned.locator(".markdown-content")).to_have_text("Corrected voice transcript") except AssertionError as error: raise AssertionError({ "activity": page.locator("#today-progress-activity").inner_html(), "edited": fake.edited_comments, "requests": fake.requests[-10:], "failed_responses": failed_responses, }) from error expect(page.locator("#today-progress-sheet")).to_be_visible() expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Ship mobile capture") assert fake.edited_comments == [(41, 701, "Corrected voice transcript")] assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#cancel-today-progress").click() expect(page.locator("#today-progress-sheet")).to_be_hidden() page.locator("[data-mobile-today-more]").click() expect(page.locator("#mobile-today-actions")).to_be_visible() take_break = page.locator("[data-today-break-open]") expect(take_break).to_be_visible() take_break.click() expect(page.locator("#mobile-today-actions")).to_be_hidden() expect(page.locator("#today-break-sheet")).to_be_visible() for control in page.locator(".today-break-actions button").all(): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 page.go_back() expect(page.locator("#today-break-sheet")).to_be_hidden() open_today_action(page, "[data-today-break-open]") page.locator('[data-today-break-minutes="5"]').click() expect(page.locator("#today-break-status")).to_have_text("On break · resume in 5:00") expect(page.locator("[data-mobile-today-toggle]")).to_have_text("Resume timer") page.evaluate("""() => { const prefix = 'stackchain.today-sync.v1.timmy.operation.'; localStorage.setItem(prefix + 'broken', '{not-json'); }""") page.reload(wait_until="networkidle") expect(page.locator("#my-work-status")).to_contain_text("2") expect(page.locator("#today-sync-status")).to_have_text( "Today queue recovered · discarded 1 unreadable device record." ) assert page.evaluate("""() => !Object.keys(localStorage).some( key => key.startsWith('stackchain.today-sync.v1.timmy.operation.') )""") expect(page.locator("#today-break-status")).to_contain_text("On break · resume in") if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open": page.locator("#close-issue-sheet").click() if page.locator("#plan-today-sheet").is_visible(): page.locator("#cancel-plan-today").click() expect(page.locator("#resume-today-break")).to_be_visible() page.locator("#resume-today-break").click() expect(page.locator("#today-break-status")).to_be_hidden() expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open") expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture") page.locator("#close-issue-sheet").click() if page.locator("#plan-today-sheet").is_visible(): page.locator("#cancel-plan-today").click() page.set_viewport_size({"width": 320, "height": 568}) layout = page.evaluate("""() => { const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect(); const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect(); const padding = parseFloat(getComputedStyle(document.body).paddingBottom); return {hudHeight:hud.height, hudTop:hud.top, dockTop:dock.top, padding, viewport:window.innerHeight}; }""") assert layout["hudHeight"] <= 116 assert layout["padding"] >= layout["viewport"] - min(layout["hudTop"], layout["dockTop"]) for control in page.locator("[data-mobile-today-hud] button").all(): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 open_today_action(page, "[data-today-break-open]") expect(page.locator("#today-break-sheet")).to_be_visible() assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") custom = page.locator("#today-break-custom-minutes") bounds = custom.bounding_box() assert bounds and bounds["height"] >= 44 page.locator("#cancel-today-break").click() expect(page.locator("#today-break-sheet")).to_be_hidden() page.set_viewport_size({"width": 390, "height": 844}) page.locator("[data-mobile-today-open]").click() expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open") page.locator('[data-issue-section="reply"]').click() page.locator("#issue-comment").fill("Handoff complete; continuing with the next Today item.") page.locator("#send-issue-comment-next").click() expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters") expect(page.locator("#issue-comment")).to_have_value("") expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true") expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters") assert fake.comments == [ (41, "Pairing with @alex"), (41, "Handoff complete; continuing with the next Today item."), ] receipt = page.locator("#today-completion-undo") expect(receipt).to_be_visible() expect(receipt).to_contain_text("Removed from Today. Undo?") undo = page.locator("#undo-today-completion") bounds = undo.bounding_box() assert bounds and bounds["height"] >= 44 page.locator("#close-issue-sheet").click() expect(page.locator("[data-mobile-today-hud]")).to_be_visible() expect(page.locator(".mobile-task-dock")).to_be_visible() layout = page.evaluate("""() => { const receipt = document.querySelector('#today-completion-undo').getBoundingClientRect(); const hud = document.querySelector('[data-mobile-today-hud]').getBoundingClientRect(); const dock = document.querySelector('.mobile-task-dock').getBoundingClientRect(); return {receiptBottom:receipt.bottom, hudTop:hud.top, dockTop:dock.top}; }""") assert layout["receiptBottom"] <= min(layout["hudTop"], layout["dockTop"]) undo.click() expect(receipt).to_be_hidden() expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters") today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')") assert today == ["issue:acme/mobile:41:", "issue:acme/mobile:42:"] 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_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path): archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) assert len(archives) == 1 fake = FakeGiteaServer(("127.0.0.1", 0)) thread = threading.Thread(target=fake.serve_forever, daemon=True) 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}, ignore_https_errors=True) 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("Blocker flow 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") page.locator('[data-mobile-task="work"]').click() page.locator("#plan-today-available").fill("90") page.locator("#plan-today-available").press("Tab") page.locator("#build-today-plan").click() missing = page.locator("[data-plan-missing-estimate]") expect(missing).to_have_count(2) for _ in range(2): missing.nth(0).fill("30") missing.nth(0).press("Tab") page.locator("#save-and-start-today").click() expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture") page.locator("#close-issue-sheet").click() if page.locator("#plan-today-sheet").is_visible(): page.locator("#cancel-plan-today").click() expect(page.locator("[data-mobile-today-hud]")).to_be_visible() open_today_action(page, "[data-mobile-today-update]") expect(page.locator("#today-progress-sheet")).to_be_visible() page.locator("#today-progress-body").fill("Blocked waiting for the design owner") page.locator("#post-today-progress").click() expect(page.locator("#today-progress-sheet")).to_be_hidden() open_today_action(page, "[data-today-break-open]") page.locator('[data-today-break-minutes="5"]').click() expect(page.locator("#resume-today-break")).to_be_visible() page.evaluate("""() => { localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({ version: 1, drafts: { 'issue:acme/mobile:41:': { body: 'Blocked waiting for the design owner', operation_id: 'already-admitted-blocker', blocker_pending: true, blocker_until: '2000-01-02T03:04:00.000Z' } } })); }""") page.reload(wait_until="networkidle") expect(page.locator("#my-work-status")).to_contain_text("2") if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open": page.locator("#close-issue-sheet").click() if page.locator("#plan-today-sheet").is_visible(): page.locator("#cancel-plan-today").click() expect(page.locator("#resume-today-break")).to_be_visible() page.locator("#resume-today-break").click() expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open") page.locator("#close-issue-sheet").click() open_today_action(page, "[data-mobile-today-update]") expect(page.locator("#today-progress-sheet")).to_be_visible() expect(page.locator("#today-progress-blocker-recovery")).to_be_visible() expect(page.locator("#today-progress-blocker-recovery")).to_contain_text("Blocker queued") expect(page.locator("#today-progress-body")).to_be_disabled() expect(page.locator("#today-blocker-return-at")).to_have_value("2000-01-02T03:04") page.locator("#today-blocker-return-at").fill("2099-08-19T09:00") blocker = page.locator("#post-today-blocker") expect(blocker).to_have_text("Finish moving on") bounds = blocker.bounding_box() assert bounds and bounds["height"] >= 44 blocker.click() expect(page.locator("#today-progress-sheet")).to_be_hidden() expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters") state = page.evaluate("""() => ({ later: JSON.parse(localStorage.getItem('stackchain.later-work.v1.timmy') || '{}'), today: JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]'), outbox: JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null'), })""") assert state["today"] == ["issue:acme/mobile:42:"] assert state["later"]["issue:acme/mobile:41:"].startswith("2099-08-19T09:00") comments = [item for item in state["outbox"]["items"] if item["kind"] == "issue-comment"] assert len(comments) == 1 assert comments[0]["number"] == 41 assert comments[0]["body"] == "Blocked waiting for the design owner" assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert browser_errors == [] browser.close() finally: fake.shutdown() fake.server_close() thread.join(timeout=5)