diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index 294f0fb..9abe813 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) ); const pending = new Map(); - const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review']); + const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']); function reviewFingerprint(message) { return JSON.stringify({ @@ -133,6 +133,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply'; } const repository = item.repository.split('/').map(encodeURIComponent).join('/'); + if (item.kind === 'issue-close') { + return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close'; + } if (item.kind === 'pull-review') { return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review'; } @@ -155,21 +158,29 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, } result = delivery.message; } else { - const body = item.kind === 'pull-review' ? { - body: item.body, - decision: item.decision, - expected_head_sha: item.expectedHeadSha, - comments: item.comments, - } : { body: item.body }; - result = await fetchJson(endpoint(item), { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'Idempotency-Key': item.operationId, - }, - body: JSON.stringify(body), - }); + if (item.kind === 'issue-close') { + result = await fetchJson(endpoint(item), { + method: 'PATCH', + headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId }, + }); + if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.'); + } else { + const body = item.kind === 'pull-review' ? { + body: item.body, + decision: item.decision, + expected_head_sha: item.expectedHeadSha, + comments: item.comments, + } : { body: item.body }; + result = await fetchJson(endpoint(item), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify(body), + }); + } } if (!result) return { blocked: true }; clearConfirmedReviewState(item); diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index c43ed48..057d23c 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -249,6 +249,12 @@ function createBackgroundIssueSync({ route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number), }; } + if (item.kind === 'issue-close') { + return { + id: item.id, status, kind: 'message', + route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number), + }; + } if (item.kind === 'issue-comment' || item.kind === 'pull-comment') { const resource = item.kind === 'pull-comment' ? 'pull' : 'issue'; return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) }; @@ -267,6 +273,15 @@ function createBackgroundIssueSync({ return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item); } const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); + if (item.kind === 'issue-close') { + return { + url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close', + options: { + method: 'PATCH', + headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId }, + }, + }; + } if (item.kind === 'pull-review') { return { url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review', @@ -332,6 +347,11 @@ function createBackgroundIssueSync({ const request = deliveryRequest(item); try { const delivered = await requestJson(request.url, request.options); + if (item.kind === 'issue-close' && delivered?.state !== 'closed') { + const error = new Error('Issue closure was not confirmed.'); + error.status = 422; + throw error; + } await store.complete(item.id, delivered); const receipt = receiptFor(item, 'confirmed', delivered); return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 02936c0..218dc83 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -120,6 +120,7 @@ let selectedUpdate = null; let updateTrigger = null; let selectedIssue = null; + let selectedIssueOffline = false; let selectedIssueDetail = null; let issueConversation = null; let issueTrigger = null; @@ -781,6 +782,10 @@ warm: warmTodayOffline, announce: message => { qs('#my-work-action-status').textContent = message; }, }); + const closeOfflineIssue = createOfflineIssueClose({ + enqueueDurably: message => authoredOutbox.enqueueDurably(message), + completeToday: (item, options) => completeTodayItem(item, options), + }); function reviewingActiveTodayItem() { return workSession.checkpointed(); @@ -1060,7 +1065,7 @@ function setOfflineDetailControls(kind) { const selectors = kind === 'issue' ? [ - '#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff', + '#edit-issue-content', '#release-issue', '#load-issue-handoff', '#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date', '#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date', '#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments', @@ -1250,6 +1255,7 @@ const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; const isUnfiled = item.kind === 'unfiled-issue'; const reviewOutbox = item.outbox_kind === 'pull-review'; + const closureOutbox = item.outbox_kind === 'issue-close'; const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now'; const outboxActions = item.quarantined ? '' + @@ -1266,7 +1272,8 @@ '' + '' : item.kind === 'authored-outbox' && !reviewOutbox ? - '' + + '' + '' + '' : reviewOutbox ? @@ -1686,6 +1693,7 @@ qs('#issue-planning').inert = false; qs('#issue-handoff').inert = false; selectedIssue = item; + selectedIssueOffline = Boolean(offlineDetail); selectedIssueDetail = null; issueConversation = null; issueTrigger = trigger; @@ -1728,7 +1736,9 @@ qs('#issue-edit-form').hidden = true; qs('#issue-edit-status').textContent = ''; qs('#close-issue').disabled = false; - qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue'; + qs('#close-issue').textContent = offlineDetail ? + (workSession.active() ? 'Queue close & next' : 'Queue issue closure') : + (workSession.active() ? 'Close & next' : 'Close issue'); qs('#close-issue-sheet').focus(); try { const detail = offlineDetail || await issueController.load(item); @@ -1777,6 +1787,7 @@ mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel')); qs('#issue-sheet').classList.remove('open'); selectedIssue = null; + selectedIssueOffline = false; selectedIssueDetail = null; issueConversation = null; if (issueTrigger?.isConnected) issueTrigger.focus(); @@ -3458,6 +3469,26 @@ const closing = selectedIssue; const button = qs('#close-issue'); button.disabled = true; + if (selectedIssueOffline) { + qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…'; + try { + const outcome = await closeOfflineIssue(closing); + closeIssueSheet(); + refreshMyWorkView({ reconcileSession:false }); + if (!outcome.advanced) { + qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.'; + } else if (outcome.admission.background) { + qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.'; + } else { + qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.'; + } + } catch (error) { + qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.'; + button.disabled = false; + button.focus(); + } + return; + } qs('#issue-sheet-status').textContent = 'Closing issue…'; try { await issueController.close(selectedIssue); diff --git a/frontend/drafts.js b/frontend/drafts.js index 1d5dc2f..97f6419 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -140,6 +140,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat .map(item => { const isUpdate = item.kind === 'update-reply'; const isReview = item.kind === 'pull-review'; + const isClosure = item.kind === 'issue-close'; const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue'); const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number; const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment => @@ -158,8 +159,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat kind: 'authored-outbox', status: item.status === 'attention' ? 'attention' : 'queued', label: item.deliveryState === 'uncertain' ? 'Verify delivery' : - (item.status === 'attention' ? 'Needs attention' : - (isReview ? 'Queued review' : 'Queued message')), + (item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') : + (isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))), delivery_state: item.deliveryState, repository: isUpdate ? '' : item.repository, title: target, diff --git a/frontend/index.html b/frontend/index.html index 43f5ace..585ad8d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -594,6 +594,7 @@ + diff --git a/frontend/offline-issue-close.js b/frontend/offline-issue-close.js new file mode 100644 index 0000000..b4c2749 --- /dev/null +++ b/frontend/offline-issue-close.js @@ -0,0 +1,19 @@ +function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () => + globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) { + return async function closeOfflineIssue(item) { + const admission = await enqueueDurably({ + kind: 'issue-close', + repository: String(item.repository || ''), + number: Number(item.number || 0), + body: '', + operationId: String(createOperationId()).slice(0, 128), + }); + const advanced = Boolean(completeToday(item, { + successMessage: 'Issue closure queued. Next Today item opened.', + failureMessage: 'Issue closure queued, but Today still needs completion.', + })); + return { admission, advanced }; + }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index b1308ac..6203cdd 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-v70'; +const CACHE = 'stackchain-dashboard-shell-v71'; 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; @@ -21,6 +21,7 @@ const SHELL = [ BASE + 'static/outbox-coordinator.js', BASE + 'static/issue-outbox.js', BASE + 'static/authored-outbox.js', + BASE + 'static/offline-issue-close.js', BASE + 'static/notification-read-outbox.js', BASE + 'static/offline-work.js', BASE + 'static/offline-today.js', diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py index ea3f408..91fd1f0 100644 --- a/tests/test_authored_outbox.py +++ b/tests/test_authored_outbox.py @@ -126,6 +126,36 @@ if (queued) {{ assert output["progress"] is None +def test_authored_outbox_persists_and_delivers_idempotent_issue_closure(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); const calls = []; +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const outbox = createAuthoredOutbox({{ + storage, getOwnerLogin:()=> 'timmy', + fetchJson: async (url, options) => {{ calls.push({{url,options}}); return {{number:27,state:'closed'}}; }}, +}}); +const queued = outbox.enqueue({{ + kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op' +}}); +outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{ + queued,calls,result,remaining:outbox.list() +}}))); +""" + output = run_node(script) + + assert output["queued"]["kind"] == "issue-close" + assert output["calls"] == [{ + "url": "api/v1/repos/stackchain/dashboard/issues/27/close", + "options": { + "method": "PATCH", + "headers": {"Accept": "application/json", "Idempotency-Key": "close-op"}, + }, + }] + assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}] + assert output["remaining"] == [] + + def test_authored_outbox_classifies_failures_and_continues_past_attention_items(): script = f""" const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 5e2cd7c..dd5454f 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -236,6 +236,36 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo }] +def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt(): + authored = { + "id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued", + "kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "", + } + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +let queued = {json.dumps(authored)}; const calls=[]; +const store = {{ + claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null, + complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0, +}}; +const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}}; +createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}}))); +""" + output = run_node(script) + + mutation = output["calls"][1] + assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close" + assert mutation["options"]["method"] == "PATCH" + assert mutation["options"]["headers"] == { + "Accept": "application/json", "Idempotency-Key": "close-op" + } + assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}] + assert output["result"]["receipts"] == [{ + "id": "close-op", "status": "confirmed", "kind": "message", + "route": "#/my-work/issue/stackchain/dashboard/27", + }] + + def test_reconciling_one_outbox_lane_preserves_the_other_lane(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); diff --git a/tests/test_drafts.py b/tests/test_drafts.py index d611333..48d7789 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -142,6 +142,27 @@ process.stdout.write(JSON.stringify(item)); assert output["quarantined"] is False +def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard(): + script = f""" +const createDraftInbox = require({json.dumps(str(DRAFTS))}); +const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{ + id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'', + ownerLogin:'timmy',status:'attention',error:'Closure rejected',queuedAt:200 +}}]}})]]); +const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0]; +process.stdout.write(JSON.stringify(item)); +""" + output = run_node(script) + + assert output["label"] == "Issue closure needs attention" + assert output["outbox_kind"] == "issue-close" + assert output["title"] == "stackchain/dashboard#27" + assert output["preview"] == "Closure rejected" + assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27} + assert output["quarantined"] is False + + def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation(): script = f""" const createDraftInbox = require({json.dumps(str(DRAFTS))}); @@ -218,6 +239,8 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): assert 'createDraftInbox({ storage: localStorage' in html assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html assert "Verified not posted — retry" in html + assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html + assert "closureOutbox ? 'Open issue' : 'Open message'" in html assert "payload.detail?.message" in html assert "error.code = payload.detail?.code" in html diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 71cb273..399c9c0 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-v70" in source + assert "stackchain-dashboard-shell-v71" 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 4b7cf39..0131153 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-v70" in worker + assert "stackchain-dashboard-shell-v71" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 76e788c..013bb5a 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-v70" in worker + assert "stackchain-dashboard-shell-v71" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index c14b3d1..db29a77 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1672,7 +1672,8 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next(): html = await dashboard() - assert "qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';" in html + assert "workSession.active() ? 'Close & next' : 'Close issue'" in html + assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split( "qs('#close-pull-sheet').addEventListener", 1 )[0] diff --git a/tests/test_offline_issue_close.py b/tests/test_offline_issue_close.py new file mode 100644 index 0000000..9fefde7 --- /dev/null +++ b/tests/test_offline_issue_close.py @@ -0,0 +1,35 @@ +import json +import subprocess +from pathlib import Path + + +OFFLINE_CLOSE = Path(__file__).parents[1] / "frontend" / "offline-issue-close.js" + + +def run_node(script: str): + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def test_offline_issue_close_waits_for_durable_admission_before_advancing_today(): + script = f""" +const createOfflineIssueClose = require({json.dumps(str(OFFLINE_CLOSE))}); +const events=[]; let release; +const gate=new Promise(resolve=>release=resolve); +const close=createOfflineIssueClose({{ + enqueueDurably:async message=>{{events.push('admit');await gate;events.push('durable');return {{item:message,background:true}};}}, + completeToday:item=>{{events.push('complete:' + item.number);return true;}}, +}}); +const item={{kind:'issue',repository:'stackchain/dashboard',number:27}}; +const pending=close(item).then(result=>{{events.push('resolved');return result;}}); +Promise.resolve().then(async()=>{{ + const before=events.slice();release();const result=await pending; + process.stdout.write(JSON.stringify({{before,events,result}})); +}}); +""" + output = run_node(script) + + assert output["before"] == ["admit"] + assert output["events"] == ["admit", "durable", "complete:27", "resolved"] + assert output["result"]["advanced"] is True + assert output["result"]["admission"]["background"] is True diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index e2b970b..8a16f47 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -301,3 +301,22 @@ async def test_offline_today_review_queues_durably_before_completing_and_advanci assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler assert "if (!advanced)" in handler assert "Review queued, but Today still needs completion." in handler + + +@pytest.mark.anyio +async def test_offline_today_issue_queues_closure_before_completing_and_advancing(): + html = await dashboard() + + assert '' in html + assert "const closeOfflineIssue = createOfflineIssueClose({" in html + assert "enqueueDurably: message => authoredOutbox.enqueueDurably(message)" in html + assert "completeToday: (item, options) => completeTodayItem(item, options)" in html + assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html + handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split( + "qs('#close-pull-sheet').addEventListener", 1 + )[0] + admission = handler.index("await closeOfflineIssue(closing)") + sheet_close = handler.index("closeIssueSheet()") + assert admission < sheet_close + assert "Issue closure queued for reconnect." in handler + assert "The issue remains in Today; retry." in handler diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index b4b6ee4..45ec1f5 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -168,6 +168,6 @@ 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-v70" in source + assert "stackchain-dashboard-shell-v71" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 0ca9c9e..3f14e4b 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v70" in source + assert "stackchain-dashboard-shell-v71" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v70" in source + assert "stackchain-dashboard-shell-v71" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -139,7 +139,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-v70" in source + assert "stackchain-dashboard-shell-v71" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -147,14 +147,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-v70" in source + assert "stackchain-dashboard-shell-v71" 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-v70" in source + assert "stackchain-dashboard-shell-v71" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -163,21 +163,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-v70" in source + assert "stackchain-dashboard-shell-v71" 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-v70" in source + assert "stackchain-dashboard-shell-v71" 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-v70" in source + assert "stackchain-dashboard-shell-v71" in source assert "BASE + 'static/update-ownership.js'" in source @@ -358,7 +358,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-v70" in source + assert "stackchain-dashboard-shell-v71" in source assert "BASE + 'static/queue-today.js'" in source @@ -389,6 +389,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/outbox-coordinator.js", "/dashboard/static/issue-outbox.js", "/dashboard/static/authored-outbox.js", + "/dashboard/static/offline-issue-close.js", "/dashboard/static/notification-read-outbox.js", "/dashboard/static/offline-work.js", "/dashboard/static/offline-today.js", diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 0fd8f3f..13473d0 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-v70" in source + assert "stackchain-dashboard-shell-v71" in source assert "BASE + 'static/today-sync.js'" in source