From 72dcdc6c52e737f4f18cfd27834c146d441f9256 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 16:50:48 +0000 Subject: [PATCH] feat: toggle mobile issue checklists (Closes #905) --- frontend/dashboard.css | 2 + frontend/dashboard.js | 50 +++++++++-------- frontend/issue-sheet.js | 68 ++++++++++++++++++++++- frontend/markdown.js | 42 ++++++++++++-- frontend/service-worker.js | 2 +- tests/test_comment_next.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 55 ++++++++++++++++-- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_my_work.py | 61 +++++++++++++++++++- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 20 +++---- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 16 files changed, 261 insertions(+), 55 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b838d07..af77298 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -639,6 +639,8 @@ textarea { resize: vertical; min-height: 120px; } .markdown-content .task-list { padding-left:0; list-style:none; } .markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; } .markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; } +.markdown-content .task-list-toggle { min-width:44px; min-height:44px; margin:-9px 0 -9px -9px; cursor:pointer; accent-color:#60a5fa; } +.markdown-content.checklist-pending .task-list-toggle { opacity:.65; cursor:wait; } .markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; } @media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } } .create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index efd1452..503ce43 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -3373,20 +3373,6 @@ .map(input => Number(input.value)).filter(Number.isInteger); } - function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) { - const select = qs('#issue-milestone'); - const status = qs('#issue-milestone-status'); - select.innerHTML = ''; - select.innerHTML += milestones.map(milestone => - '' - ).join(''); - const draft = issueController.loadMilestoneDraft(item); - select.value = String(draft ?? confirmedMilestone?.id ?? ''); - select.disabled = false; - qs('#save-issue-milestone').disabled = false; - status.textContent = confirmedMilestone ? - 'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.'; - } async function loadIssuePlanning() { const item = selectedIssue; @@ -3399,7 +3385,7 @@ const planning = await planningLoader.open(selectedIssue); if (selectedIssue !== item) return; renderIssueLabelEditor(item, detail.labels || [], planning.labels); - renderIssueMilestoneEditor(item, detail.milestone, planning.milestones); + issueController.renderMilestoneEditor(item, detail.milestone, planning.milestones); } catch (_error) { if (selectedIssue !== item) return; qs('#issue-label-status').textContent = 'Labels could not be loaded.'; @@ -3408,6 +3394,23 @@ } } + function renderIssueBody(detail) { + issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline && + !issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at); + } + + function applyIssueContent(editing, detail, confirmed) { + const merged = issueController.mergeContent( + lastContextSnapshot, editing, detail, confirmed, buildMyWork.replaceIssueContent + ); + lastContextSnapshot = merged.snapshot; + selectedIssue = merged.item; + selectedIssueDetail = merged.detail; + qs('#issue-sheet-title').textContent = confirmed.title; + renderIssueBody(selectedIssueDetail); + if (lastContextSnapshot) paintMyWork(lastContextSnapshot); + } + async function openIssueSheet(item, trigger, offlineDetail = null) { if (!item) return; const readOnly = issueController.readOnly(item); @@ -3493,7 +3496,7 @@ renderPlanIssueDependencies(detail); issueConversation = issueController.conversation(item, detail.conversation); qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue'; - qs('#issue-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.'); + renderIssueBody(detail); qs('#issue-labels').innerHTML = (detail.labels || []).map(label => '' + escapeHtml(label) + '' ).join(' '); @@ -5593,6 +5596,12 @@ qs('#retry-issue-load').addEventListener('click', () => { if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger); }); + issueController.bindTaskToggles({ + container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'), + current:()=>({item:selectedIssue,detail:selectedIssueDetail}), + confirmed:applyIssueContent, + restore:renderIssueBody, + }); qs('#issue-planning').addEventListener('toggle', event => { if (event.currentTarget.open) loadIssuePlanning(); }); @@ -5653,14 +5662,7 @@ qs('#issue-edit-status').textContent = 'Saving issue…'; try { const confirmed = await issueController.updateContent(editing, draft); - lastContextSnapshot = buildMyWork.replaceIssueContent( - lastContextSnapshot, editing.repository, editing.number, confirmed - ); - selectedIssue = { ...editing, ...confirmed, key: editing.key }; - selectedIssueDetail = { ...selectedIssueDetail, ...confirmed }; - qs('#issue-sheet-title').textContent = confirmed.title; - qs('#issue-sheet-body').innerHTML = renderMarkdown(confirmed.body || 'No description provided.'); - paintMyWork(lastContextSnapshot); + applyIssueContent(editing, selectedIssueDetail, confirmed); if (issueEditHistoryActive) history.back(); else qs('#issue-edit-form').hidden = true; qs('#issue-sheet-status').textContent = 'Issue saved.'; diff --git a/frontend/issue-sheet.js b/frontend/issue-sheet.js index e5c45b9..208297c 100644 --- a/frontend/issue-sheet.js +++ b/frontend/issue-sheet.js @@ -18,7 +18,13 @@ function createPlanningLoader({ loadLabels, loadMilestones }) { }; } -function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { +function escapeOptionHtml(value) { + return String(value || '').replace(/[&<>"']/g, character => ({ + '&':'&', '<':'<', '>':'>', '"':'"', "'":''', + })[character]); +} + +function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) { let commentRequest = null; let closeRequest = null; let releaseRequest = null; @@ -138,6 +144,66 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global }).finally(() => { editRequest = null; }); return editRequest; }, + toggleTask(item, detail, taskIndex, checked) { + if (typeof toggleTask !== 'function') return Promise.reject(new Error('Checklist updates are unavailable.')); + return this.updateContent(item, { + title: detail.title, + body: toggleTask(detail.body, taskIndex, checked), + expectedUpdatedAt: detail.updated_at, + }); + }, + bindTaskToggles({ container, status, retry, current, confirmed, restore }) { + container.addEventListener('change', async event => { + const control = event.target.closest('input.task-list-toggle'); + const state = current(); + if (!control || !state?.item || !state?.detail?.updated_at) return; + container.classList.add('checklist-pending'); + container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; }); + status.textContent = 'Updating checklist…'; + try { + const result = await this.toggleTask( + state.item, state.detail, Number(control.dataset.taskIndex), control.checked + ); + const latest = current(); + if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) { + confirmed(state.item, state.detail, result); + status.textContent = 'Checklist updated.'; + } + } catch (error) { + const latest = current(); + if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) { + restore(state.detail); + status.textContent = error.message + ' Checklist was not changed; reload latest or use Edit issue.'; + retry.hidden = false; + } + } + }); + }, + renderTasks(container, detail, interactive) { + container.classList.remove('checklist-pending'); + container.innerHTML = renderMarkdown( + detail.body || 'No description provided.', { interactiveTasks: Boolean(interactive) } + ); + }, + mergeContent(snapshot, item, detail, confirmed, replace) { + return { + snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot, + item: { ...item, ...confirmed, key:item.key }, + detail: { ...detail, ...confirmed }, + }; + }, + renderMilestoneEditor(item, confirmed, milestones, document = globalThis.document) { + const select = document.querySelector('#issue-milestone'); + const status = document.querySelector('#issue-milestone-status'); + select.innerHTML = '' + milestones.map(milestone => + '' + ).join(''); + select.value = String(this.loadMilestoneDraft(item) ?? confirmed?.id ?? ''); + select.disabled = false; + document.querySelector('#save-issue-milestone').disabled = false; + status.textContent = confirmed ? 'Planned for ' + confirmed.title + '.' : 'No milestone set.'; + }, + loadDueDateDraft(item) { try { const raw = storage?.getItem(dueDateDraftKey(item)); diff --git a/frontend/markdown.js b/frontend/markdown.js index 24d6c07..d095cca 100644 --- a/frontend/markdown.js +++ b/frontend/markdown.js @@ -32,23 +32,28 @@ return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]); } - function renderList(lines) { + function renderList(lines, options, firstTaskIndex) { const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line)); - const items = lines.map(line => { + const items = lines.map((line, offset) => { let body = line.replace(/^[-*+]\s+/, ''); if (!taskList) return '
  • ' + renderInline(body) + '
  • '; const checked = /^\[[xX]\]\s+/.test(body); body = body.replace(/^\[[ xX]\]\s+/, ''); - return '
  • ' + renderInline(body) + '
  • '; }).join(''); return '' + items + ''; } - return function renderMarkdown(raw) { + function renderMarkdown(raw, options = {}) { const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n'); const output = []; let index = 0; + let taskIndex = 0; while (index < lines.length) { const line = lines[index]; if (!line.trim()) { @@ -90,7 +95,10 @@ list.push(lines[index]); index += 1; } - output.push(renderList(list)); + output.push(renderList(list, options, taskIndex)); + if (list.every(candidate => /^[-*+]\s+\[[ xX]\]\s+/.test(candidate))) { + taskIndex += list.length; + } continue; } const paragraph = []; @@ -103,5 +111,29 @@ output.push('

    ' + renderInline(paragraph.join('\n')).replace(/\n/g, '
    ') + '

    '); } return output.join(''); + } + + renderMarkdown.toggleTask = function toggleTask(raw, targetIndex, checked) { + const parts = String(raw || '').split(/(\r\n|\n|\r)/); + let fenced = false; + let taskIndex = 0; + for (let index = 0; index < parts.length; index += 2) { + const line = parts[index]; + if (/^\s*```/.test(line)) { + fenced = !fenced; + continue; + } + if (fenced) continue; + const task = line.match(/^([-*+]\s+\[)([ xX])(\])/); + if (!task) continue; + if (taskIndex === Number(targetIndex)) { + parts[index] = task[1] + (checked ? 'x' : ' ') + task[3] + line.slice(task[0].length); + return parts.join(''); + } + taskIndex += 1; + } + return parts.join(''); }; + + return renderMarkdown; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f5eef15..808f0a0 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ 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-v106'; +const CACHE = 'stackchain-dashboard-shell-v107'; 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; diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index e7838bd..ceed78f 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v106" in worker + assert "stackchain-dashboard-shell-v107" in worker diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index ea15a8f..537a336 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-v106" in source + assert "stackchain-dashboard-shell-v107" 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 8ea3b99..f7981ec 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -10,10 +10,11 @@ FRONTEND = Path(__file__).parent.parent / "frontend" RENDERER = FRONTEND / "markdown.js" -def render_markdown(payload): +def render_markdown(payload, options=None): + options = options or {} script = ( f"const render = require({json.dumps(str(RENDERER))});" - f"process.stdout.write(render({json.dumps(payload)}));" + f"process.stdout.write(render({json.dumps(payload)}, {json.dumps(options)}));" ) return subprocess.run( ["node", "-e", script], @@ -23,6 +24,22 @@ def render_markdown(payload): ).stdout +def toggle_task(payload, task_index, checked): + script = ( + f"const render = require({json.dumps(str(RENDERER))});" + f"process.stdout.write(JSON.stringify(render.toggleTask({json.dumps(payload)}, " + f"{task_index}, {json.dumps(checked)})));" + ) + return json.loads( + subprocess.run( + ["node", "-e", script], + check=True, + capture_output=True, + text=True, + ).stdout + ) + + class ScriptSourceParser(HTMLParser): def __init__(self): super().__init__() @@ -81,6 +98,34 @@ def test_markdown_renderer_preserves_mobile_reading_structure(): ) +def test_interactive_markdown_tasks_expose_accessible_source_indices(): + rendered = render_markdown( + "- [ ] Verify production\n- [x] Notify support", + {"interactiveTasks": True}, + ) + + assert rendered == ( + '' + ) + + +def test_interactive_task_toggle_changes_exact_source_marker_only(): + body = ( + "```md\r\n- [ ] example only\r\n```\r\n" + "- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [ ] Duplicate\r\n" + ) + + assert toggle_task(body, 1, True) == ( + "```md\r\n- [ ] example only\r\n```\r\n" + "- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [x] Duplicate\r\n" + ) + + def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert(): rendered = render_markdown( "[Forge](https://forge.example/work?q=1&safe=yes) " @@ -110,12 +155,12 @@ def test_all_read_only_work_bodies_use_the_shared_markdown_renderer(): "renderMarkdown(detail.body || 'No description provided.')", "renderMarkdown(item.body || 'No description provided.')", "renderMarkdown(review.body)", - "renderMarkdown(confirmed.body || 'No description provided.')", + "issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline", ) for path in expected_paths: assert path in dashboard - assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 4 + assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 3 def test_markdown_work_bodies_are_mobile_safe_block_containers(): @@ -137,4 +182,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-v106" in worker + assert "stackchain-dashboard-shell-v107" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 5158cac..ff2f75c 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v106" in worker + assert "stackchain-dashboard-shell-v107" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 719db40..3787b89 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v106" in worker + assert "stackchain-dashboard-shell-v107" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 575a692..5a87b66 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -283,4 +283,4 @@ 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-v106" in service_worker + assert "stackchain-dashboard-shell-v107" in service_worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index b970614..7afae15 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -2201,6 +2201,65 @@ async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_co assert "overflow-wrap:anywhere" in html +@pytest.mark.anyio +async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery(): + html = await dashboard() + controller = ISSUE_SHEET.read_text() + + assert "issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline" in html + assert "issueController.bindTaskToggles({" in html + assert "container.addEventListener('change', async event =>" in controller + assert "event.target.closest('input.task-list-toggle')" in controller + assert "state.item, state.detail, Number(control.dataset.taskIndex), control.checked" in controller + assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail})" in html + assert "buildMyWork.replaceIssueContent" in html + assert "status.textContent = 'Checklist updated.'" in controller + assert "restore(state.detail)" in controller + assert "error.message + ' Checklist was not changed; reload latest or use Edit issue.'" in controller + assert '.task-list-toggle { min-width:44px; min-height:44px;' in html + assert '.checklist-pending .task-list-toggle { opacity:.65;' in html + + +def test_issue_checklist_toggle_submits_exact_revision_checked_body_once(): + script = f""" +const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); +let calls = []; +let finish; +const controller = createIssueSheet({{ + storage:null, + toggleTask:(body, index, checked) => body.replace(index === 1 ? '[X]' : '[ ]', checked ? '[x]' : '[ ]'), + fetchJson:(url, options) => {{ + calls.push({{url, options}}); + return new Promise(resolve => {{ finish = resolve; }}); + }}, +}}); +const item = {{repository:'stackchain/api', number:17}}; +const detail = {{title:'Release', body:'- [ ] Build\\n- [X] Ship', updated_at:'2026-08-15T10:00:00Z'}}; +const first = controller.toggleTask(item, detail, 1, true); +const duplicate = controller.toggleTask(item, detail, 1, true); +finish({{number:17,title:'Release',body:'- [ ] Build\\n- [x] Ship',updated_at:'2026-08-15T10:01:00Z'}}); +Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{ + calls:calls.map(call => ({{url:call.url,body:JSON.parse(call.options.body)}})), + same:first === duplicate, + confirmed:results[0], +}}))); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["same"] is True + assert output["calls"] == [{ + "url": "api/v1/repos/stackchain/api/issues/17/content", + "body": { + "title": "Release", + "body": "- [ ] Build\n- [x] Ship", + "expected_updated_at": "2026-08-15T10:00:00Z", + }, + }] + assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z" + + def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed(): script = f""" const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); @@ -4863,7 +4922,7 @@ async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor(): assert 'id="retry-issue-load" type="button" hidden>Reload latest issue' in html assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html - assert 'buildMyWork.replaceIssueContent(' in html + assert 'buildMyWork.replaceIssueContent' in html @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index a356af6..9ac4cd6 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v106" in source + assert "stackchain-dashboard-shell-v107" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index bde5014..11f2496 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -155,7 +155,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v106" in source + assert "stackchain-dashboard-shell-v107" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -164,14 +164,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v106" in source + assert "stackchain-dashboard-shell-v107" 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-v106" in source + assert "stackchain-dashboard-shell-v107" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -179,7 +179,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-v106" in source + assert "stackchain-dashboard-shell-v107" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -187,14 +187,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-v106" in source + assert "stackchain-dashboard-shell-v107" 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-v106" in source + assert "stackchain-dashboard-shell-v107" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -203,21 +203,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-v106" in source + assert "stackchain-dashboard-shell-v107" 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-v106" in source + assert "stackchain-dashboard-shell-v107" 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-v106" in source + assert "stackchain-dashboard-shell-v107" in source assert "BASE + 'static/update-ownership.js'" in source @@ -881,7 +881,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-v106" in source + assert "stackchain-dashboard-shell-v107" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index e3e5ab1..ce5a8bb 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v106';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v107';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 6f98a9b..974e1c8 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); 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-v106" in source + assert "stackchain-dashboard-shell-v107" in source assert "BASE + 'static/today-sync.js'" in source