diff --git a/frontend/private-data-registry.js b/frontend/private-data-registry.js index 542407e..001923f 100644 --- a/frontend/private-data-registry.js +++ b/frontend/private-data-registry.js @@ -6,6 +6,7 @@ 'stackchain-voice-transcripts-v1', 'stackchain-search-reply-drafts-v1', 'stackchain-conversation-reply-drafts-v1', + 'stackchain-today-action-mailbox-v1', ]); if (typeof module !== 'undefined' && module.exports) module.exports = databases; else root.stackchainPrivateDatabases = databases; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index db5ca3f..71f6e5d 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,12 +1,67 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v125'; +const CACHE = 'stackchain-dashboard-shell-v126'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000; +const TODAY_ACTION_TTL_MS = 2 * 60 * 1000; const PRIVATE_DATABASES = self.stackchainPrivateDatabases; + +function createTodayActionStore() { + const dbName = 'stackchain-today-action-mailbox-v1'; + const storeName = 'commands'; + const open = () => new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = () => request.result.createObjectStore(storeName, {keyPath:'clientId'}); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Today action mailbox unavailable.')); + }); + return { + async put(clientId, command) { + const database = await open(); + await new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, 'readwrite'); + transaction.objectStore(storeName).put({...command, clientId}); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error || new Error('Today action could not be saved.')); + transaction.onabort = transaction.onerror; + }); + database.close(); + }, + async claim(clientId, now) { + const database = await open(); + let command = null; + await new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, 'readwrite'); + const store = transaction.objectStore(storeName); + const request = store.get(clientId); + request.onsuccess = () => { + command = request.result || null; + if (command) store.delete(clientId); + }; + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error || new Error('Today action could not be claimed.')); + transaction.onabort = transaction.onerror; + }); + database.close(); + return command && command.expiresAt > now ? command : null; + }, + async purge() { + const database = await open(); + await new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, 'readwrite'); + transaction.objectStore(storeName).clear(); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error || new Error('Today actions could not be purged.')); + transaction.onabort = transaction.onerror; + }); + database.close(); + }, + }; +} +const todayActionStore = self.__STACKCHAIN_TODAY_ACTION_STORE || createTodayActionStore(); const SHELL = [ BASE, BASE + 'manifest.webmanifest', @@ -473,6 +528,7 @@ self.addEventListener('message', event => { if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => { try { await issueSync.purge(); + await todayActionStore.purge(); await deletePrivateDatabases(); await updateTodayLockScreen(false, false); event.ports?.[0]?.postMessage({ ok: true }); @@ -481,12 +537,33 @@ self.addEventListener('message', event => { } })()); if (event.data?.type === 'stackchain-today-lock-screen') { - event.waitUntil(updateTodayLockScreen( - event.data.active === true, - event.data.running === true, - String(event.data.actionToken || ''), - Number(event.data.breakDeadlineAt || 0) - )); + event.waitUntil((async () => { + const active = event.data.active === true; + if (!active) await todayActionStore.purge(); + await updateTodayLockScreen( + active, + event.data.running === true, + String(event.data.actionToken || ''), + Number(event.data.breakDeadlineAt || 0) + ); + })()); + } + if (event.data?.type === 'stackchain-claim-today-action') { + event.waitUntil((async () => { + const source = event.source; + if (!source?.id || !String(source.url || '').startsWith(self.location.origin + BASE)) return; + let command; + try { command = await todayActionStore.claim(source.id, Date.now()); } + catch (_error) { return; } + if (!command || !['pause', 'resume', 'complete'].includes(command.action)) return; + if (command.actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(command.actionToken)) return; + if (command.action === 'complete' && !command.actionToken) return; + source.postMessage?.({ + type:'stackchain-today-timer-action', + action:command.action, + ...(command.actionToken ? {actionToken:command.actionToken} : {}), + }); + })()); } }); @@ -608,10 +685,17 @@ async function applyTodayTimerAction(action, actionToken = '') { }); return client.focus?.(); } - const query = '?today_timer_action=' + action + - (actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : ''); - const target = new URL(BASE + query + route, self.location.origin).href; - return self.clients.openWindow(target); + const target = new URL(BASE + route, self.location.origin).href; + const opened = await self.clients.openWindow(target); + if (!opened?.id) return opened; + try { + await todayActionStore.put(opened.id, { + action, + ...(actionToken ? {actionToken} : {}), + expiresAt:Date.now() + TODAY_ACTION_TTL_MS, + }); + } catch (_error) { /* The clean Today route remains safe; fail the action closed. */ } + return opened; } self.addEventListener('notificationclick', event => { diff --git a/frontend/today-lock-screen.js b/frontend/today-lock-screen.js index fa53577..ec9f124 100644 --- a/frontend/today-lock-screen.js +++ b/frontend/today-lock-screen.js @@ -149,16 +149,9 @@ function createTodayLockScreen({ }, consumeAction, async consumeLaunchAction() { - let url; - try { url = new URL(locationRef?.href || ''); } - catch (_error) { return false; } - const action = url.searchParams.get('today_timer_action'); - if (!['pause', 'resume', 'complete'].includes(action)) return false; - const token = url.searchParams.get('today_action_token') || ''; - url.searchParams.delete('today_timer_action'); - url.searchParams.delete('today_action_token'); - historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash); - return await consumeAction(action, token); + if (!enabled()) return false; + await post({type:'stackchain-claim-today-action'}); + return true; }, render, }; diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index b1271f9..0126605 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v125" in worker + assert "stackchain-dashboard-shell-v126" in worker diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 49b3dda..5572a37 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -364,6 +364,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert result["remaining"] == ["gitea.preference"] assert result["replaced"] == [ @@ -391,6 +392,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["replaced"] == ["/dashboard/login?reason=session-expired"] @@ -523,6 +525,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] @@ -547,6 +550,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] @@ -644,6 +648,7 @@ process.stdout.write(JSON.stringify(state)); "stackchain-background-outbox-v1", "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["assigned"] == "/dashboard/login" diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index da4b465..a155b57 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index dfc5d63..ea58efb 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v125" in worker + assert "stackchain-dashboard-shell-v126" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 4c41c7b..63a7fac 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v125" in worker + assert "stackchain-dashboard-shell-v126" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 32c5ff8..02595ed 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v125" in worker + assert "stackchain-dashboard-shell-v126" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index f51323b..d4f05d2 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v125" in worker + assert "stackchain-dashboard-shell-v126" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 10d978b..19d7e44 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v125" in service_worker + assert "stackchain-dashboard-shell-v126" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 0306f29..70d1042 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_private_data_registry.py b/tests/test_private_data_registry.py index af0d8c8..f231905 100644 --- a/tests/test_private_data_registry.py +++ b/tests/test_private_data_registry.py @@ -33,6 +33,7 @@ process.stdout.write(JSON.stringify(databases)); "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", } diff --git a/tests/test_private_device_data.py b/tests/test_private_device_data.py index 33fe93e..b17fa8b 100644 --- a/tests/test_private_device_data.py +++ b/tests/test_private_device_data.py @@ -50,6 +50,7 @@ const clear = createPrivateDeviceDataPurger({{ "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert state["caches"] == ["stackchain-dashboard-shell-v37"] assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}] diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 29cd616..e70d68d 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', @@ -66,12 +66,24 @@ const context = {{ get: async id => state.sharedRecords[id] || null, delete: async id => {{ delete state.sharedRecords[id]; }}, }}, + __STACKCHAIN_TODAY_ACTION_STORE: {{ + put: async (clientId, command) => {{ + if (state.failTodayCommandPut) throw new Error('quota'); + state.todayCommands[clientId] = command; + }}, + claim: async (clientId, now) => {{ + const command = state.todayCommands[clientId] || null; + delete state.todayCommands[clientId]; + return command && command.expiresAt > now ? command : null; + }}, + purge: async () => {{ state.todayCommands = {{}}; }}, + }}, addEventListener: (name, handler) => {{ listeners[name] = handler; }}, skipWaiting: async () => {{ state.skipped = true; }}, clients: {{ claim: async () => {{ state.claimed = true; }}, matchAll: async () => state.clientList || [], - openWindow: async url => {{ state.opened.push(url); }}, + openWindow: async url => {{ state.opened.push(url); return state.openedClient || {{id:'opened-client',url}}; }}, }}, registration: {{ showNotification: async (title, options) => state.notifications.push({{title,options}}), @@ -133,9 +145,9 @@ async function dispatchSync(tag) {{ listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }}); if (pending) await pending; }} -async function dispatchMessage(data, ports = []) {{ +async function dispatchMessage(data, ports = [], source = null) {{ let pending; - listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }}); + listeners.message({{ data, ports, source, waitUntil: promise => {{ pending = promise; }} }}); if (pending) await pending; }} async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null, actionToken = '') {{ @@ -165,16 +177,16 @@ async function dispatchPush(payload) {{ return json.loads(completed.stdout) -def test_offline_activation_migration_rolls_the_shell_cache(): +def test_private_today_action_mailbox_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -183,7 +195,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -192,7 +204,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -200,14 +212,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -215,7 +227,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -223,7 +235,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -233,14 +245,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -249,21 +261,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/update-ownership.js'" in source @@ -451,6 +463,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert result["state"]["clientMessages"] == [ @@ -610,17 +623,81 @@ def test_today_lock_screen_action_updates_an_open_dashboard_without_navigation() assert result["opened"] == [] -def test_today_lock_screen_action_opens_a_validated_one_shot_route_when_closed(): +def test_today_lock_screen_action_opens_clean_route_and_privately_hands_off_once_when_closed(): result = run_worker_scenario( """ - await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session'); + state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'}; + await dispatchNotificationClick('#/my-work/today','resume-today',null,'stackchain-today-session',null,'opaque-token-1234567890'); + const messages=[]; + const source={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>messages.push(message)}; + await dispatchMessage({type:'stackchain-claim-today-action'}, [], source); + await dispatchMessage({type:'stackchain-claim-today-action'}, [], source); + state.claimMessages=messages; process.stdout.write(JSON.stringify(state)); """ ) assert result["opened"] == [ - "https://forge.example/dashboard/?today_timer_action=resume#/my-work/today" + "https://forge.example/dashboard/#/my-work/today" ] + assert "today_timer_action" not in result["opened"][0] + assert "today_action_token" not in result["opened"][0] + assert result["claimMessages"] == [{ + "type": "stackchain-today-timer-action", + "action": "resume", + "actionToken": "opaque-token-1234567890", + }] + assert result["todayCommands"] == {} + + +def test_today_lock_screen_private_handoff_rejects_wrong_client_and_expires_closed(): + result = run_worker_scenario( + """ + state.openedClient={id:'intended-client',url:'https://forge.example/dashboard/#/my-work/today'}; + await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890'); + const wrong=[]; + await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'other-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>wrong.push(message)}); + state.todayCommands['intended-client'].expiresAt=0; + const expired=[]; + await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'intended-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>expired.push(message)}); + process.stdout.write(JSON.stringify({wrong,expired,commands:state.todayCommands})); +""" + ) + + assert result == {"wrong": [], "expired": [], "commands": {}} + + +def test_today_action_mailbox_persistence_failure_opens_clean_route_without_mutation(): + result = run_worker_scenario( + """ + state.failTodayCommandPut=true; + state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'}; + const outcome=await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890') + .then(()=> 'safe', error=>error.message); + process.stdout.write(JSON.stringify({outcome,opened:state.opened,commands:state.todayCommands})); +""" + ) + + assert result == { + "outcome": "safe", + "opened": ["https://forge.example/dashboard/#/my-work/today"], + "commands": {}, + } + + +def test_private_data_purge_removes_pending_today_action_handoffs(): + result = run_worker_scenario( + """ + state.openedClient={id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today'}; + await dispatchNotificationClick('#/my-work/today','finish-today',null,'stackchain-today-session',null,'opaque-token-1234567890'); + await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:()=>{}}]); + const messages=[]; + await dispatchMessage({type:'stackchain-claim-today-action'}, [], {id:'cold-client',url:'https://forge.example/dashboard/#/my-work/today',postMessage:message=>messages.push(message)}); + process.stdout.write(JSON.stringify({messages,commands:state.todayCommands})); +""" + ) + + assert result == {"messages": [], "commands": {}} def test_finish_today_lock_screen_action_forwards_only_its_opaque_token(): @@ -707,6 +784,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion(): "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert result["replies"] == [{"ok": True}] @@ -1167,7 +1245,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/queue-today.js'" in source @@ -1465,6 +1543,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she "stackchain-voice-transcripts-v1", "stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1", + "stackchain-today-action-mailbox-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert "private cached dashboard" not in result["body"] diff --git a/tests/test_today_lock_screen.py b/tests/test_today_lock_screen.py index 7d9e088..436574e 100644 --- a/tests/test_today_lock_screen.py +++ b/tests/test_today_lock_screen.py @@ -74,21 +74,19 @@ def test_disabling_or_ending_session_removes_notification(): ] -def test_valid_notification_action_is_consumed_once_and_removed_from_url(): +def test_cold_launch_requests_one_private_action_claim_without_url_capabilities(): result = run_scenario(r""" values.set('stackchain.today-lock-screen.v1.timmy','1'); - locationRef.href='https://forge.example/dashboard/?today_timer_action=pause#/my-work/today'; const consumed = await lockScreen.consumeLaunchAction(); - const second = await lockScreen.consumeLaunchAction(); listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'resume'}}); - process.stdout.write(JSON.stringify({consumed,second,actions,href:locationRef.href})); + process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href,messages})); """) assert result == { "consumed": True, - "second": False, - "actions": [["pause", None], ["resume", None]], + "actions": [["resume", None]], "href": "https://forge.example/dashboard/#/my-work/today", + "messages": [{"type": "stackchain-claim-today-action"}], } @@ -133,20 +131,18 @@ def test_break_sync_carries_deadline_and_resume_is_bound_to_that_exact_break(): assert "private/repo" not in json.dumps(result["messages"]) -def test_cold_launch_finish_action_is_removed_from_history_before_completion(): +def test_private_cold_launch_finish_action_is_bound_to_the_active_identity(): result = run_scenario(r""" await lockScreen.enable(); await lockScreen.sync({identity:'issue:private/repo:42:',running:true}, true); - locationRef.href='https://forge.example/dashboard/?today_timer_action=complete&today_action_token=opaque-token-1234567890#/my-work/today'; - const consumed = await lockScreen.consumeLaunchAction(); - process.stdout.write(JSON.stringify({consumed,actions,href:locationRef.href})); + await lockScreen.consumeLaunchAction(); + await listeners['sw-message']({data:{type:'stackchain-today-timer-action',action:'complete',actionToken:'opaque-token-1234567890'}}); + process.stdout.write(JSON.stringify({actions,href:locationRef.href,messages})); """) - assert result == { - "consumed": True, - "actions": [["complete", "issue:private/repo:42:"]], - "href": "https://forge.example/dashboard/#/my-work/today", - } + assert result["actions"] == [["complete", "issue:private/repo:42:"]] + assert result["href"] == "https://forge.example/dashboard/#/my-work/today" + assert result["messages"][-1] == {"type": "stackchain-claim-today-action"} def test_permission_denial_is_truthful_and_does_not_persist_opt_in(): diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 0649f1b..20e33e8 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v125';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v126';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 99c208e..91d2bea 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v125" in source + assert "stackchain-dashboard-shell-v126" in source assert "BASE + 'static/today-sync.js'" in source