feat: hand off Today lock-screen actions privately (Closes #1172)
This commit is contained in:
parent
f56ca6373c
commit
988f6646c2
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user