diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 768f49c..d8103a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: pip install -r requirements-e2e.txt python3 -m playwright install --with-deps chromium - name: Exercise packaged mobile work journeys - run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py -q + run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_today_handoff_release.py -q release-candidate: runs-on: ubuntu-latest diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 61b884d..28b957b 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -323,7 +323,9 @@ const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`; if (!localStorage.getItem(marker)) { localStorage.setItem(marker, '1'); - setTimeout(() => openPlanToday(qs('#plan-today')), 0); + setTimeout(() => { + if (!workSession.checkpointed()) openPlanToday(qs('#plan-today')); + }, 0); } }, onStatus: (state, detail = {}) => { @@ -2477,7 +2479,7 @@ function renderPlanIssueDependencies(detail, loading = false) { const preview = planTodayPreview.snapshot(); - const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail); + const assignedIssue = Boolean(!preview.open && selectedIssue && selectedIssueDetail === detail); if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return; const panel = qs('#issue-blockers'); const list = qs('#issue-blocker-list'); diff --git a/frontend/mobile-work-entry.js b/frontend/mobile-work-entry.js index 622711f..a8952b3 100644 --- a/frontend/mobile-work-entry.js +++ b/frontend/mobile-work-entry.js @@ -5,7 +5,7 @@ function mode() { if (options.isTodayActive()) return 'continue'; const recommended = options.queueLauncher && options.queueLauncher.recommend(); - if (recommended && recommended.name !== 'today') return recommended.name; + if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name; if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume'; if (options.getTodayCount() > 0) return 'start'; if (options.getEligibleCount() > 0) return 'plan'; diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 864125b..29e716e 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -21,7 +21,7 @@ COMMONJS_BROWSER_BRANCH = re.compile( ) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" FEATURE_SOURCES = { - "comment-actions": ("static/conversation.js", "static/comment-actions.js"), + "comment-actions": ("static/comment-actions.js",), "issue-capture": ( "static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js", ), @@ -30,7 +30,7 @@ FEATURE_SOURCES = { "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "security-center": ("static/security-center.js",), "today-timer": ( - "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", + "static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", diff --git a/tests/e2e/fake_gitea.py b/tests/e2e/fake_gitea.py index 90bf2bf..1efd791 100644 --- a/tests/e2e/fake_gitea.py +++ b/tests/e2e/fake_gitea.py @@ -51,6 +51,9 @@ class FakeGiteaServer(ThreadingHTTPServer): super().__init__(address, FakeGiteaHandler) self.created_issues: list[dict] = [] self.issue_creation_enabled = False + self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES] + self.comments: list[tuple[int, str]] = [] + self.requests: list[tuple[str, str]] = [] class FakeGiteaHandler(BaseHTTPRequestHandler): @@ -73,6 +76,7 @@ class FakeGiteaHandler(BaseHTTPRequestHandler): parsed = urlsplit(self.path) path = parsed.path query = parse_qs(parsed.query) + self.server.requests.append(("GET", self.path)) if path == "/api/v1/user": self._json(200, USER) elif path == "/api/v1/user/repos": @@ -82,17 +86,33 @@ class FakeGiteaHandler(BaseHTTPRequestHandler): elif path == "/api/v1/repos/search": self._json(200, {"data": [REPOSITORY], "ok": True}) elif path == "/api/v1/repos/issues/search": + is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"] is_available_scan = not any( name in query for name in ("assigned", "created", "review_requested", "q") ) and query.get("type") == ["issues"] - issues = AVAILABLE_ISSUES if is_available_scan else [] + assigned = [ + {**issue, "assignees": [USER]} + for issue in AVAILABLE_ISSUES + if issue["number"] in self.server.assigned_issue_numbers + ] + available = [ + issue for issue in AVAILABLE_ISSUES + if issue["number"] not in self.server.assigned_issue_numbers + ] + issues = assigned if is_assigned_scan else available if is_available_scan else [] self._json(200, issues, **{"X-Total-Count": str(len(issues))}) + elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"): + self._json(200, [], **{"X-Total-Count": "0"}) + elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"): + self._json(200, []) elif path.startswith("/api/v1/repos/acme/mobile/issues/"): try: number = int(path.rsplit("/", 1)[-1]) except ValueError: number = 0 issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None) + if issue and number in self.server.assigned_issue_numbers: + issue = {**issue, "assignees": [USER]} self._json(200, issue) if issue else self._json(404, {"message": "not found"}) elif path.startswith("/api/v1/"): self._json(200, [], **{"X-Total-Count": "0"}) @@ -101,8 +121,29 @@ class FakeGiteaHandler(BaseHTTPRequestHandler): def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API path = urlsplit(self.path).path + self.server.requests.append(("POST", self.path)) length = int(self.headers.get("Content-Length", "0")) payload = json.loads(self.rfile.read(length) or b"{}") + if path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"): + try: + number = int(path.split("/")[-2]) + except ValueError: + self._json(404, {"message": "not found"}) + return + body = str(payload.get("body", "")) + self.server.comments.append((number, body)) + self._json( + 201, + { + "id": len(self.server.comments), + "body": body, + "user": USER, + "created_at": "2026-08-16T20:00:00Z", + "updated_at": "2026-08-16T20:00:00Z", + "html_url": f"http://127.0.0.1/acme/mobile/issues/{number}#issuecomment-1", + }, + ) + return if path != "/api/v1/repos/acme/mobile/issues": self._json(404, {"message": "not found"}) return diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py new file mode 100644 index 0000000..d0f1890 --- /dev/null +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -0,0 +1,101 @@ +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 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") + + expect(page.locator("#my-work-status")).to_contain_text("2") + 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('[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, "Handoff complete; continuing with the next Today item.")] + today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')") + assert today == ["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) diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index e755c2b..b7540ff 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -41,6 +41,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey(): assert ( "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py " "tests/e2e/test_mobile_search_preview_navigation.py " - "tests/e2e/test_mobile_find_work_release.py -q" + "tests/e2e/test_mobile_find_work_release.py " + "tests/e2e/test_mobile_today_handoff_release.py -q" ) in browser assert "needs: [lint, build-release, browser-journey]" in release diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index f5021a5..e7260d5 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -72,6 +72,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes assert b"function createQueueToday" not in first.runtime_bytes assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes + assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes + assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes # Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions. diff --git a/tests/test_mobile_task_dock.py b/tests/test_mobile_task_dock.py index f96379a..ed29bc7 100644 --- a/tests/test_mobile_task_dock.py +++ b/tests/test_mobile_task_dock.py @@ -85,6 +85,30 @@ process.stdout.write(JSON.stringify({{modes, calls}})); } +def test_mobile_work_entry_plans_eligible_work_before_find_fallback(): + script = f""" +const createEntry = require({json.dumps(str(ENTRY))}); +const calls = []; +const entry = createEntry({{ + isTodayActive: () => false, + isTodayResumable: () => false, + getTodayCount: () => 0, + getEligibleCount: () => 2, + queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}}, + continueToday: () => {{}}, + resumeToday: () => {{}}, + startToday: () => {{}}, + planToday: () => calls.push('plan'), + findWork: () => calls.push('find'), +}}); +process.stdout.write(JSON.stringify({{mode:entry.open(), calls}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]} + + def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus(): script = f""" const createDock = require({json.dumps(str(DOCK))});