feat: queue offline issue closure and continue Today (#427)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-09 21:26:23 +00:00
parent ad27db6846
commit c451cd0bcb
19 changed files with 260 additions and 37 deletions

View File

@ -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';
}
@ -154,6 +157,13 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
throw error;
}
result = delivery.message;
} else {
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,
@ -171,6 +181,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
body: JSON.stringify(body),
});
}
}
if (!result) return { blocked: true };
clearConfirmedReviewState(item);
discard(item.id);

View File

@ -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 };

View File

@ -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 ?
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
@ -1266,7 +1272,8 @@
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
item.kind === 'authored-outbox' && !reviewOutbox ?
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
(closureOutbox ? 'Open issue' : 'Open message') + '</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
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);

View File

@ -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,

View File

@ -594,6 +594,7 @@
<script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/offline-issue-close.js"></script>
<script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>

View File

@ -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;

View File

@ -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',

View File

@ -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))});

View File

@ -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))});

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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 '<script src="static/offline-issue-close.js"></script>' 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

View File

@ -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

View File

@ -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",

View File

@ -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