From 5d9cec1dd706cb8f0e25b9fc4da8e4519fb7db35 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 11:39:32 +0000 Subject: [PATCH 1/7] perf: demand-load mobile workspace features (Closes #1468) --- frontend/dashboard.js | 1 + frontend/service-worker.js | 5 +- frontend/workspace-bootstrap.js | 71 ++++++++++++---- src/frontend_bundle.py | 16 +++- .../e2e/test_mobile_home_bootstrap_release.py | 53 +++++++++--- tests/test_frontend_bundle.py | 15 +++- tests/test_workspace_bootstrap.py | 83 ++++++++++++++++--- 7 files changed, 204 insertions(+), 40 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d16a41d..4463ac8 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -8614,4 +8614,5 @@ function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } setInterval(widgetTick, 1000); + workspaceLifecycle.markWorkspaceReady?.(); })(); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 873b1ae..9745a8e 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -301,6 +301,8 @@ const SHELL = [ ]; const OPTIONAL_FEATURES = [ ]; +const DEMAND_FEATURES = [ +]; const SHARED_IMAGE_ID = 'shared-image'; const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); @@ -1027,7 +1029,8 @@ self.addEventListener('fetch', event => { event.respondWith(caches.match(request).then(cached => cached || fetch(request))); return; } - if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) { + if (url.origin === self.location.origin && + (OPTIONAL_FEATURES.includes(url.pathname) || DEMAND_FEATURES.includes(url.pathname))) { event.respondWith(cachedOptionalFeature(request)); } }); diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js index 45b428d..b19102b 100644 --- a/frontend/workspace-bootstrap.js +++ b/frontend/workspace-bootstrap.js @@ -19,6 +19,15 @@ async function loadWorkspace({ const failed = new Set(); const recoveries = new Map(); let retryInFlight = null; + let resolveWorkspaceReady; + const workspaceReady = new Promise(resolve => { resolveWorkspaceReady = resolve; }); + let workspaceMarkedReady = false; + const markWorkspaceReady = () => { + if (workspaceMarkedReady) return false; + workspaceMarkedReady = true; + resolveWorkspaceReady(true); + return true; + }; const loadFeature = name => { attempts[name] += 1; @@ -73,23 +82,55 @@ async function loadWorkspace({ } hideRecovery(); - const optional = ['today-timer', 'planning'].map(async name => { - try { - await retryOnce(name); - return true; - } catch (_error) { - failed.add(name); - showRecovery(); - return new Promise(resolve => recoveries.set(name, {resolve})); - } - }); - const optionalReady = Promise.all(optional).then(() => { - hideRecovery(); - return true; - }); + let optionalReady = null; + const hydrateWorkspace = () => { + if (optionalReady) return optionalReady; + let resolveOptional; + optionalReady = new Promise(resolve => { resolveOptional = resolve; }); + const optional = ['today-timer', 'planning'].map(async name => { + try { + await retryOnce(name); + return true; + } catch (_error) { + failed.add(name); + showRecovery(); + return new Promise(resolve => recoveries.set(name, {resolve})); + } + }); + Promise.all(optional).then(() => { + hideRecovery(); + resolveOptional(true); + }); + return optionalReady; + }; + + const hydrationSelector = [ + '[data-mobile-task]:not([data-mobile-task="work"]):not([data-mobile-task="queues"])', + '[data-progressive-loading="true"]', + '#app-menu-toggle', + '#work-settings-toggle', + ].join(','); + const hydrateForAction = async event => { + const target = event.target?.closest?.(hydrationSelector); + if (!target) return; + event.preventDefault(); + event.stopImmediatePropagation(); + await hydrateWorkspace(); + await workspaceReady; + document.removeEventListener?.('click', hydrateForAction, true); + target.click?.(); + }; + document.addEventListener?.('click', hydrateForAction, true); + const hash = window?.location?.hash || ''; + const deepLinkReady = hash.startsWith('#/') && hash !== '#/my-work' ? + hydrateWorkspace() : Promise.resolve(false); return { - optionalReady, + hydrateWorkspace, + deepLinkReady, + workspaceReady, + markWorkspaceReady, + get optionalReady() { return hydrateWorkspace(); }, retryFeature(name) { if (!failed.has(name)) return Promise.resolve(true); return retryFailed().then(() => !failed.has(name)); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 07d382c..c9ea018 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -140,7 +140,16 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: for source in sources: if source != WORKER_RUNTIME_SOURCE: worker = worker.replace(f" BASE + '{source}',\n", "") - optional_features = feature_bundles + # The workspace hydrator fetches these large chunks only when a route or action + # needs them; warming them here would defeat demand loading on every visit. + optional_features = { + name: bundle for name, bundle in feature_bundles.items() + if name not in {"today-timer", "planning"} + } + demand_features = { + name: bundle for name, bundle in feature_bundles.items() + if name in {"today-timer", "planning"} + } worker = worker.replace( " BASE + 'static/dashboard.css',\n", " BASE + 'static/dashboard.css',\n" @@ -151,6 +160,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: "const OPTIONAL_FEATURES = [\n" + "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()), ) + worker = worker.replace( + "const DEMAND_FEATURES = [\n", + "const DEMAND_FEATURES = [\n" + + "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in demand_features.values()), + ) worker = CACHE_DECLARATION.sub( "const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1 ) diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 96d462c..c430cdf 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -41,6 +41,10 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) expect(page.locator("#my-work-status")).to_contain_text("No assigned work") page.locator('[data-mobile-task="work"]').click() @@ -100,7 +104,12 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) fresh_page.locator("#submit-sign-in").click() fresh_page.wait_for_url(origin + "/", wait_until="networkidle") + fresh_page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work") + fresh_page.wait_for_load_state("networkidle") assert fresh_page.evaluate( "localStorage.getItem('stackchain.first-task.v1:timmy')" ) == "complete" @@ -128,6 +137,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( browser_errors: list[str] = [] failed_responses: list[str] = [] workspace_requests: list[str] = [] + optional_workspace_requests: list[str] = [] launch_transfer_events: list[str] = [] live_requests: list[str] = [] @@ -171,6 +181,12 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( ) if "feature-work-core-" in request.url else None, ) + page.on( + "request", + lambda request: optional_workspace_requests.append(request.url) + if "feature-today-timer-" in request.url or "feature-planning-" in request.url + else None, + ) page.on( "requestfinished", lambda request: launch_transfer_events.append("core-finished") @@ -184,6 +200,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( ) page.goto(origin + "/", wait_until="networkidle") + assert optional_workspace_requests == [] page.locator('input[name="device_label"]').fill("Home bootstrap release phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) live_requests.clear() @@ -194,9 +211,24 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( ), launch_transfer_events expect(page.locator("#my-work-status")).to_contain_text("2") + assert optional_workspace_requests == [] revisionless_live_requests = [url for url in live_requests if "?" not in url] assert len(revisionless_live_requests) == 1, live_requests initial_live_requests = len(live_requests) + + page.locator("#work-settings-toggle").click() + tomorrow = page.locator("#plan-tomorrow") + expect(tomorrow).to_be_visible() + assert len(optional_workspace_requests) == 2, optional_workspace_requests + tomorrow_bounds = tomorrow.bounding_box() + assert tomorrow_bounds and tomorrow_bounds["height"] >= 44 + tomorrow.click() + expect(page.locator("#plan-today-sheet")).to_be_visible() + expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow") + expect(page.locator("#save-and-start-today")).to_be_hidden() + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + page.locator("#cancel-plan-today").click() + page.evaluate( """ () => { @@ -226,18 +258,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( bounds = control.bounding_box() assert bounds and bounds["height"] >= 44 - page.locator("#work-settings-toggle").click() - tomorrow = page.locator("#plan-tomorrow") - expect(tomorrow).to_be_visible() - tomorrow_bounds = tomorrow.bounding_box() - assert tomorrow_bounds and tomorrow_bounds["height"] >= 44 - tomorrow.click() - expect(page.locator("#plan-today-sheet")).to_be_visible() - expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow") - expect(page.locator("#save-and-start-today")).to_be_hidden() - assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") - page.locator("#cancel-plan-today").click() - page.locator('[data-mobile-task="queues"]').click() page.locator(".mobile-queue-all summary").click() delivery_queue = page.locator('[data-mobile-queue="delivery"]') @@ -361,6 +381,7 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa 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() page.wait_for_timeout(1500) resources = page.evaluate( @@ -406,6 +427,10 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) page.evaluate( """ @@ -508,6 +533,10 @@ def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot( page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) page.locator('[data-mobile-task="queues"]').click() page.locator('[data-mobile-queue="agenda"]').click() diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 65e336c..c56de5a 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -33,6 +33,15 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): assert first.runtime_name.startswith("runtime-") assert first.runtime_name.endswith(".js") assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024 + initial_mobile_javascript = ( + len(first.runtime_gzip_bytes) + + len(first.feature_bundles["work-core"].runtime_gzip_bytes) + ) + assert initial_mobile_javascript <= 60 * 1024 + assert ( + len(first.feature_bundles["today-timer"].runtime_gzip_bytes) + + len(first.feature_bundles["planning"].runtime_gzip_bytes) + ) > initial_mobile_javascript changed_frontend = tmp_path / "frontend" shutil.copytree(FRONTEND, changed_frontend) @@ -100,9 +109,13 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): ) optional_block = optional_block.split("];", 1)[0] assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block + demand_loaded = {"today-timer", "planning"} for name, bundle in first.feature_bundles.items(): assert f"BASE + '{bundle.runtime_name}'" not in shell_block - assert f"BASE + '{bundle.runtime_name}'" in optional_block + if name in demand_loaded: + assert f"BASE + '{bundle.runtime_name}'" not in optional_block + else: + assert f"BASE + '{bundle.runtime_name}'" in optional_block changed_frontend = tmp_path / "frontend" shutil.copytree(FRONTEND, changed_frontend) diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py index a740b44..ee1481c 100644 --- a/tests/test_workspace_bootstrap.py +++ b/tests/test_workspace_bootstrap.py @@ -19,7 +19,7 @@ const loadWorkspace = require({json.dumps(str(BOOTSTRAP))}); return json.loads(completed.stdout) -def test_workspace_bootstrap_loads_content_addressed_feature_before_startup(): +def test_workspace_bootstrap_demand_loads_optional_features_after_work_core(): result = run_bootstrap(""" const status={textContent:''}; const document={ @@ -33,11 +33,14 @@ const document={ }; const requested=[]; const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}}); -await loadWorkspace({document,createLoader}); -console.log(JSON.stringify({requested,status:status.textContent})); +const lifecycle=await loadWorkspace({document,createLoader}); +const before=requested.slice(); +await lifecycle.hydrateWorkspace?.(); +console.log(JSON.stringify({before,after:requested,status:status.textContent})); """) assert result == { - "requested": [ + "before": ["work-core:feature-work-core-123.js"], + "after": [ "work-core:feature-work-core-123.js", "today-timer:feature-workspace-abc.js", "planning:feature-planning-def.js", @@ -46,7 +49,65 @@ console.log(JSON.stringify({requested,status:status.textContent})); } -def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate(): +def test_workspace_bootstrap_hydrates_once_and_replays_dependent_mobile_action(): + result = run_bootstrap(""" +const listeners={}; const requested=[]; +const document={ + querySelector(selector) { + const match=selector.match(/stackchain-feature-([^\"]+)/); + return match ? {content:'feature-' + match[1] + '.js'} : null; + }, + addEventListener(name,callback,capture){listeners[name]={callback,capture};}, + removeEventListener(name,callback,capture){ + if(listeners[name]?.callback===callback && listeners[name]?.capture===capture) delete listeners[name]; + }, +}; +const createLoader=()=>({load:async name=>{ + requested.push(name); + if(name==='today-timer') setImmediate(()=>{ + actionReady=true; + lifecycle.markWorkspaceReady?.(); + }); +}}); +let lifecycle=await loadWorkspace({document,createLoader}); +let prevented=0,stopped=0,replayed=0,actionReady=false,replayedReady=false; +const target={ + closest(selector){return selector.includes('data-mobile-task') ? this : null;}, + click(){replayed++; replayedReady=actionReady;}, +}; +await listeners.click?.callback({target,preventDefault(){prevented++;},stopImmediatePropagation(){stopped++;}}); +console.log(JSON.stringify({requested,prevented,stopped,replayed,replayedReady,listening:Boolean(listeners.click)})); +""") + assert result == { + "requested": ["work-core", "today-timer", "planning"], + "prevented": 1, + "stopped": 1, + "replayed": 1, + "replayedReady": True, + "listening": False, + } + + +def test_workspace_bootstrap_hydrates_immediately_for_deep_link(): + result = run_bootstrap(""" +const requested=[]; +const document={ + querySelector(selector) { + const match=selector.match(/stackchain-feature-([^\"]+)/); + return match ? {content:'feature-' + match[1] + '.js'} : null; + }, + addEventListener(){}, +}; +const window={location:{hash:'#/my-work/today'},addEventListener(){},removeEventListener(){}}; +const createLoader=()=>({load:async name=>{requested.push(name);}}); +const lifecycle=await loadWorkspace({document,window,createLoader}); +await lifecycle.deepLinkReady; +console.log(JSON.stringify({requested})); +""") + assert result == {"requested": ["work-core", "today-timer", "planning"]} + + +def test_workspace_bootstrap_returns_after_work_core_before_optional_features_hydrate(): result = run_bootstrap(""" const document={querySelector(selector) { const match=selector.match(/stackchain-feature-([^\"]+)/); @@ -60,12 +121,13 @@ const createLoader=()=>({load:name=>{ }}); const lifecycle=await loadWorkspace({document,createLoader}); const returned=requested.slice(); +const hydration=lifecycle.hydrateWorkspace(); releases['today-timer'](); releases.planning(); -await lifecycle.optionalReady; +await hydration; console.log(JSON.stringify({returned,settled:requested})); """) assert result == { - "returned": ["work-core", "today-timer", "planning"], + "returned": ["work-core"], "settled": ["work-core", "today-timer", "planning"], } @@ -81,7 +143,8 @@ const document={querySelector(selector) { }}; const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}}); const schedule=callback=>{callback();}; -await loadWorkspace({document,createLoader,schedule}); +const lifecycle=await loadWorkspace({document,createLoader,schedule}); +await lifecycle.hydrateWorkspace(); console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden})); """) assert result == {"attempts": 4, "status": "", "retryHidden": True} @@ -107,7 +170,7 @@ await Promise.all([first,second,loading]); console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden})); """) assert result == { - "attempts": 5, + "attempts": 3, "reloads": 0, "offered": { "hidden": False, @@ -151,7 +214,7 @@ await loading; console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent})); """) assert result == { - "attempts": 5, + "attempts": 3, "waiting": True, "reloads": 0, "status": "", From 8909bedd788bd117f532d93f9040ae7cb485b798 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 12:17:20 +0000 Subject: [PATCH 2/7] test: align packaged journeys with demand hydration --- frontend/dashboard.js | 3 ++- frontend/workspace-bootstrap.js | 5 ++++- tests/e2e/test_human_gates_reopen_release.py | 4 +++- tests/e2e/test_mobile_offline_issue_release.py | 7 +++++++ tests/e2e/test_mobile_photo_draft_inbox_release.py | 3 ++- tests/e2e/test_mobile_today_summary_release.py | 3 ++- tests/e2e/test_mobile_today_wrap_up_release.py | 3 ++- tests/e2e/test_mobile_tomorrow_conflict_release.py | 3 ++- tests/e2e/test_mobile_wrap_up_handoff_release.py | 3 ++- tests/test_workspace_bootstrap.py | 2 +- 10 files changed, 27 insertions(+), 9 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 4463ac8..a0e53a2 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -8472,7 +8472,8 @@ }); let pushControllerReady = Promise.resolve(null); if ('serviceWorker' in navigator) { - pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => { + pushControllerReady = (workspaceLifecycle.serviceWorkerReady || + navigator.serviceWorker.register('service-worker.js')).then(async () => { await issueCaptureFeatures.load('push-notifications'); const controller = createPushNotifications({ control:qs('#push-updates'), diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js index b19102b..f3bcebe 100644 --- a/frontend/workspace-bootstrap.js +++ b/frontend/workspace-bootstrap.js @@ -72,6 +72,8 @@ async function loadWorkspace({ retryButton?.addEventListener?.('click', retryFailed); const handleOnline = () => { cameOnline = true; void retryFailed(); }; window?.addEventListener('online', handleOnline); + const serviceWorkerReady = window?.navigator?.serviceWorker?.register ? + window.navigator.serviceWorker.register('service-worker.js') : Promise.resolve(null); try { await retryOnce('work-core'); @@ -105,7 +107,7 @@ async function loadWorkspace({ }; const hydrationSelector = [ - '[data-mobile-task]:not([data-mobile-task="work"]):not([data-mobile-task="queues"])', + '[data-mobile-task]:not([data-mobile-task="queues"])', '[data-progressive-loading="true"]', '#app-menu-toggle', '#work-settings-toggle', @@ -130,6 +132,7 @@ async function loadWorkspace({ deepLinkReady, workspaceReady, markWorkspaceReady, + serviceWorkerReady, get optionalReady() { return hydrateWorkspace(); }, retryFeature(name) { if (!failed.has(name)) return Promise.resolve(true); diff --git a/tests/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py index c5aa656..a3ea15a 100644 --- a/tests/e2e/test_human_gates_reopen_release.py +++ b/tests/e2e/test_human_gates_reopen_release.py @@ -13,7 +13,7 @@ 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 +from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) @@ -85,6 +85,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + hydrate_workspace(page) page.evaluate("document.querySelector('#open-human-gates').click()") expect(page.locator("#human-gates")).to_be_visible() @@ -244,6 +245,7 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + hydrate_workspace(page) page.evaluate("document.querySelector('#open-human-gates').click()") expect(page.locator("#human-gates")).to_be_visible() cached_before = page.evaluate("Object.keys(localStorage).sort()") diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index a8a2dab..c2e2fe8 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -114,6 +114,13 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str): process.communicate() +def hydrate_workspace(page) -> None: + page.evaluate( + "async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; " + "await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }" + ) + + def indexed_issue_records(page: Page) -> list[dict]: return page.evaluate( """async () => { diff --git a/tests/e2e/test_mobile_photo_draft_inbox_release.py b/tests/e2e/test_mobile_photo_draft_inbox_release.py index f77e9b0..1042b4e 100644 --- a/tests/e2e/test_mobile_photo_draft_inbox_release.py +++ b/tests/e2e/test_mobile_photo_draft_inbox_release.py @@ -12,7 +12,7 @@ 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 +from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path): @@ -60,6 +60,7 @@ def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work })""" ) page.reload(wait_until="networkidle") + hydrate_workspace(page) stored = page.evaluate( """() => new Promise((resolve,reject)=>{ const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1); diff --git a/tests/e2e/test_mobile_today_summary_release.py b/tests/e2e/test_mobile_today_summary_release.py index c432e18..d576f5f 100644 --- a/tests/e2e/test_mobile_today_summary_release.py +++ b/tests/e2e/test_mobile_today_summary_release.py @@ -12,7 +12,7 @@ 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 +from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) @@ -50,6 +50,7 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary( page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + hydrate_workspace(page) page.evaluate( """ diff --git a/tests/e2e/test_mobile_today_wrap_up_release.py b/tests/e2e/test_mobile_today_wrap_up_release.py index ee743c9..1322dfe 100644 --- a/tests/e2e/test_mobile_today_wrap_up_release.py +++ b/tests/e2e/test_mobile_today_wrap_up_release.py @@ -12,7 +12,7 @@ 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 +from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) @@ -47,6 +47,7 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up( page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + hydrate_workspace(page) page.evaluate( """ diff --git a/tests/e2e/test_mobile_tomorrow_conflict_release.py b/tests/e2e/test_mobile_tomorrow_conflict_release.py index 086d219..278f33e 100644 --- a/tests/e2e/test_mobile_tomorrow_conflict_release.py +++ b/tests/e2e/test_mobile_tomorrow_conflict_release.py @@ -13,7 +13,7 @@ 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 +from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server @pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) @@ -71,6 +71,7 @@ def test_release_artifact_resolves_cross_device_tomorrow_conflicts_on_mobile( page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + hydrate_workspace(page) def stage_phone_plan(): page.evaluate( diff --git a/tests/e2e/test_mobile_wrap_up_handoff_release.py b/tests/e2e/test_mobile_wrap_up_handoff_release.py index a6d2553..bfa614c 100644 --- a/tests/e2e/test_mobile_wrap_up_handoff_release.py +++ b/tests/e2e/test_mobile_wrap_up_handoff_release.py @@ -12,7 +12,7 @@ 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 +from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path): @@ -36,6 +36,7 @@ def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path) 'issue:acme/mobile:41:', 'issue:acme/mobile:42:' ]))""") page.reload(wait_until="networkidle") + hydrate_workspace(page) dialog = page.locator("#today-handoff-dialog") expect(dialog).to_be_visible() diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py index ee1481c..fc7476d 100644 --- a/tests/test_workspace_bootstrap.py +++ b/tests/test_workspace_bootstrap.py @@ -72,7 +72,7 @@ const createLoader=()=>({load:async name=>{ let lifecycle=await loadWorkspace({document,createLoader}); let prevented=0,stopped=0,replayed=0,actionReady=false,replayedReady=false; const target={ - closest(selector){return selector.includes('data-mobile-task') ? this : null;}, + closest(selector){return selector.includes(':not([data-mobile-task="work"])') ? null : this;}, click(){replayed++; replayedReady=actionReady;}, }; await listeners.click?.callback({target,preventDefault(){prevented++;},stopImmediatePropagation(){stopped++;}}); From 4a99f54f12279eb67300167a3cc815d0559a21c2 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 12:22:42 +0000 Subject: [PATCH 3/7] test: hydrate demand workspace after release reloads --- tests/e2e/test_human_gates_reopen_release.py | 5 +++++ tests/e2e/test_mobile_offline_issue_release.py | 1 + tests/e2e/test_mobile_today_handoff_release.py | 5 ++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py index a3ea15a..0e47d77 100644 --- a/tests/e2e/test_human_gates_reopen_release.py +++ b/tests/e2e/test_human_gates_reopen_release.py @@ -127,6 +127,8 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( popup.close() page.reload(wait_until="networkidle") + hydrate_workspace(page) + page.wait_for_load_state("networkidle") page.evaluate("document.querySelector('#open-human-gates').click()") expect(page.locator("#human-gates")).to_be_visible() expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked() @@ -148,6 +150,8 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( assert len(decision_requests) == 1 page.reload(wait_until="networkidle") + hydrate_workspace(page) + page.wait_for_load_state("networkidle") page.evaluate("document.querySelector('#open-human-gates').click()") expect(page.locator("#human-gates")).to_be_visible() expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown") @@ -246,6 +250,7 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") hydrate_workspace(page) + page.wait_for_load_state("networkidle") page.evaluate("document.querySelector('#open-human-gates').click()") expect(page.locator("#human-gates")).to_be_visible() cached_before = page.evaluate("Object.keys(localStorage).sort()") diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index c2e2fe8..79531fc 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -282,6 +282,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo assert fake.created_issues == [] page.reload(wait_until="domcontentloaded") + hydrate_workspace(page) expect(page.locator('[data-mobile-task="new"]')).to_be_visible() durable_after_reload = indexed_issue_records(page) assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)] diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index c68040c..6b8ad5b 100644 --- a/tests/e2e/test_mobile_today_handoff_release.py +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -12,7 +12,7 @@ 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 +from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server def open_today_action(page, selector: str): @@ -269,6 +269,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: localStorage.setItem(prefix + 'broken', '{not-json'); }""") page.reload(wait_until="networkidle") + hydrate_workspace(page) expect(page.locator("#my-work-status")).to_contain_text("2") expect(page.locator("#today-sync-status")).to_have_text( "Today queue recovered · discarded 1 unreadable device record." @@ -446,6 +447,7 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights'; }""") page.reload(wait_until="networkidle") + hydrate_workspace(page) restored_pause = page.locator("#insights-sheet > [data-today-detour]") expect(restored_pause).to_be_visible() expect(restored_pause).to_contain_text("Today paused · Ship mobile capture") @@ -562,6 +564,7 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_ })); }""") page.reload(wait_until="networkidle") + hydrate_workspace(page) expect(page.locator("#my-work-status")).to_contain_text("2") if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open": page.locator("#close-issue-sheet").click() From c1d719842f952fbe97c0777ef9188faf3c702f09 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 12:25:22 +0000 Subject: [PATCH 4/7] test: await hydrated state in reload journeys --- tests/e2e/test_human_gates_reopen_release.py | 8 ++++++-- tests/e2e/test_mobile_offline_issue_release.py | 1 + tests/e2e/test_mobile_today_handoff_release.py | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py index 0e47d77..7e53a8a 100644 --- a/tests/e2e/test_human_gates_reopen_release.py +++ b/tests/e2e/test_human_gates_reopen_release.py @@ -253,7 +253,9 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t page.wait_for_load_state("networkidle") page.evaluate("document.querySelector('#open-human-gates').click()") expect(page.locator("#human-gates")).to_be_visible() - cached_before = page.evaluate("Object.keys(localStorage).sort()") + cached_before = page.evaluate( + "Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()" + ) page.locator("#human-gates-history").click() expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available") @@ -271,7 +273,9 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2") expect(page.locator("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.") expect(page.locator("[data-human-gate-history-more]")).to_have_count(0) - assert page.evaluate("Object.keys(localStorage).sort()") == cached_before + assert page.evaluate( + "Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()" + ) == cached_before assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") for selector in ("#human-gates-pending", "#human-gates-history"): bounds = page.locator(selector).bounding_box() diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index 79531fc..8d50061 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -330,6 +330,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo page.wait_for_timeout(100) assert durable_completion and all(item.get("status") == "sent" for item in durable_completion) page.reload(wait_until="networkidle") + hydrate_workspace(page) page.evaluate("window.dispatchEvent(new Event('online'))") for _ in range(40): completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")) diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index 6b8ad5b..f51a658 100644 --- a/tests/e2e/test_mobile_today_handoff_release.py +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -52,6 +52,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + hydrate_workspace(page) try: expect(page.locator("#my-work-status")).to_contain_text("2") @@ -378,6 +379,7 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + hydrate_workspace(page) expect(page.locator("#my-work-status")).to_contain_text("2") page.locator('[data-mobile-task="work"]').click() @@ -514,6 +516,7 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_ page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) page.locator("#submit-sign-in").click() page.wait_for_url(origin + "/", wait_until="networkidle") + hydrate_workspace(page) expect(page.locator("#my-work-status")).to_contain_text("2") page.locator('[data-mobile-task="work"]').click() From e8b2f31e5e104a42b10a7a247ab062adc5fd09ad Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 12:27:44 +0000 Subject: [PATCH 5/7] fix: restore hydrated session state before action replay --- frontend/dashboard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a0e53a2..99d2bac 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -8615,5 +8615,6 @@ function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } setInterval(widgetTick, 1000); + await timerView.restore(Promise.resolve(true)); workspaceLifecycle.markWorkspaceReady?.(); })(); From 5ccf499b1a61a10b7f4823eb98ea1a4ee1c2436e Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 13:10:30 +0000 Subject: [PATCH 6/7] test: isolate workspace retry from service worker --- tests/e2e/test_mobile_home_bootstrap_release.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index c430cdf..75d7b0b 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -366,7 +366,10 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa try: with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright: browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) - page = browser.new_page(viewport={"width": 390, "height": 844}) + context = browser.new_context( + viewport={"width": 390, "height": 844}, service_workers="block" + ) + page = context.new_page() page.goto(origin + "/", wait_until="networkidle") def interrupt_once(route): From 02b9fdd2e4c68ededdb28e8d74a8e2f5393be2c1 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 27 Aug 2026 14:34:13 +0000 Subject: [PATCH 7/7] fix: finish identity recovery before workspace readiness --- frontend/context-poller.js | 2 +- frontend/dashboard.js | 25 +++++++++- frontend/today-timer.js | 5 +- .../e2e/test_mobile_home_bootstrap_release.py | 47 +++++++++++++++++-- .../e2e/test_mobile_today_handoff_release.py | 11 ++++- tests/test_context_polling.py | 29 ++++++++++++ 6 files changed, 111 insertions(+), 8 deletions(-) diff --git a/frontend/context-poller.js b/frontend/context-poller.js index c75ce83..e633480 100644 --- a/frontend/context-poller.js +++ b/frontend/context-poller.js @@ -126,7 +126,7 @@ function createContextPoller({ let request; try { - request = fetchContext({ ...revisions }, { signal: controller.signal }); + request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal }); } catch (error) { request = Promise.reject(error); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 99d2bac..5864460 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -358,6 +358,7 @@ qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click()); qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click()); let liveMode = true; + let initialAccountRecovery = Promise.resolve(false); const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1'; const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1'; const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft']; @@ -5644,6 +5645,9 @@ activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; if (activeFlushLogin) { confirmedOwnerLogin = activeFlushLogin; + planningOwnerLogin = activeFlushLogin; + planningOwnerAccountKey = snapshot.context.user?.id ? + String(snapshot.context.user.id) + ':' + activeFlushLogin : ''; mobileQueuePriority.render(); renderMobileQueuePresentation(); void mobileQueuePriority.load(); @@ -5666,7 +5670,10 @@ if (planningOwnerLogin) { syncPendingTomorrow(); todaySync.migrate(todayWork.read()); - todaySync.flush(); + initialAccountRecovery = Promise.all([ + initialAccountRecovery, + todaySync.flush(), + ]).then(() => true); laterSync.migrate(laterWork.read()); laterSync.flush(); } @@ -8597,6 +8604,20 @@ adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise); } if (!adoptedProgressiveSnapshot) await load(); + else if (!confirmedOwnerLogin) { + await contextPoller.refresh({ force:true, full:true }); + if (!confirmedOwnerLogin) { + try { + const identity = await fetchReviewJson('api/v1/background-identity'); + const login = String(identity?.login || '').trim(); + if (login) { + confirmedOwnerLogin = login; + planningOwnerLogin = login; + initialAccountRecovery = timerView.restore(todaySync.flush()); + } + } catch (_error) {} + } + } if (progressiveWorkHandoff?.openWork) { const progressiveItem = lastMyWork.find(item => item.repository === progressiveWorkHandoff.openWork.repository && @@ -8615,6 +8636,8 @@ function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); } setInterval(widgetTick, 1000); + await initialAccountRecovery; + if (planningOwnerLogin) await todaySync.flush(); await timerView.restore(Promise.resolve(true)); workspaceLifecycle.markWorkspaceReady?.(); })(); diff --git a/frontend/today-timer.js b/frontend/today-timer.js index 935a3ff..460dfac 100644 --- a/frontend/today-timer.js +++ b/frontend/today-timer.js @@ -489,7 +489,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu const snapshot = timer.snapshot(); breakView?.render(); const active = Boolean(progress && isActive() && snapshot.identity); - queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; }); + queryAll('[data-mobile-today-hud]').forEach(element => { + const coaching = element.querySelector?.('[data-mobile-first-task-coach]:not([hidden])'); + element.hidden = !active && !coaching; + }); queryAll('[data-mobile-today-open]').forEach(element => { element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : ''; }); diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 75d7b0b..fafc02e 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -60,6 +60,7 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( expect(sheet).to_be_hidden() expect(page.locator("#find-work-sheet")).to_be_visible() page.locator("#close-find-work").click() + expect(page.locator("#find-work-sheet")).to_be_hidden() context.set_offline(True) page.locator('[data-mobile-task="work"]').click() @@ -70,8 +71,9 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( expect(page.locator("#mobile-first-task-create")).to_be_focused() page.locator("#close-mobile-first-task").click() + expect(sheet).to_be_hidden() context.set_offline(False) - page.evaluate( + probe_result = page.evaluate( """() => { document.querySelector('[data-mobile-today-hud]').hidden = false; localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching'); @@ -82,17 +84,54 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true, coach: isolatedCoach, }); - return window.firstTaskOutcomeProbe.refresh(); + return { + result: window.firstTaskOutcomeProbe.refresh(), + state: localStorage.getItem('stackchain.first-task.v1:timmy'), + mobile: matchMedia('(max-width: 600px)').matches, + }; }""" ) + assert probe_result == {"result": "coaching", "state": "coaching", "mobile": True} coach = page.locator("[data-mobile-first-task-coach]") - expect(coach).to_be_visible() + try: + expect(coach).to_be_visible() + except AssertionError as error: + raise AssertionError(page.evaluate("""() => { + const coach = document.querySelector('[data-mobile-first-task-coach]'); + const hud = coach.closest('[data-mobile-today-hud]'); + return { + coachHidden: coach.hidden, + coachDisplay: getComputedStyle(coach).display, + hudHidden: hud.hidden, + hudOverlay: hud.getAttribute('data-overlay-hidden'), + hudDisplay: getComputedStyle(hud).display, + open: Array.from(document.querySelectorAll('dialog, .open')) + .filter(element => element.open || element.classList.contains('open')) + .map(element => element.id || element.className), + }; + }""")) from error expect(coach).to_contain_text("Complete your first task") assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") - assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True + completion = page.evaluate("""() => { + const completed = window.firstTaskOutcomeProbe.completeOutcome(); + return { + completed, + status: document.querySelector('#today-sync-status').textContent, + pending: Object.keys(localStorage).filter(key => + key.startsWith('stackchain.today-sync.v1.timmy.operation.')), + }; + }""") + assert completion["completed"] is True + assert completion["pending"] + assert "sync pending" in completion["status"] expect(page.locator("#mobile-first-task-receipt")).to_be_visible() expect(coach).to_be_hidden() expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account") + remote_activation = page.evaluate("""async () => { + const response = await fetch('api/v1/today'); + return (await response.json()).first_task_state; + }""") + assert remote_activation == "complete" assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") fresh_context = browser.new_context( diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index f51a658..92fb13f 100644 --- a/tests/e2e/test_mobile_today_handoff_release.py +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -278,7 +278,16 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: assert page.evaluate("""() => !Object.keys(localStorage).some( key => key.startsWith('stackchain.today-sync.v1.timmy.operation.') )""") - expect(page.locator("#today-break-status")).to_contain_text("On break · resume in") + try: + expect(page.locator("#today-break-status")).to_contain_text("On break · resume in") + except AssertionError as error: + raise AssertionError(page.evaluate("""() => ({ + login: document.querySelector('#current-user')?.textContent, + timers: Object.fromEntries(Object.keys(localStorage) + .filter(key => key.startsWith('stackchain.today-timer.v1.')) + .map(key => [key, JSON.parse(localStorage.getItem(key))])), + status: document.querySelector('#today-break-status').outerHTML, + })""")) from error if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open": page.locator("#close-issue-sheet").click() if page.locator("#plan-today-sheet").is_visible(): diff --git a/tests/test_context_polling.py b/tests/test_context_polling.py index 03e2ba8..398476b 100644 --- a/tests/test_context_polling.py +++ b/tests/test_context_polling.py @@ -117,6 +117,32 @@ const poller = createContextPoller({{ } +def test_context_poller_can_force_a_full_identity_refresh_after_adoption(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const requested = []; +const seed = {{ + context:{{user:{{login:'timmy'}}}}, events:[], notifications:[], + revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2'}}, +}}; +const poller = createContextPoller({{ + fetchContext: revisions => {{ + requested.push({{...revisions}}); + return Promise.resolve({{...seed, freshness:{{sections:{{context:{{degraded:false}}}}}}}}); + }}, + onSnapshot: () => {{}}, onError: error => {{ throw error; }}, + setTimer: () => 1, clearTimer: () => {{}}, +}}); +poller.adopt(seed); +(async () => {{ + await poller.refresh({{force:true, full:true}}); + process.stdout.write(JSON.stringify({{requested}})); +}})(); +""" + + assert run_node(script) == {"requested": [{}]} + + def test_context_poller_adopts_progressive_snapshot_before_revision_conditional_refresh(): script = f""" const createContextPoller = require({json.dumps(str(POLLER))}); @@ -200,6 +226,9 @@ def test_dashboard_adopts_progressive_snapshot_or_falls_back_to_immediate_load() assert "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source assert "if (!adoptedProgressiveSnapshot) await load();" in source + assert "contextPoller.refresh({ force:true, full:true })" in source + assert "fetchReviewJson('api/v1/background-identity')" in source + assert "initialAccountRecovery = timerView.restore(todaySync.flush());" in source def test_context_poller_uses_failed_section_retry_before_healthy_freshness_deadline():