diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cb42df1..4ce905a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -57,7 +57,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 tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_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_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q release-candidate: runs-on: ubuntu-latest diff --git a/frontend/dashboard.js b/frontend/dashboard.js index e8070cb..25e67e2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,4 +1,5 @@ (async function(){ + await loadWorkspace({ document }); const qs = (s, el=document) => el.querySelector(s); const fmt = (d) => new Date(d).toLocaleString(); const cardPlanning = createCardPlanning(document); diff --git a/frontend/index.html b/frontend/index.html index 193e952..64d77c1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1650,6 +1650,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index fa98124..69bdb74 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -16,6 +16,7 @@ const SHELL = [ BASE + 'static/icons/stackchain-512.png', BASE + 'static/session.js', BASE + 'static/feature-loader.js', + BASE + 'static/workspace-bootstrap.js', BASE + 'static/conversation-action-hydrator.js', BASE + 'static/security-center.js', BASE + 'static/markdown.js', diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js new file mode 100644 index 0000000..0d07105 --- /dev/null +++ b/frontend/workspace-bootstrap.js @@ -0,0 +1,23 @@ +async function loadWorkspace({ document, createLoader = createFeatureLoader }) { + const status = document.querySelector('#my-work-action-status'); + const url = document.querySelector( + 'meta[name="stackchain-feature-today-timer"]' + )?.content || ''; + const loader = createLoader({ + document, + urls: { 'today-timer': url }, + }); + if (status) status.textContent = 'Starting workspace…'; + try { + await loader.load('today-timer'); + if (status) status.textContent = ''; + } catch (error) { + if (status) { + status.textContent = + 'Workspace could not load. Check your connection, then reload to retry.'; + } + throw error; + } +} + +if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace; \ No newline at end of file diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 71c2db6..38f0858 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -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/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.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-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/today-completion.js", "static/card-planning.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-wrap-up.js", "static/today-handoff.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", @@ -114,7 +114,6 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: dashboard_html = dashboard_html.replace("", feature_metadata + "\n") dashboard_html = dashboard_html.replace( "", - f'\n' f'\n', ) @@ -122,15 +121,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: for source in sources: if source != WORKER_RUNTIME_SOURCE: worker = worker.replace(f" BASE + '{source}',\n", "") - eager_feature = feature_bundles["today-timer"] - optional_features = { - name: bundle for name, bundle in feature_bundles.items() if name != "today-timer" - } + optional_features = feature_bundles worker = worker.replace( " BASE + 'static/dashboard.css',\n", " BASE + 'static/dashboard.css',\n" - + f" BASE + '{core.runtime_name}',\n" - + f" BASE + '{eager_feature.runtime_name}',\n", + + f" BASE + '{core.runtime_name}',\n", ) worker = worker.replace( "const OPTIONAL_FEATURES = [\n", diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py new file mode 100644 index 0000000..413e2b0 --- /dev/null +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -0,0 +1,91 @@ +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 mobile Home bootstrap 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_bootstraps_mobile_home_and_returns_from_insights( + 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)) + 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] = [] + workspace_requests: 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": width, "height": height}, 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.on( + "request", + lambda request: workspace_requests.append(request.url) + if "feature-today-timer-" in request.url + else None, + ) + + page.goto(origin + "/", wait_until="networkidle") + page.locator('input[name="device_label"]').fill("Home bootstrap 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") + dock = page.locator("#mobile-task-dock") + expect(dock).to_be_visible() + expect(dock.locator("button")).to_have_count(5) + for control in dock.locator("button").all(): + bounds = control.bounding_box() + assert bounds and bounds["height"] >= 44 + + page.locator("#app-menu-toggle").click() + page.locator("#open-insights").click() + expect(page.locator("#insights-sheet")).to_be_visible() + expect(page.locator("#insights-heading")).to_have_text("Insights") + page.locator("#close-insights").click() + expect(page.locator("#insights-sheet")).to_be_hidden() + expect(page.locator("#my-work")).to_be_visible() + expect(dock).to_be_visible() + + assert len(workspace_requests) == 1, workspace_requests + 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 cf30512..78f040d 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -59,6 +59,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey(): "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_home_bootstrap_release.py " "tests/e2e/test_mobile_today_handoff_release.py " "tests/e2e/test_mobile_today_wrap_up_release.py " "tests/e2e/test_mobile_wrap_up_handoff_release.py -q" diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index e7260d5..089a862 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -26,8 +26,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): assert first.runtime_gzip_bytes == second.runtime_gzip_bytes assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes assert PAGE_SCRIPT.findall(first.dashboard_html) == [] - assert first.dashboard_html.count("' in first.dashboard_html + assert first.dashboard_html.count("' not in first.dashboard_html assert f'' in first.dashboard_html assert first.runtime_name.startswith("runtime-") assert first.runtime_name.endswith(".js") @@ -51,10 +51,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): "comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup", "today-timer", "security-center", } - assert ( - f'\n' - f'' - ) in first.dashboard_html + assert first.dashboard_html.count("' in first.dashboard_html capture = first.feature_bundles["issue-capture"] pull_workflow = first.feature_bundles["pull-workflow"] assert b"function createIssueCapture" not in first.runtime_bytes @@ -89,13 +87,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): "const OPTIONAL_FEATURES = [", 1 ) optional_block = optional_block.split("];", 1)[0] - assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block + assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block for name, bundle in first.feature_bundles.items(): - if name == "today-timer": - assert f"BASE + '{bundle.runtime_name}'" not in optional_block - else: - assert f"BASE + '{bundle.runtime_name}'" not in shell_block - assert f"BASE + '{bundle.runtime_name}'" in optional_block + assert f"BASE + '{bundle.runtime_name}'" not in shell_block + assert f"BASE + '{bundle.runtime_name}'" in optional_block changed_frontend = tmp_path / "frontend" shutil.copytree(FRONTEND, changed_frontend) diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index ec11016..628e45d 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -953,6 +953,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/icons/stackchain-512.png", "/dashboard/static/session.js", "/dashboard/static/feature-loader.js", + "/dashboard/static/workspace-bootstrap.js", "/dashboard/static/conversation-action-hydrator.js", "/dashboard/static/security-center.js", "/dashboard/static/markdown.js", diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py new file mode 100644 index 0000000..50640cd --- /dev/null +++ b/tests/test_workspace_bootstrap.py @@ -0,0 +1,56 @@ +import json +import subprocess +from pathlib import Path + + +BOOTSTRAP = Path(__file__).parents[1] / "frontend" / "workspace-bootstrap.js" + + +def run_bootstrap(scenario: str) -> dict: + harness = f""" +const loadWorkspace = require({json.dumps(str(BOOTSTRAP))}); +(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], check=True, capture_output=True, text=True + ) + return json.loads(completed.stdout) + + +def test_workspace_bootstrap_loads_content_addressed_feature_before_startup(): + result = run_bootstrap(""" +const status={textContent:''}; +const document={ + querySelector(selector) { + if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + return null; + } +}; +let requested=''; +const createLoader=options=>({load:async name=>{requested=name + ':' + options.urls[name];}}); +await loadWorkspace({document,createLoader}); +console.log(JSON.stringify({requested,status:status.textContent})); +""") + assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""} + + +def test_workspace_bootstrap_keeps_shell_and_announces_retry_when_feature_fails(): + result = run_bootstrap(""" +const status={textContent:''}; +const document={ + querySelector(selector) { + if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + return null; + } +}; +const createLoader=()=>({load:async()=>{throw new Error('network failed');}}); +let message=''; +try { await loadWorkspace({document,createLoader}); } catch (error) { message=error.message; } +console.log(JSON.stringify({message,status:status.textContent})); +""") + assert result == { + "message": "network failed", + "status": "Workspace could not load. Check your connection, then reload to retry.", + } \ No newline at end of file