stackchain-dashboard/tests/e2e/test_mobile_week_ahead_release.py
timmy 666671ee59
All checks were successful
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped
feat: confirm capacity before Week-to-Today pulls (Closes #1244)
2026-08-22 01:33:04 +00:00

491 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:0010: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)