from __future__ import annotations import json import os import re import threading from datetime import date, timedelta from pathlib import Path import pytest if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": pytest.skip("packaged Week Ahead 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 @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) def test_release_artifact_plans_seven_touch_safe_mobile_dates( 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)) thread = threading.Thread(target=fake.serve_forever, daemon=True) thread.start() saved: list[dict] = [] try: with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright: browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC") page_errors: list[str] = [] page.on("pageerror", lambda error: page_errors.append(str(error))) pulled: list[dict] = [] page.route("**/api/v1/today", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps({ "revision": 3, "ids": ["issue:acme/mobile:99:"], "capacity_minutes": 60, "estimates": {"issue:acme/mobile:99:": 30}, }), )) def pull_item_route(route): body = json.loads(route.request.post_data or "{}") pulled.append(body) route.fulfill(status=200, content_type="application/json", body=json.dumps({ "today": {"revision": 4, "ids": ["issue:acme/mobile:99:", body["identity"]], "capacity_minutes": 60, "estimates": {"issue:acme/mobile:99:": 30, body["identity"]: 45}}, "week": {"revision": 99, "timezone": "UTC", "days": [{ "plan_date": (date.today() + timedelta(days=1)).isoformat(), "ids": [], "capacity_minutes": 60, "estimates": {}, }]}, })) page.route("**/api/v1/week/pull-item", pull_item_route) def week_route(route): if route.request.method == "PUT": body = json.loads(route.request.post_data or "{}") saved.append(body) route.fulfill(status=200, content_type="application/json", body=json.dumps({ "revision": body["base_revision"] + 1, "timezone": body["timezone"], "days": body["days"], })) return route.fulfill(status=200, content_type="application/json", body=json.dumps({ "revision": 0, "timezone": None, "days": [{ "plan_date": (date.today() + timedelta(days=1)).isoformat(), "ids": ["issue:acme/mobile:41:", "issue:acme/mobile:42:"], "capacity_minutes": 60, "estimates": {"issue:acme/mobile:41:": 45, "issue:acme/mobile:42:": 45}, }], })) page.route("**/api/v1/week", week_route) page.route( "**/api/v1/repos/acme/mobile/issues/41/close", lambda route: route.fulfill( status=200, content_type="application/json", body='{"number":41,"state":"closed","updated_at":"2026-08-21T12:00:00Z"}', ), ) page.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("Week Ahead 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") if page.locator("#plan-today-sheet").is_visible(): page.locator("#cancel-plan-today").click() page.locator('[data-mobile-task="queues"]').click() page.locator('[data-mobile-queue="week"]').click() page.wait_for_timeout(100) assert not page_errors, f"Week Ahead launch raised: {page_errors}" expect(page.locator("#plan-today-title")).to_have_text("Week Ahead") overview = page.locator("#week-review") expect(overview).to_be_visible() cards = page.locator("#week-review-days .week-review-day") expect(cards).to_have_count(7) expect(cards.first).to_contain_text("Next up") expect(cards.first).to_contain_text("Ship mobile capture") expect(page.locator("#week-review-status")).to_have_text( "Week Ahead overview · no changes made." ) expect(page.locator("#confirm-week-plan")).to_be_hidden() edit_week = page.locator("#edit-week-plan") expect(edit_week).to_be_visible() remove_actions = cards.first.locator("[data-week-unplan]") expect(remove_actions).to_have_count(2) remove_bounds = remove_actions.nth(1).bounding_box() assert remove_bounds and remove_bounds["height"] >= 44 remove_actions.nth(1).click() expect(cards.first).not_to_contain_text("Polish desktop filters") expect(page.locator("#week-unplan-receipt")).to_have_text( "Polish desktop filters removed from Week Ahead." ) undo = page.locator("#undo-week-unplan") expect(undo).to_be_visible() undo_bounds = undo.bounding_box() assert undo_bounds and undo_bounds["height"] >= 44 undo.click() expect(cards.first).to_contain_text("Polish desktop filters") expect(page.locator("#week-unplan-receipt")).to_be_hidden() assert saved[-2]["days"][0]["ids"] == ["issue:acme/mobile:41:"] assert saved[-1]["days"][0]["ids"] == [ "issue:acme/mobile:41:", "issue:acme/mobile:42:" ] writes_before_reflow = len(saved) reflow = page.locator("#open-week-reflow") expect(reflow).to_be_visible() bounds = reflow.bounding_box() assert bounds and bounds["height"] >= 44 reflow.click() preview = page.locator("#week-reflow-review") expect(preview).to_be_visible() expect(page.locator("#week-reflow-days")).to_contain_text("1 item · 45 of 60 min") expect(page.locator("#week-reflow-unscheduled")).to_have_text( "1 item cannot fit: Polish desktop filters. It will remain in My Work." ) for control in (page.locator("#cancel-week-reflow"), page.locator("#apply-week-reflow")): control_bounds = control.bounding_box() assert control_bounds and control_bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#cancel-week-reflow").click() expect(preview).to_be_hidden() assert len(saved) == writes_before_reflow, "cancelling reflow must not write" reflow.click() page.locator("#apply-week-reflow").click() expect(page.locator("#week-review-status")).to_have_text("Week Ahead reflowed and saved.") expect(preview).to_be_hidden() assert len(saved) == writes_before_reflow + 1 assert [day["ids"] for day in saved[-1]["days"]][:2] == [["issue:acme/mobile:41:"], []] planned_item = cards.first.locator("[data-week-open-item]") expect(planned_item).to_have_count(1) planned_bounds = planned_item.bounding_box() assert planned_bounds and planned_bounds["height"] >= 44 planned_item.click() expect(page.locator("#issue-sheet")).to_have_class(re.compile(r"\bopen\b")) expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture") expect(overview).to_be_visible() page.locator("#close-issue-sheet").click() expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b")) expect(overview).to_be_visible() expect(planned_item).to_be_focused() assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write" for control in (cards.first.locator("[data-week-edit-day]"), edit_week): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") edit_week.click() expect(overview).to_be_hidden() expect(page.locator("#plan-today-title")).to_have_text("Plan Week Ahead") dates = page.locator("#week-plan-dates button") expect(dates).to_have_count(7) expect(dates.first).to_have_attribute("aria-current", "date") for index in range(7): bounds = dates.nth(index).bounding_box() assert bounds and bounds["height"] >= 44 dates.nth(2).click() expect(dates.nth(2)).to_have_attribute("aria-current", "date") expect(page.locator("#week-plan-progress")).to_have_text("Day 3 of 7 · 1 planned") expect(page.locator("#save-today-plan")).to_have_text("Save & next") page.locator("#save-today-plan").click() expect(page.locator("#plan-today-sheet")).to_be_visible() expect(dates.nth(3)).to_have_attribute("aria-current", "date") expect(page.locator("#week-plan-progress")).to_have_text("Day 4 of 7 · 1 planned") dates.nth(6).click() expect(page.locator("#save-today-plan")).to_have_text("Save week") page.locator("#save-today-plan").click() expect(page.locator("#plan-today-sheet")).to_be_visible() expect(page.locator("#week-review")).to_be_visible() expect(page.locator("#week-review-days .week-review-day")).to_have_count(7) first_day = page.locator("#week-review-days .week-review-day").first expect(first_day).to_contain_text("Ship mobile capture") expect(first_day).to_contain_text("acme/mobile #41 · Issue · 45 min") expect(first_day.locator("code")).to_have_count(0) edit = first_day.locator("[data-week-edit-day]") bounds = edit.bounding_box() assert bounds and bounds["height"] >= 44 edit.click() expect(page.locator("#week-review")).to_be_hidden() expect(page.locator("#back-to-week-review")).to_be_visible() expect(page.locator("#save-today-plan")).to_have_text("Save & review") page.locator("#back-to-week-review").click() expect(page.locator("#week-review")).to_be_visible() expect(first_day).to_contain_text("Ship mobile capture") page.locator("#open-week-capacity-import").click() capacity_import = page.locator("#week-capacity-import") expect(capacity_import).to_be_visible() private_title = "Private customer planning" tomorrow = (date.today() + timedelta(days=1)).strftime("%Y%m%d") excluded = (date.today() + timedelta(days=2)).strftime("%Y%m%d") added = (date.today() + timedelta(days=4)).strftime("%Y%m%d") calendar = ( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" f"SUMMARY:{private_title}\r\nDTSTART;TZID=America/New_York:{tomorrow}T050000\r\n" f"DTEND;TZID=America/New_York:{tomorrow}T060000\r\nRRULE:FREQ=DAILY;COUNT=3\r\n" f"EXDATE;TZID=America/New_York:{excluded}T050000\r\n" f"RDATE;TZID=America/New_York:{added}T050000\r\nEND:VEVENT\r\nEND:VCALENDAR" ) unsupported_title = "Private monthly board review" unsupported_calendar = ( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" f"SUMMARY:{unsupported_title}\r\nDTSTART:{tomorrow}T100000\r\n" f"DTEND:{tomorrow}T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\n" "END:VEVENT\r\nEND:VCALENDAR" ) page.locator("#week-capacity-file").set_input_files({ "name": "unsupported.ics", "mimeType": "text/calendar", "buffer": unsupported_calendar.encode() }) expect(page.locator("#week-capacity-status")).to_contain_text( "1 recurring event could not be counted" ) expect(page.locator("#apply-week-capacities")).to_be_disabled() expect(capacity_import).not_to_contain_text(unsupported_title) page.locator("#week-capacity-file").set_input_files({ "name": "availability.ics", "mimeType": "text/calendar", "buffer": calendar.encode() }) expect(page.locator("#week-capacity-days .week-capacity-day")).to_have_count(7) expect(page.locator("#week-capacity-days .week-capacity-day").first).to_contain_text( "420 min available" ) expect(page.locator("#week-capacity-days .week-capacity-day").nth(1)).to_contain_text( "480 min available" ) expect(page.locator("#week-capacity-days .week-capacity-day").nth(2)).to_contain_text( "420 min available" ) expect(page.locator("#week-capacity-days .week-capacity-day").nth(3)).to_contain_text( "420 min available" ) expect(capacity_import).not_to_contain_text(private_title) for control in ( page.locator("#cancel-week-capacity-import"), page.locator("#week-capacity-file"), page.locator("#week-capacity-start"), page.locator("#week-capacity-end"), page.locator("#apply-week-capacities"), ): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") page.locator("#apply-week-capacities").click() expect(capacity_import).to_be_hidden() expect(page.locator("#week-review")).to_be_visible() expect(first_day).to_contain_text("45 of 420 min") page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled") confirm = page.locator("#confirm-week-plan") bounds = confirm.bounding_box() assert bounds and bounds["height"] >= 44 confirm.click() expect(page.locator("#week-calendar-handoff")).to_be_visible() expect(page.locator("#week-review")).to_be_hidden() start = page.locator("[data-week-calendar-start]").first expect(start).to_have_value("09:00") expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min") expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time") for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")): bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 page.locator("#back-to-week-review-from-calendar").click() expect(page.locator("#week-review")).to_be_visible() confirm.click() with page.expect_download() as download_info: page.locator("#share-week-calendar").click() assert download_info.value.suggested_filename.startswith("stackchain-week-ahead-") expect(page.locator("#plan-today-sheet")).to_be_hidden() page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'") assert saved and len(saved[-1]["days"]) == 7 assert [day["plan_date"] for day in saved[-1]["days"]] == sorted( day["plan_date"] for day in saved[-1]["days"] ) before_retirement = len(saved) page.locator('[data-mobile-task="queues"]').click() page.locator('[data-mobile-queue="week"]').click() expect(overview).to_be_visible() planned_item = cards.first.locator( '[data-week-open-item="issue:acme/mobile:41:"]' ) planned_item.click() expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture") page.once("dialog", lambda dialog: dialog.accept()) page.locator("#close-issue").click() expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b")) expect(cards.first).not_to_contain_text("Ship mobile capture") expect(cards.first).to_contain_text("Polish desktop filters") expect(cards.first).to_contain_text("45 of 60 min") expect(page.locator("#week-review-status")).to_have_text( "Ship mobile capture removed and Week Ahead saved." ) expect(edit_week).to_be_focused() assert len(saved) == before_retirement + 1 assert saved[-1]["days"][0]["ids"] == ["issue:acme/mobile:42:"] assert saved[-1]["days"][0]["capacity_minutes"] == 60 pull = cards.first.locator('[data-week-pull-item="issue:acme/mobile:42:"]') expect(pull).to_have_text("Review Today · 75 of 60 min") pull_bounds = pull.bounding_box() assert pull_bounds and pull_bounds["height"] >= 44 page.once("dialog", lambda dialog: dialog.dismiss()) pull.click() expect(cards.first).to_contain_text("Polish desktop filters") assert pulled == [], "cancelling the overload review must not submit" expect(pull).to_have_text("Review Today · 75 of 60 min") page.once("dialog", lambda dialog: dialog.accept()) pull.click() expect(cards.first).not_to_contain_text("Polish desktop filters") expect(page.locator("#week-review-status")).to_have_text( "Polish desktop filters added to Today." ) assert pulled and pulled[-1]["identity"] == "issue:acme/mobile:42:" assert pulled[-1]["today_revision"] == 3 assert pulled[-1]["allow_over_capacity"] is True assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") browser.close() finally: fake.shutdown() fake.server_close() thread.join(timeout=5) @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) def test_release_artifact_reconciles_only_the_week_day_changed_on_both_devices( 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)) thread = threading.Thread(target=fake.serve_forever, daemon=True) thread.start() puts: list[dict] = [] try: with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright: browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) page = browser.new_page(viewport={"width": width, "height": height}) page_errors: list[str] = [] page.on("pageerror", lambda error: page_errors.append(str(error))) def week_route(route): if route.request.method == "PUT": body = json.loads(route.request.post_data or "{}") puts.append(body) if len(puts) == 1: route.fulfill(status=409, content_type="application/json", body='{"message":"changed"}') return route.fulfill(status=200, content_type="application/json", body=json.dumps({ "revision": 6, "timezone": body["timezone"], "days": body["days"], })) return if puts: changed_date = puts[0]["days"][0]["plan_date"] following_date = (date.fromisoformat(changed_date) + timedelta(days=1)).isoformat() route.fulfill(status=200, content_type="application/json", body=json.dumps({ "revision": 5, "timezone": puts[0]["timezone"], "days": [ {"plan_date": changed_date, "ids": [], "capacity_minutes": 90, "estimates": {}}, {"plan_date": following_date, "ids": [], "capacity_minutes": 30, "estimates": {}}, ], })) return route.fulfill(status=200, content_type="application/json", body=json.dumps({ "revision": 4, "timezone": None, "days": [], })) page.route("**/api/v1/week", week_route) page.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("Week conflict 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="week"]').click() page.locator("#edit-week-plan").click() page.locator("#plan-today-available").fill("60") page.locator("#save-today-plan").click() page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent.includes('Conflict')") choices = page.locator("[data-week-conflict-choice]") expect(choices).to_have_count(2) for index in range(2): bounds = choices.nth(index).locator("xpath=..").bounding_box() assert bounds and bounds["height"] >= 44 page.locator('[data-week-conflict-choice="phone"]').check() expect(page.locator("#save-merged-week")).to_be_enabled() page.locator("#save-merged-week").click() expect(page.locator("#plan-today-sheet")).to_be_hidden() assert len(puts) == 2 assert [day["capacity_minutes"] for day in puts[-1]["days"]] == [60, 30] assert not page_errors, f"Week conflict recovery raised: {page_errors}" assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") browser.close() finally: fake.shutdown() fake.server_close() thread.join(timeout=5) @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) def test_release_artifact_reads_the_confirmed_week_offline_then_retries_live( 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)) thread = threading.Thread(target=fake.serve_forever, daemon=True) thread.start() online = True try: with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright: browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) page = browser.new_page(viewport={"width": width, "height": height}, timezone_id="UTC") page_errors: list[str] = [] page.on("pageerror", lambda error: page_errors.append(str(error))) def week_route(route): if not online: route.fulfill(status=503, content_type="application/json", body='{"message":"offline"}') return route.fulfill(status=200, content_type="application/json", body=json.dumps({ "revision": 7, "timezone": "UTC", "days": [{ "plan_date": (date.today() + timedelta(days=1)).isoformat(), "ids": ["issue:acme/mobile:41:"], "capacity_minutes": 60, "estimates": {"issue:acme/mobile:41:": 30}, }], })) page.route("**/api/v1/week", week_route) page.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("Offline Week Ahead 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="week"]').click() expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture") page.locator("#cancel-plan-today").click() online = False page.locator('[data-mobile-task="queues"]').click() page.locator('[data-mobile-queue="week"]').click() notice = page.locator("#week-offline-snapshot") expect(notice).to_be_visible() expect(notice).to_contain_text("Offline snapshot") expect(page.locator("#week-review-days")).to_contain_text("Ship mobile capture") expect(page.locator("[data-week-edit-day]")).to_have_count(0) expect(page.locator("#edit-week-plan")).to_be_hidden() expect(page.locator("#open-week-capacity-import")).to_be_hidden() retry = page.locator("#retry-week-live") expect(retry).to_be_visible() bounds = retry.bounding_box() assert bounds and bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") online = True retry.click() expect(notice).to_be_hidden() expect(page.locator("#edit-week-plan")).to_be_visible() expect(page.locator("[data-week-edit-day]")).to_have_count(7) assert not page_errors, f"offline Week Ahead recovery raised: {page_errors}" browser.close() finally: fake.shutdown() fake.server_close() thread.join(timeout=5)