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 d16a41d..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(); } @@ -8472,7 +8479,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'), @@ -8596,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 && @@ -8614,4 +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/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/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/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js index 45b428d..f3bcebe 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; @@ -63,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'); @@ -73,23 +84,56 @@ 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="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, + serviceWorkerReady, + 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_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py index c5aa656..7e53a8a 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() @@ -126,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() @@ -147,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") @@ -244,9 +249,13 @@ 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.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") @@ -264,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_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 96d462c..fafc02e 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() @@ -56,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() @@ -66,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'); @@ -78,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( @@ -100,7 +143,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 +176,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 +220,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 +239,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 +250,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 +297,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"]') @@ -346,7 +405,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): @@ -361,6 +423,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 +469,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 +575,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/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index a8a2dab..8d50061 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 () => { @@ -275,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)] @@ -322,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_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_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index c68040c..92fb13f 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): @@ -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") @@ -269,6 +270,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." @@ -276,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(): @@ -377,6 +388,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() @@ -446,6 +458,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") @@ -512,6 +525,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() @@ -562,6 +576,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() 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_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(): 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..fc7476d 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(':not([data-mobile-task="work"])') ? null : this;}, + 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": "",