diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 98e8841..962b45b 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -180,7 +180,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n claim, claimNext, claimBatch, - complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })), + complete: (id, deliveredIssue) => update(id, item => ({ + ...item, status: 'sent', claimUntil: 0, + ...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}), + })), release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })), fail: (id, error, deliveryState) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error, @@ -303,7 +306,7 @@ function createBackgroundIssueSync({ const request = deliveryRequest(item); try { const delivered = await requestJson(request.url, request.options); - await store.complete(item.id); + await store.complete(item.id, delivered); const receipt = receiptFor(item, 'confirmed', delivered); return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; } catch (error) { diff --git a/frontend/create-and-start.js b/frontend/create-and-start.js index 2a54f67..4b5b9ea 100644 --- a/frontend/create-and-start.js +++ b/frontend/create-and-start.js @@ -17,11 +17,11 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno announce('Created, but Today could not be saved on this device.'); return 'unavailable'; } - completed.add(identity); - if (added === 'added' && !todaySync.enqueue('add', identity)) { + if (!todaySync.enqueue('add', identity)) { announce('Created and saved to Today on this device, but account sync is unavailable.'); return 'sync-unavailable'; } + completed.add(identity); refresh(); todaySync.flush(); warm(); diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a6a598d..824b9dd 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1062,6 +1062,9 @@ const outboxActions = item.quarantined ? '' + '' : + item.continuation ? + '' + + '' : item.kind === 'issue-outbox' ? '' + '' + @@ -1075,7 +1078,8 @@ ''; const state = (isOutbox || isUnfiled) ? '' + (item.quarantined ? 'Identity protected' : - (isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' + + (isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' : + item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' + (item.ownership ? '
' + escapeHtml(item.ownership) + '
' : '') : ''; return '
' + '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + @@ -1100,6 +1104,16 @@ else if (item.route) workRoute.open(item.route); }); }); + list.querySelectorAll('.draft-continue').forEach(button => { + button.addEventListener('click', () => { + const item = lastDrafts[Number(button.dataset.draftIndex)]; + const completion = issueOutbox.pendingCompletions(activeFlushLogin) + .find(candidate => candidate.id === item?.outbox_id); + if (completion) applyOutboxResult({ + confirmed: [completion.issue], completions: [completion], remaining: issueOutbox.list(), + }); + }); + }); list.querySelectorAll('.draft-edit').forEach(button => { button.addEventListener('click', () => { const item = lastDrafts[Number(button.dataset.draftIndex)]; @@ -1988,6 +2002,18 @@ }); if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot); refreshMyWorkView(); + const startedCompletions = new Set(); + (result.completions || []).forEach(completion => { + if (completion.ownerLogin !== activeFlushLogin || completion.intent !== 'create-and-start') return; + const created = lastMyWork.find(item => item.kind === 'issue' && + item.repository === completion.issue?.repository && item.number === completion.issue?.number); + if (!created) return; + const outcome = createAndStart.complete(created); + if (outcome === 'started' || outcome === 'exists') { + issueOutbox.completeIntent(completion.id, activeFlushLogin); + startedCompletions.add(created.repository + '#' + created.number); + } + }); if (result.confirmed?.length) { const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', '); qs('#my-work-action-status').textContent = keys + ' created and assigned to you.'; @@ -1995,10 +2021,11 @@ const created = lastMyWork.find(item => item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number ); - if (startCreated && created) { + const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number); + if (startCreated && !(result.completions || []).length && created) { const outcome = createAndStart.complete(created); if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue')); - } else if (openCreated && created) openRoutedWork(created, qs('#new-issue')); + } else if (openCreated && created && !completionHandled) openRoutedWork(created, qs('#new-issue')); } else if ((result.remaining || []).some(item => item.status === 'attention')) { qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.'; } else { @@ -2774,8 +2801,9 @@ qs('#create-issue-status').textContent = createAndStartRequested ? 'Creating issue and adding it to Today…' : 'Saving for background delivery…'; try { - const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, captureDraft) : - await issueOutbox.enqueueDurably(captureDraft); + const durableDraft = createAndStartRequested ? { ...captureDraft, completionIntent: 'create-and-start' } : captureDraft; + const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) : + await issueOutbox.enqueueDurably(durableDraft); const queued = admission.item; if (!admission.background) { editingOutboxId = queued.id; diff --git a/frontend/drafts.js b/frontend/drafts.js index 4ddcba4..55ccaf1 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -101,7 +101,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat function parseOutbox(raw) { try { const record = JSON.parse(raw); - if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return []; + if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return []; const currentLogin = String(getCurrentLogin() || '').trim(); return record.items.filter(item => item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string' @@ -112,9 +112,11 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat id: 'stackchain.issue-outbox.v1:' + item.id, outbox_id: item.id, kind: 'issue-outbox', - status: item.status === 'attention' ? 'attention' : 'queued', + status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'), label: item.deliveryState === 'uncertain' ? 'Verify delivery' : - (item.status === 'attention' ? 'Needs attention' : 'Queued issue'), + (item.status === 'completion' ? 'Created · ready to start' : + (item.status === 'attention' ? 'Needs attention' : 'Queued issue')), + continuation: item.status === 'completion' && item.completionIntent === 'create-and-start', delivery_state: item.deliveryState, repository: item.repository, title: textPreview(item.title) || 'Untitled queued issue', diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 35aab89..207ccfd 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -8,13 +8,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); - if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return []; + if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return []; return record.items.filter(item => item && typeof item === 'object'); } catch (_error) { return []; } } function write(items, mirror = true) { - storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); + storage?.setItem(storageKey, JSON.stringify({ version: 3, items })); coordinator?.notify('issue'); if (mirror && backgroundSync?.reconcile) { Promise.resolve(backgroundSync.reconcile(items)) @@ -39,6 +39,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge status: 'queued', queuedAt: Number(now()), }; + if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start'; item.operationId = item.id; if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) { item.milestoneId = Number(draft.milestoneId); @@ -86,6 +87,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge operationId: changed ? String(operationId()).slice(0, 128) : item.operationId, status: 'queued', }; + if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start'; + else delete updated.completionIntent; if (nextMilestoneId === undefined) delete updated.milestoneId; if (nextDueDate === undefined) delete updated.dueDate; delete updated.error; @@ -147,7 +150,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge } if (!issue) return { blocked: true }; discard(item.id); - return { issue }; + return { issue, item }; } catch (error) { const status = Number(error?.status || 0); if (status >= 400 && status < 500) { @@ -166,16 +169,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge async function flushQueue(currentLogin) { const confirmed = []; + const completions = []; let blocked = 0; currentLogin = String(currentLogin || '').trim(); for (const item of read()) { - if (item.status === 'attention') continue; + if (item.status === 'attention' || item.status === 'completion') continue; if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; } const result = await sendItem(item, currentLogin); - if (result.issue) confirmed.push(result.issue); + if (result.issue) { + confirmed.push(result.issue); + if (result.item?.completionIntent) completions.push({ + id: result.item.id, + intent: result.item.completionIntent, + ownerLogin: result.item.ownerLogin, + operationId: result.item.operationId, + issue: result.issue, + }); + } if (result.transient) break; } - return { confirmed, remaining: read(), blocked }; + return { confirmed, completions, remaining: read(), blocked }; } async function flush(currentLogin) { @@ -202,7 +215,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge write(read().map(candidate => candidate.id === id ? queued : candidate)); } const result = await sendItem(queued, currentLogin); - return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 }; + return { + confirmed: result.issue ? [result.issue] : [], + completions: result.issue && result.item?.completionIntent ? [{ + id: result.item.id, + intent: result.item.completionIntent, + ownerLogin: result.item.ownerLogin, + operationId: result.item.operationId, + issue: result.issue, + }] : [], + remaining: read(), blocked: 0, + }; } async function retry(id, currentLogin) { @@ -214,7 +237,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge const statuses = new Map((records || []).map(item => [item.id, item])); const items = read().flatMap(item => { const background = statuses.get(item.id); - if (background?.status === 'sent') return []; + if (background?.status === 'sent') { + if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{ + ...item, status: 'completion', deliveredIssue: background.deliveredIssue, + }]; + return []; + } if (background?.status === 'attention') return [{ ...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240), ...(background.deliveryState ? { deliveryState: background.deliveryState } : {}), @@ -225,7 +253,32 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge return items; } - return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) }; + function pendingCompletions(currentLogin) { + const login = String(currentLogin || '').trim(); + if (!login) return []; + return read().filter(item => item.status === 'completion' && item.ownerLogin === login && + item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({ + id: item.id, + intent: item.completionIntent, + ownerLogin: item.ownerLogin, + operationId: item.operationId, + issue: item.deliveredIssue, + })); + } + + function completeIntent(id, currentLogin) { + const login = String(currentLogin || '').trim(); + const items = read(); + const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login); + if (!matched) return false; + write(items.filter(item => item.id !== id)); + return true; + } + + return { + enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, + pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })), + }; } if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index e39e7c9..f21162c 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v58'; +const CACHE = 'stackchain-dashboard-shell-v59'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index ef08f22..dcf84f9 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -64,6 +64,38 @@ const fetchJson = async (url, options = {{}}) => {{ } +def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const item = {{ + id:'capture-start',operationId:'capture-start',ownerLogin:'timmy',status:'queued', + repository:'stackchain/dashboard',title:'Resume',body:'',labelIds:[], + completionIntent:'create-and-start', +}}; +let queued = item; +const state = {{completed:[]}}; +const store = {{ + claimNext:async () => queued ? (queued=null,{{...item}}) : null, + complete:async (id, deliveredIssue) => state.completed.push({{id,deliveredIssue}}), + release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0, +}}; +const fetchJson = async url => url === 'api/v1/background-identity' ? {{login:'timmy'}} : + {{repository:'stackchain/dashboard',number:389,title:'Resume'}}; +(async()=>{{ + await createBackgroundIssueSync({{store,fetchJson}}).flush(); + process.stdout.write(JSON.stringify(state)); +}})(); +""" + output = run_node(script) + + assert output["completed"] == [{ + "id": "capture-start", + "deliveredIssue": { + "repository": "stackchain/dashboard", "number": 389, "title": "Resume" + }, + }] + + def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); diff --git a/tests/test_create_and_start.py b/tests/test_create_and_start.py index 5f75881..7cbffd7 100644 --- a/tests/test_create_and_start.py +++ b/tests/test_create_and_start.py @@ -67,6 +67,18 @@ def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_ assert "BASE + 'static/create-and-start.js'" in worker +def test_create_and_start_intent_is_durable_and_has_a_one_tap_relaunch_continuation(): + dashboard = DASHBOARD.read_text() + + assert "completionIntent: 'create-and-start'" in dashboard + assert "result.completions || []" in dashboard + assert "(result.completions || []).forEach(completion =>" in dashboard + assert "issueOutbox.pendingCompletions(activeFlushLogin)" in dashboard + assert "issueOutbox.completeIntent(completion.id, activeFlushLogin)" in dashboard + assert 'class="draft-continue"' in dashboard + assert "Continue created work" in dashboard + + def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued(): script = f""" const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))}); @@ -85,3 +97,31 @@ process.stdout.write(JSON.stringify({{result:flow.complete(issue), calls}})); "result": "sync-unavailable", "calls": ["Created and saved to Today on this device, but account sync is unavailable."], } + + +def test_create_and_start_can_resume_after_today_sync_admission_recovers(): + script = f""" +const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))}); +const calls=[];let admitted=false;let added=false; +const issue={{kind:'issue',repository:'stackchain/dashboard',number:389}}; +const flow=createCreateAndStart({{ + todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:stackchain/dashboard:389:',add:()=>{{ + const outcome=added?'exists':'added';added=true;return outcome; + }}}}, + todaySync:{{enqueue:()=>admitted,flush:()=>calls.push('flush')}}, + refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'), + announce:message=>calls.push(message), +}}); +const first=flow.complete(issue); +admitted=true; +const second=flow.complete(issue); +process.stdout.write(JSON.stringify({{first,second,calls}})); +""" + output = run_node(script) + + assert output["first"] == "sync-unavailable" + assert output["second"] == "started" + assert output["calls"][-5:] == [ + "refresh", "flush", "warm", "start", + "Created, added to Today, and ready to work.", + ] diff --git a/tests/test_drafts.py b/tests/test_drafts.py index e46c2aa..d552e3b 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -121,6 +121,26 @@ process.stdout.write(JSON.stringify(drafts)); assert output[1]["label"] == "Queued issue" +def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation(): + script = f""" +const createDraftInbox = require({json.dumps(str(DRAFTS))}); +const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{ + id:'continue-1',operationId:'continue-1',repository:'stackchain/dashboard', + title:'Resume this issue',body:'',ownerLogin:'timmy',status:'completion',queuedAt:200, + completionIntent:'create-and-start',deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume this issue'}} +}}]}})]]); +const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null}}; +const item = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0]; +process.stdout.write(JSON.stringify(item)); +""" + output = run_node(script) + + assert output["status"] == "completion" + assert output["label"] == "Created · ready to start" + assert output["continuation"] is True + assert output["quarantined"] is False + + def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification(): script = f""" const createDraftInbox = require({json.dumps(str(DRAFTS))}); diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 8736ccb..6df9d90 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -68,7 +68,57 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g ] assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"] assert all(item["status"] == "queued" for item in output["items"]) - assert output["stored"]["version"] == 2 + assert output["stored"]["version"] == 3 + + +def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox = createIssueOutbox({{ + storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'create-start-1', + fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}}), +}}); +outbox.enqueue({{ + repository:'stackchain/dashboard',title:'Resume me',body:'',completionIntent:'create-and-start' +}}); +const reloaded = createIssueOutbox({{ + storage, fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}}) +}}); +reloaded.flush('timmy').then(result => process.stdout.write(JSON.stringify({{ + persisted:JSON.parse(values.get('stackchain.issue-outbox.v1')), + result, +}}))); +""" + output = run_node(script) + + assert output["persisted"]["version"] == 3 + completion = output["result"]["completions"][0] + assert completion["intent"] == "create-and-start" + assert completion["ownerLogin"] == "timmy" + assert completion["operationId"] == "create-start-1" + assert completion["issue"]["number"] == 389 + + +def test_editing_a_queued_issue_updates_its_completion_intent_without_rekeying_delivery(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values=new Map();let sequence=0; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox=createIssueOutbox({{ + storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'intent-' + (++sequence) +}}); +const queued=outbox.enqueue({{repository:'o/r',title:'Work',body:''}}); +const started=outbox.update(queued.id,{{...queued,completionIntent:'create-and-start'}}); +const ordinary=outbox.update(queued.id,{{...started,completionIntent:undefined}}); +process.stdout.write(JSON.stringify({{started,ordinary}})); +""" + output = run_node(script) + + assert output["started"]["completionIntent"] == "create-and-start" + assert "completionIntent" not in output["ordinary"] + assert output["started"]["operationId"] == output["ordinary"]["operationId"] == "intent-1" def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued(): @@ -384,13 +434,45 @@ process.stdout.write(JSON.stringify(outbox.list())); ] +def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values=new Map(); +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{ + id:'resume',operationId:'resume',ownerLogin:'timmy',status:'queued', + repository:'stackchain/dashboard',title:'Resume',body:'',completionIntent:'create-and-start' +}}]}})); +const outbox=createIssueOutbox({{storage}}); +outbox.reconcileBackground([{{ + id:'resume',status:'sent',ownerLogin:'timmy',completionIntent:'create-and-start', + deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume'}} +}}]); +const blocked=outbox.pendingCompletions('alexander'); +const pending=outbox.pendingCompletions('timmy'); +const retained=outbox.list(); +outbox.completeIntent('resume','timmy'); +process.stdout.write(JSON.stringify({{blocked,pending,retained,remaining:outbox.list()}})); +""" + output = run_node(script) + + assert output["blocked"] == [] + assert output["pending"] == [{ + "id": "resume", "intent": "create-and-start", "ownerLogin": "timmy", + "operationId": "resume", + "issue": {"repository": "stackchain/dashboard", "number": 389, "title": "Resume"}, + }] + assert output["retained"][0]["status"] == "completion" + assert output["remaining"] == [] + + @pytest.mark.anyio async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions(): html = await dashboard() assert '' in html assert "const issueOutbox = createIssueOutbox({" in html - assert "await issueOutbox.enqueueDurably(captureDraft)" in html + assert "await issueOutbox.enqueueDurably(durableDraft)" in html assert "Saving for background delivery…" in html assert "Saved for next launch; background delivery unavailable." in html assert "throw new Error('Background Sync unavailable')" in html diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 15bf74a..decb91d 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,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-v58" in source + assert "stackchain-dashboard-shell-v59" 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 42809ef..0d83837 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,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-v58" in worker + assert "stackchain-dashboard-shell-v59" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 627dfa9..f765675 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ 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-v58" in worker + assert "stackchain-dashboard-shell-v59" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 9f167af..4124f2a 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -3290,7 +3290,7 @@ async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate(): assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index( - "issueOutbox.enqueueDurably(captureDraft)" + "issueOutbox.enqueueDurably(durableDraft)" ) assert ".create-issue-duplicate-card" in html assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 3f0608e..e1da8cb 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v58" in source + assert "stackchain-dashboard-shell-v59" in source assert "BASE + 'static/plan-today.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2a30e9a..35b38f6 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v58" in source + assert "stackchain-dashboard-shell-v59" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -116,14 +116,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v58" in source + assert "stackchain-dashboard-shell-v59" 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-v58" in source + assert "stackchain-dashboard-shell-v59" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -132,21 +132,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-v58" in source + assert "stackchain-dashboard-shell-v59" 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-v58" in source + assert "stackchain-dashboard-shell-v59" 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-v58" in source + assert "stackchain-dashboard-shell-v59" in source assert "BASE + 'static/update-ownership.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index a31a70d..8a909c7 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:'); 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-v58" in source + assert "stackchain-dashboard-shell-v59" in source assert "BASE + 'static/today-sync.js'" in source