From e10bd2d2616b26441b152e65461c00fde487192a Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 18:58:13 +0000 Subject: [PATCH] test: gate packaged mobile Find Work journey (Closes #981) --- .gitea/workflows/ci.yml | 4 +- frontend/dashboard.js | 5 +- tests/e2e/fake_gitea.py | 67 +++++++++++- tests/e2e/test_mobile_find_work_release.py | 114 +++++++++++++++++++++ tests/test_ci_workflow.py | 3 +- 5 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/test_mobile_find_work_release.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1538fa7..768f49c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -54,8 +54,8 @@ jobs: run: | pip install -r requirements-e2e.txt python3 -m playwright install --with-deps chromium - - name: Exercise mobile offline filing and Search Preview navigation - run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py -q + - 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 release-candidate: runs-on: ubuntu-latest diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 620c9cb..61b884d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -5678,9 +5678,10 @@ }); qs('#confirm-find-work-estimates').addEventListener('click', async event => { - event.currentTarget.disabled = true; + const button = event.currentTarget; + button.disabled = true; const outcome = await batchFindWork.run(findWorkController.selectedItems(), findWorkEstimateValues()); - event.currentTarget.disabled = false; + button.disabled = false; if (outcome.status === 'estimates-required') { const first = outcome.invalid[0]; document.querySelector('[data-find-work-estimate="' + CSS.escape(first) + '"]')?.focus(); diff --git a/tests/e2e/fake_gitea.py b/tests/e2e/fake_gitea.py index ac1ef64..90bf2bf 100644 --- a/tests/e2e/fake_gitea.py +++ b/tests/e2e/fake_gitea.py @@ -4,7 +4,7 @@ from __future__ import annotations import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlsplit +from urllib.parse import parse_qs, urlsplit REPOSITORY = { @@ -16,6 +16,34 @@ REPOSITORY = { "owner": {"login": "acme"}, } USER = {"id": 1, "login": "timmy", "full_name": "Timmy"} +AVAILABLE_ISSUES = [ + { + "id": 101, + "number": 41, + "title": "Ship mobile capture", + "body": "Complete the capture flow on a phone.", + "state": "open", + "html_url": "http://127.0.0.1/acme/mobile/issues/41", + "repository": REPOSITORY, + "user": USER, + "assignees": [], + "labels": [{"name": "P1"}], + "updated_at": "2026-08-15T12:00:00Z", + }, + { + "id": 102, + "number": 42, + "title": "Polish desktop filters", + "body": "Keep the desktop filter controls clear.", + "state": "open", + "html_url": "http://127.0.0.1/acme/mobile/issues/42", + "repository": REPOSITORY, + "user": USER, + "assignees": [], + "labels": [], + "updated_at": "2026-08-15T11:00:00Z", + }, +] class FakeGiteaServer(ThreadingHTTPServer): @@ -42,7 +70,9 @@ class FakeGiteaHandler(BaseHTTPRequestHandler): self.wfile.write(body) def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API - path = urlsplit(self.path).path + parsed = urlsplit(self.path) + path = parsed.path + query = parse_qs(parsed.query) if path == "/api/v1/user": self._json(200, USER) elif path == "/api/v1/user/repos": @@ -51,6 +81,19 @@ class FakeGiteaHandler(BaseHTTPRequestHandler): self._json(200, REPOSITORY) elif path == "/api/v1/repos/search": self._json(200, {"data": [REPOSITORY], "ok": True}) + elif path == "/api/v1/repos/issues/search": + 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 [] + self._json(200, issues, **{"X-Total-Count": str(len(issues))}) + 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) + 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"}) else: @@ -84,3 +127,23 @@ class FakeGiteaHandler(BaseHTTPRequestHandler): "updated_at": "2026-08-15T12:00:00Z", }, ) + + def do_PATCH(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + path = urlsplit(self.path).path + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length) or b"{}") + if not path.startswith("/api/v1/repos/acme/mobile/issues/"): + self._json(404, {"message": "not found"}) + return + try: + number = int(path.rsplit("/", 1)[-1]) + except ValueError: + self._json(404, {"message": "not found"}) + return + issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None) + if issue is None: + self._json(404, {"message": "not found"}) + return + assignee = payload.get("assignee") + self.server.assigned_issue_numbers.append(number) + self._json(200, {**issue, "assignees": [{"login": assignee}] if assignee else []}) diff --git a/tests/e2e/test_mobile_find_work_release.py b/tests/e2e/test_mobile_find_work_release.py new file mode 100644 index 0000000..7107160 --- /dev/null +++ b/tests/e2e/test_mobile_find_work_release.py @@ -0,0 +1,114 @@ +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 Find Work 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_guides_mobile_find_work_through_assignment(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.assigned_issue_numbers = [] + 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("Find Work 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="find"]').click() + expect(page.locator("#find-work-sheet")).to_have_class("find-work-sheet open") + expect(page.locator(".find-work-card")).to_have_count(2) + expect(page.locator("#find-work-status")).to_contain_text("2 of 2 available") + + navigation = page.locator(".mobile-find-work-nav") + expect(navigation).to_be_visible() + for control in navigation.locator("button").all(): + bounds = control.bounding_box() + assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + page.locator("#select-find-work").click() + choices = page.locator("[data-find-work-select]") + expect(choices).to_have_count(2) + choices.nth(0).check() + choices.nth(1).check() + page.locator("#claim-selected-work").click() + + expect(navigation.locator('[data-find-work-stage="review"]')).to_have_attribute( + "aria-current", "step" + ) + expect(page.locator("#find-work-review-list .find-work-review-item")).to_have_count(2) + expect(page.locator("#find-work-review-summary")).to_contain_text("2 selected") + page.locator("[data-find-work-review-remove]").nth(1).click() + expect(page.locator("#find-work-review-list .find-work-review-item")).to_have_count(1) + expect(page.locator("#find-work-review-summary")).to_contain_text("1 selected") + + page.locator("#continue-find-work-fit").click() + expect(navigation.locator('[data-find-work-stage="fit"]')).to_have_attribute( + "aria-current", "step" + ) + expect(page.locator("#find-work-estimate-list")).to_contain_text("Ship mobile capture") + for control in page.locator("#find-work-fit-stage button").all(): + bounds = control.bounding_box() + assert bounds and bounds["height"] >= 44 + + page.locator("#confirm-find-work-estimates").click() + expect(navigation.locator('[data-find-work-stage="discover"]')).to_have_attribute( + "aria-current", "step" + ) + page.wait_for_timeout(1_000) + status = page.locator("#find-work-status").inner_text() + assert "1 queued" in status, { + "status": status, + "browser_errors": browser_errors, + "failed_responses": failed_responses, + "assigned": fake.assigned_issue_numbers, + "today": page.evaluate("localStorage.getItem('stackchain.today-work.v1.timmy')"), + } + + assert fake.assigned_issue_numbers == [41] + today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')") + assert today == ["issue:acme/mobile:41:"] + 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 f304dad..e755c2b 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -40,6 +40,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey(): assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser assert ( "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py " - "tests/e2e/test_mobile_search_preview_navigation.py -q" + "tests/e2e/test_mobile_search_preview_navigation.py " + "tests/e2e/test_mobile_find_work_release.py -q" ) in browser assert "needs: [lint, build-release, browser-journey]" in release -- 2.43.0