stackchain-dashboard/tests/e2e/test_mobile_week_ahead_release.py
timmy 42e97b4a79
All checks were successful
CI / lint (pull_request) Successful in 3m37s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m19s
CI / release-candidate (pull_request) Has been skipped
feat: add read-first Week Ahead overview (Closes #1216)
2026-08-21 09:07:27 +00:00

290 lines
16 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 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)))
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:"], "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("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")
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()
assert saved == [], "opening and inspecting Week Ahead must not 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 · 30 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("30 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:30 · 30 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"]
)
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)