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..9a13d51 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1,4 +1,5 @@ (async function(){ + const workspaceLifecycle = await loadWorkspace({ document, window }); const qs = (s, el=document) => el.querySelector(s); const fmt = (d) => new Date(d).toLocaleString(); const cardPlanning = createCardPlanning(document); @@ -7330,6 +7331,9 @@ flushAuthored: flushAuthoredOutbox, flushNotificationReads: flushNotificationReadOutbox, }); + const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500); + window.addEventListener('online', reconnectAfterOnline); + workspaceLifecycle.replayOnline(reconnectAfterOnline); async function setOfflineWorkEnabled(enabled) { keepWorkOffline.checked = enabled; offlineWorkStore.setEnabled(enabled); @@ -7387,7 +7391,6 @@ updateDeliveryReceiptControls(); if (!navigator.onLine) await showOfflineStatus(); window.addEventListener('offline', showOfflineStatus); - window.addEventListener('online', reconnectOutboxes); qs('#refresh').addEventListener('click', load); qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget)); 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/reconnect-outboxes.js b/frontend/reconnect-outboxes.js index 85f1fd2..e304299 100644 --- a/frontend/reconnect-outboxes.js +++ b/frontend/reconnect-outboxes.js @@ -5,7 +5,10 @@ function createReconnectOutboxes({ flushAuthored, flushNotificationReads, }) { - return async function reconnectOutboxes() { + let activeReconnect = null; + let followUpRequested = false; + + async function reconnectOnce() { const snapshot = await refresh(); const freshness = snapshot?.freshness?.sections?.context; const identityFresh = snapshot?.context && !snapshot.context.error && @@ -19,6 +22,22 @@ function createReconnectOutboxes({ flushNotificationReads(), ]); return true; + } + + return function reconnectOutboxes() { + if (activeReconnect) { + followUpRequested = true; + return activeReconnect; + } + activeReconnect = (async () => { + let result = false; + do { + followUpRequested = false; + result = await reconnectOnce(); + } while (followUpRequested); + return result; + })().finally(() => { activeReconnect = null; }); + return activeReconnect; }; } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index fa98124..bbdeb68 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', @@ -382,6 +383,17 @@ async function warmOptionalFeature(cache, staleCacheNames, asset) { await cache.add(asset); } +async function cachedOptionalFeature(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE); + await cache.put(request, response.clone()); + } + return response; +} + self.addEventListener('activate', event => { event.waitUntil((async () => { const keys = await caches.keys(); @@ -634,6 +646,6 @@ self.addEventListener('fetch', event => { return; } if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) { - event.respondWith(caches.match(request).then(cached => cached || fetch(request))); + event.respondWith(cachedOptionalFeature(request)); } }); diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js new file mode 100644 index 0000000..7a56902 --- /dev/null +++ b/frontend/workspace-bootstrap.js @@ -0,0 +1,41 @@ +async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) { + let cameOnline = false; + let replayed = false; + const captureOnline = () => { cameOnline = true; }; + window?.addEventListener('online', captureOnline); + 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 = ''; + return { + replayOnline(callback) { + if (replayed) return; + replayed = true; + window?.removeEventListener('online', captureOnline); + if (cameOnline) callback(); + }, + }; + } catch (error) { + window?.removeEventListener('online', captureOnline); + if (window?.location?.reload) { + if (cameOnline) window.location.reload(); + else window.addEventListener('online', () => window.location.reload(), { once: true }); + } + if (status) { + status.textContent = window + ? 'Workspace could not load. Reconnect to retry automatically, or reload now.' + : '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..d2c8ede 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -30,8 +30,8 @@ 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/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/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-work-entry.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", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.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/fake_gitea.py b/tests/e2e/fake_gitea.py index 1efd791..9b6e5f6 100644 --- a/tests/e2e/fake_gitea.py +++ b/tests/e2e/fake_gitea.py @@ -4,6 +4,7 @@ from __future__ import annotations import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Event from urllib.parse import parse_qs, urlsplit @@ -51,6 +52,7 @@ class FakeGiteaServer(ThreadingHTTPServer): super().__init__(address, FakeGiteaHandler) self.created_issues: list[dict] = [] self.issue_creation_enabled = False + self.issue_creation_ready = Event() self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES] self.comments: list[tuple[int, str]] = [] self.requests: list[tuple[str, str]] = [] @@ -147,6 +149,8 @@ class FakeGiteaHandler(BaseHTTPRequestHandler): if path != "/api/v1/repos/acme/mobile/issues": self._json(404, {"message": "not found"}) return + if not self.server.issue_creation_enabled: + self.server.issue_creation_ready.wait(timeout=45) if not self.server.issue_creation_enabled: self._json(503, {"message": "release journey is still offline"}) return 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/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index ae20ea2..5c18145 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -232,6 +232,10 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo page.locator("#create-issue-repository").select_option("acme/mobile") expect(page.locator("#submit-new-issue")).to_be_enabled() + # Page offline emulation can leave service-worker requests connected. + # Abort the mutation at the browser boundary too, matching a real + # transport outage without poisoning server-side idempotency state. + context.route("**/api/v1/repos/acme/mobile/issues", lambda route: route.abort()) context.set_offline(True) page.locator("#submit-new-issue").click() expect(page.locator("#issue-filing-review")).to_be_visible() @@ -267,6 +271,8 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo browser_errors.clear() # Chromium reports expected network errors while the context is offline. failed_responses.clear() fake.issue_creation_enabled = True + fake.issue_creation_ready.set() + context.unroute("**/api/v1/repos/acme/mobile/issues") context.set_offline(False) page.evaluate("window.dispatchEvent(new Event('online'))") # Containerized Actions runners can take longer than a local browser to wake the @@ -275,7 +281,22 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo if fake.created_issues: break page.wait_for_timeout(250) - assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}] + local_debug = page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')") + durable_debug = indexed_issue_records(page) + assert fake.created_issues == [ + {"title": TITLE, "body": BODY, "assignee": "timmy"} + ], ( + f"url={page.url} ready={page.evaluate('document.readyState')} " + f"status={page.locator('#my-work-action-status').inner_text()!r} " + f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} " + f"local={local_debug!r} durable={durable_debug!r} " + f"requests={fake.requests[-20:]!r}" + ) + # Deferred workspace startup can report its already-issued offline request + # after the pre-reconnect clear; retain every non-offline browser error. + browser_errors[:] = [ + error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error + ] for _ in range(40): durable_completion = indexed_issue_records(page) @@ -298,6 +319,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") browser.close() finally: + fake.issue_creation_ready.set() 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_reconnect_outboxes.py b/tests/test_reconnect_outboxes.py index 28b91ab..969da9e 100644 --- a/tests/test_reconnect_outboxes.py +++ b/tests/test_reconnect_outboxes.py @@ -41,6 +41,36 @@ reconnect().then(result => process.stdout.write(JSON.stringify({{result, calls}} } +def test_concurrent_reconnect_requests_queue_one_follow_up_flush(): + result = run_node( + f""" +const createReconnectOutboxes = require({json.dumps(str(RECONNECT))}); +(async () => {{ +let releaseFirst; let issueCalls=0; let active=0; let maxActive=0; +const reconnect = createReconnectOutboxes({{ + refresh: async () => ({{context:{{user:{{login:'timmy'}}}}}}), + restoreIdentity: () => {{}}, + flushIssue: async () => {{ + issueCalls++; active++; maxActive=Math.max(maxActive,active); + if (issueCalls === 1) await new Promise(resolve => {{ releaseFirst=resolve; }}); + active--; + }}, + flushAuthored: async () => {{}}, + flushNotificationReads: async () => {{}}, +}}); +const first=reconnect(); +while (!releaseFirst) await new Promise(resolve=>setTimeout(resolve,0)); +const second=reconnect(); +releaseFirst(); +await Promise.all([first,second]); +process.stdout.write(JSON.stringify({{issueCalls,maxActive}})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + ) + + assert result == {"issueCalls": 2, "maxActive": 1} + + def test_dashboard_uses_reconnect_flush_after_live_identity_refresh(): index = (ROOT / "frontend" / "index.html").read_text() dashboard = (ROOT / "frontend" / "dashboard.js").read_text() @@ -48,4 +78,14 @@ def test_dashboard_uses_reconnect_flush_after_live_identity_refresh(): assert '' in index assert "const reconnectOutboxes = createReconnectOutboxes({" in dashboard assert "restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }" in dashboard - assert "window.addEventListener('online', reconnectOutboxes);" in dashboard + assert "window.addEventListener('online', reconnectAfterOnline);" in dashboard + assert "setTimeout(reconnectOutboxes, 500)" in dashboard + + +def test_dashboard_registers_reconnect_before_offline_startup_can_wait(): + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + + listener = dashboard.index("window.addEventListener('online', reconnectAfterOnline);") + offline_startup = dashboard.index("await updateOfflineWorkControls();") + + assert listener < offline_startup diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index ec11016..0d22430 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", @@ -1107,6 +1108,25 @@ def test_offline_activation_migrates_cached_optional_feature_before_deleting_old assert state["claimed"] is True +def test_successful_optional_feature_fetch_is_cached_for_offline_reopen(): + result = run_worker_scenario( + """ + const asset = '/dashboard/feature-today-timer-test.js'; + context.self.__testOptionalFeatures.push(asset); + const response = await dispatch('fetch', { + method: 'GET', mode: 'cors', + url: 'https://forge.example/dashboard/feature-today-timer-test.js', + }); + process.stdout.write(JSON.stringify({ body: await response.text(), state })); +""" + ) + + assert result["body"] == "network" + assert result["state"]["puts"] == [ + "https://forge.example/dashboard/feature-today-timer-test.js" + ] + + def test_activate_warms_optional_features_without_blocking_siblings_or_claim(): result = run_worker_scenario( """ diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py new file mode 100644 index 0000000..f13c73f --- /dev/null +++ b/tests/test_workspace_bootstrap.py @@ -0,0 +1,124 @@ +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.", + } + + +def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects(): + result = run_bootstrap(""" +const status={textContent:''}; const listeners={}; let reloads=0; +const document={querySelector(selector) { + if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + return null; +}}; +const window={ + location:{reload(){reloads++;}}, + addEventListener(name,callback) { listeners[name]=callback; }, + removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; }, +}; +const createLoader=()=>({load:async()=>{throw new Error('offline');}}); +try { await loadWorkspace({document,window,createLoader}); } catch (error) {} +const waiting=Boolean(listeners.online); +if (listeners.online) listeners.online(); +console.log(JSON.stringify({waiting,reloads,status:status.textContent})); +""") + assert result == { + "waiting": True, + "reloads": 1, + "status": "Workspace could not load. Reconnect to retry automatically, or reload now.", + } + + +def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure(): + result = run_bootstrap(""" +const status={textContent:''}; const listeners={}; let reloads=0; +const document={querySelector(selector) { + if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + return null; +}}; +const window={ + location:{reload(){reloads++;}}, + addEventListener(name,callback) { listeners[name]=callback; }, + removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; }, +}; +const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}}); +try { await loadWorkspace({document,window,createLoader}); } catch (error) {} +console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)})); +""") + assert result == {"reloads": 1, "waiting": False} + + +def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers(): + result = run_bootstrap(""" +const status={textContent:''}; const listeners={}; +const document={querySelector(selector) { + if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + return null; +}}; +const window={ + addEventListener(name,callback) { listeners[name]=callback; }, + removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; }, +}; +const createLoader=()=>({load:async()=>{listeners.online();}}); +const lifecycle=await loadWorkspace({document,window,createLoader}); +let reconnects=0; +lifecycle.replayOnline(()=>{reconnects++;}); +lifecycle.replayOnline(()=>{reconnects++;}); +console.log(JSON.stringify({reconnects,listening:Boolean(listeners.online)})); +""") + assert result == {"reconnects": 1, "listening": False} \ No newline at end of file