diff --git a/frontend/human-gates.js b/frontend/human-gates.js index 5c532f9..26295d1 100644 --- a/frontend/human-gates.js +++ b/frontend/human-gates.js @@ -20,6 +20,8 @@ function createHumanGates(options = {}) { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[character]); const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase(); + const progressKey = item => 'stackchain.human-gate-review.v1:' + + String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision; const setText = (node, value) => { if (node) node.textContent = value; }; const setHtml = (node, value) => { if (node) node.innerHTML = value; }; const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state); @@ -38,6 +40,40 @@ function createHumanGates(options = {}) { try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {} } + function restoreProgress(item) { + if (!getLogin() || !item) return null; + try { + const value = JSON.parse(storage.getItem(progressKey(item)) || 'null'); + if (!value || value.gate_id !== item.id || value.revision !== item.revision) return null; + return value; + } catch (_) { return null; } + } + + function saveProgress(values = {}) { + const item = current(); + if (!getLogin() || !item) return false; + const checklist = values.checklist || {}; + const progress = { + gate_id:item.id, revision:item.revision, + checklist:Object.fromEntries(['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].map(key => [key, checklist[key] === true])), + reason:String(values.reason || ''), override_reason:String(values.override_reason || ''), + }; + try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; } + } + + function captureProgress() { + if (!nodes.detail?.querySelectorAll) return false; + const checklist = Object.fromEntries(Array.from(nodes.detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked])); + return saveProgress({ + checklist, + reason:nodes.detail.querySelector?.('[data-gate-reason]')?.value || '', + override_reason:nodes.detail.querySelector?.('[data-gate-override]')?.value || '', + }); + } + + nodes.detail?.addEventListener?.('input', captureProgress); + nodes.detail?.addEventListener?.('change', captureProgress); + function render() { setText(nodes.count, String(queue.pending_count)); if (!queue.pending_count) { @@ -58,11 +94,13 @@ function createHumanGates(options = {}) { setHtml(nodes.detail, '
Inbox zeroFixed review snapshot complete.
'); return; } + const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''}; + const checked = key => progress.checklist?.[key] === true ? ' checked' : ''; const checks = (item.checks || []).map(check => '
  • ' + escape(check.name) + ' · ' + escape(check.state) + (check.required ? ' · required' : '') + '
  • ' ).join(''); - const artifacts = (item.artifacts || []).map(artifact => '
  • ' + escape(artifact.name) + '
  • ').join(''); - const links = (item.links || []).map(link => '
  • ' + escape(link.label) + '
  • ').join(''); + const artifacts = (item.artifacts || []).map(artifact => '
  • ' + escape(artifact.name) + '
  • ').join(''); + const links = (item.links || []).map(link => '
  • ' + escape(link.label) + '
  • ').join(''); const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '
  • ' + escape(key) + ' · ' + escape(value) + '
  • ').join(''); const history = (item.history || []).map(event => '
  • ' + escape(event.action) + ' · ' + escape(event.at) + '
  • ').join(''); setHtml(nodes.detail, @@ -73,11 +111,11 @@ function createHumanGates(options = {}) { '

    Artifacts

    Links

    ' + '

    Checks

    Provenance

    ' + '

    History

    ' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + '
    ' + '
    ' ); @@ -183,6 +221,7 @@ function createHumanGates(options = {}) { body: JSON.stringify(payload), }); decisionKeys.delete(decisionKey.operation); + try { storage.removeItem?.(progressKey(item)); } catch (_) {} queue.items = queue.items.filter(candidate => candidate.id !== item.id); queue.pending_count = Math.max(0, queue.pending_count - 1); save(queue); render(); @@ -220,7 +259,7 @@ function createHumanGates(options = {}) { } return { - load, open, reviewNext, select, decideAndNext, current, + load, open, reviewNext, select, decideAndNext, current, saveProgress, setOnChange(callback) { onChange = callback; }, restoreCached: restore, snapshot: () => JSON.parse(JSON.stringify(queue)), diff --git a/frontend/service-worker.js b/frontend/service-worker.js index cb52202..587323b 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-v145'; +const CACHE = 'stackchain-dashboard-shell-v146'; 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/e2e/test_human_gates_reopen_release.py b/tests/e2e/test_human_gates_reopen_release.py index f112450..426e43c 100644 --- a/tests/e2e/test_human_gates_reopen_release.py +++ b/tests/e2e/test_human_gates_reopen_release.py @@ -27,6 +27,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( fake_thread = threading.Thread(target=fake.serve_forever, daemon=True) fake_thread.start() current = {"gate": "g1"} + evidence_url = {"value": "/evidence/manifest"} list_requests: list[str] = [] browser_errors: list[str] = [] @@ -39,7 +40,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( "revision": 1, "priority": 5, "checks": [], - "artifacts": [], + "artifacts": [{"name": "Signed manifest", "url": evidence_url["value"]}], "links": [], "provenance": {}, "history": [], @@ -49,6 +50,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( with release_server( archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}" ) as origin, sync_playwright() as playwright: + evidence_url["value"] = origin + "/evidence/manifest" browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) context = browser.new_context( viewport={"width": width, "height": height}, ignore_https_errors=True @@ -58,7 +60,9 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( def human_gates_route(route): path = route.request.url.split("?", 1)[0] - if path.endswith("/api/v1/human-gates"): + if route.request.method == "POST": + payload = {"receipt_id": "receipt-1", "state": "released"} + elif path.endswith("/api/v1/human-gates"): list_requests.append(current["gate"]) payload = {"pending_count": 1, "items": [gate(current["gate"])]} else: @@ -66,6 +70,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( route.fulfill(status=200, content_type="application/json", body=json.dumps(payload)) page.route("**/api/v1/human-gates**", human_gates_route) + page.route("**/api/v1/human-gates/**", human_gates_route) page.goto(origin + "/", wait_until="networkidle") page.locator('input[name="device_label"]').fill("Human Gates release phone") page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) @@ -75,6 +80,36 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot( page.evaluate("document.querySelector('#open-human-gates').click()") expect(page.locator("#human-gates")).to_be_visible() expect(page.locator("#human-gate-detail")).to_contain_text("First candidate") + + page.locator('[data-gate-checklist="exact_hash"]').check() + page.locator('[data-gate-checklist="artifacts_reviewed"]').check() + page.locator('[data-gate-checklist="provenance_reviewed"]').check() + page.locator("[data-gate-reason]").fill("Awaiting final approval") + with page.expect_popup() as popup_info: + page.get_by_role("link", name="Signed manifest").click() + popup = popup_info.value + popup.wait_for_load_state("domcontentloaded") + assert not page.url.endswith("/evidence/manifest") + expect(page.locator("#human-gates")).to_be_visible() + expect(page.locator("#human-gate-detail")).to_contain_text("First candidate") + popup.close() + + page.reload(wait_until="networkidle") + page.evaluate("document.querySelector('#open-human-gates').click()") + expect(page.locator("#human-gates")).to_be_visible() + expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked() + expect(page.locator('[data-gate-checklist="artifacts_reviewed"]')).to_be_checked() + expect(page.locator('[data-gate-checklist="provenance_reviewed"]')).to_be_checked() + expect(page.locator("[data-gate-reason]")).to_have_value("Awaiting final approval") + progress_key = page.evaluate( + "Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-review.v1:'))" + ) + assert progress_key and progress_key.endswith(":g1:1") + assert page.evaluate("key => localStorage.getItem(key) !== null", progress_key) + page.locator('[data-gate-decision="release"]').click() + expect(page.locator("#human-gates-status")).to_contain_text("Decision saved") + assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key) + page.evaluate("document.querySelector('#close-human-gates').click()") before_reopen = len(list_requests) diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 128d4c7..6d56188 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-v145" in worker + assert "stackchain-dashboard-shell-v146" in worker diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index bc91419..1211c57 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{ assert ".following-disposition-mode" in css assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard - assert "stackchain-dashboard-shell-v145" in service_worker + assert "stackchain-dashboard-shell-v146" in service_worker def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): diff --git a/tests/test_human_gates_frontend.py b/tests/test_human_gates_frontend.py index 0ff8ad0..8d8300d 100644 --- a/tests/test_human_gates_frontend.py +++ b/tests/test_human_gates_frontend.py @@ -196,10 +196,84 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:() """) assert "stackchain/dashboard" in output["html"] assert "https://forge.example/pull/1" in output["html"] + assert output["html"].count('target="_blank"') == 2 + assert output["html"].count('rel="noreferrer noopener"') == 2 assert "bot" in output["html"] assert "intake" in output["html"] +def test_unfinished_review_restores_after_reload_for_same_account_gate_and_revision(): + output = run_node(r""" +const values=new Map(); +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const item={id:'g1',title:'Candidate',project:'stackchain/dashboard',candidate_hash:'abc123',revision:4,checks:[]}; +const make=()=>{ + const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:'',addEventListener(){}}}; + return {nodes,gates:createHumanGates({ + storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''}, + fetchJson:async path=>path.endsWith('/g1')?item:{pending_count:1,items:[item]}, + })}; +}; +(async()=>{ + const first=make(); await first.gates.load(); first.gates.reviewNext(); + first.gates.saveProgress({ + checklist:{exact_hash:true,artifacts_reviewed:false,provenance_reviewed:true}, + reason:'Need the signed manifest',override_reason:'Approved exception', + }); + const reloaded=make(); await reloaded.gates.load(); reloaded.gates.reviewNext(); + await new Promise(resolve=>setTimeout(resolve,0)); + process.stdout.write(JSON.stringify({keys:[...values.keys()],html:reloaded.nodes.detail.innerHTML})); +})(); +""") + assert "stackchain.human-gate-review.v1:7:timmy:g1:4" in output["keys"] + assert 'data-gate-checklist="exact_hash" checked' in output["html"] + assert 'data-gate-checklist="artifacts_reviewed" checked' not in output["html"] + assert 'data-gate-checklist="provenance_reviewed" checked' in output["html"] + assert "Need the signed manifest" in output["html"] + assert "Approved exception" in output["html"] + + +def test_review_form_changes_are_saved_without_a_decision_tap(): + output = run_node(r""" +const values=new Map(), listeners={}; +const inputs=[ + {dataset:{gateChecklist:'exact_hash'},checked:true}, + {dataset:{gateChecklist:'artifacts_reviewed'},checked:true}, + {dataset:{gateChecklist:'provenance_reviewed'},checked:false}, +]; +const reason={value:'Waiting for mobile evidence'}, override={value:'Temporary exception'}; +const detail={ + innerHTML:'', addEventListener:(name,listener)=>listeners[name]=listener, + querySelectorAll:()=>inputs, + querySelector:selector=>selector==='[data-gate-reason]'?reason:override, +}; +const item={id:'g1',title:'Candidate',candidate_hash:'abc',revision:2,checks:[]}; +const gates=createHumanGates({ + storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}, + getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true, + nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''}, + fetchJson:async()=>({pending_count:1,items:[item]}), +}); +(async()=>{ + await gates.load(); gates.reviewNext(); + listeners.input(); + const saved=JSON.parse(values.get('stackchain.human-gate-review.v1:7:timmy:g1:2')); + process.stdout.write(JSON.stringify(saved)); +})(); +""") + assert output == { + "gate_id": "g1", + "revision": 2, + "checklist": { + "exact_hash": True, + "artifacts_reviewed": True, + "provenance_reviewed": False, + }, + "reason": "Waiting for mobile evidence", + "override_reason": "Temporary exception", + } + + def test_release_requires_override_for_unmet_required_checks_and_decisions_require_online_identity(): output = run_node(r""" let online=true, login='timmy', posts=0; @@ -259,6 +333,32 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:() assert output["keys"][0] == output["keys"][1] +def test_failed_decision_keeps_review_progress_and_successful_retry_clears_it(): + output = run_node(r""" +const stored=new Map(); let attempts=0; +const storage={getItem:key=>stored.get(key)||null,setItem:(key,value)=>stored.set(key,value),removeItem:key=>stored.delete(key)}; +const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:3,checks:[]}; +const gates=createHumanGates({ + storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true, + nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''}, + fetchJson:async(path,options={})=>{ + if(options.method==='POST') { attempts += 1; if(attempts===1) throw new Error('offline'); return {receipt_id:'r1'}; } + return {pending_count:1,items:[item]}; + }, +}); +(async()=>{ + await gates.load(); gates.reviewNext(); + gates.saveProgress({reason:'Awaiting approval',checklist:{}}); + const key='stackchain.human-gate-review.v1:7:timmy:g1:3'; + try { await gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {} + const retained=stored.has(key); + await gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); + process.stdout.write(JSON.stringify({retained,cleared:!stored.has(key)})); +})(); +""") + assert output == {"retained": True, "cleared": True} + + def test_concurrent_decision_taps_submit_once_and_advance_once(): output = run_node(r""" let posts=0, releasePost; const posted=new Promise(resolve=>releasePost=resolve); @@ -392,7 +492,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired(): assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard assert "counts.gate = queueCounts.gate" in dashboard assert "gate:preparationItems.gate || []" in dashboard - assert "stackchain-dashboard-shell-v145" in WORKER.read_text() + assert "stackchain-dashboard-shell-v146" in WORKER.read_text() def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace(): diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 3f49c6b..8ca6500 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" 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 a23e79f..2aadf40 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v145" in worker + assert "stackchain-dashboard-shell-v146" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 5be6334..8567bc7 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-v145" in worker + assert "stackchain-dashboard-shell-v146" 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 65851ab..108a75d 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v145" in worker + assert "stackchain-dashboard-shell-v146" 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_insights.py b/tests/test_mobile_insights.py index 51cedbd..494c7c6 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v145" in worker + assert "stackchain-dashboard-shell-v146" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 02ab8bc..4856392 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -469,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v145" in service_worker + assert "stackchain-dashboard-shell-v146" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 61340b4..73322c8 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" 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 d6a2345..465f310 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -189,14 +189,14 @@ async function dispatchPush(payload) {{ def test_shared_progressive_snapshot_broker_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/progressive-live-snapshot.js'" in source def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/week-plan.js'" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -226,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -235,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -243,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" 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-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -258,7 +258,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-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -266,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -276,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" 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-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -292,21 +292,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-v145" in source + assert "stackchain-dashboard-shell-v146" 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-v145" in source + assert "stackchain-dashboard-shell-v146" 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-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1361,7 +1361,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-v145" in source + assert "stackchain-dashboard-shell-v146" 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 5b3f564..85bb3af 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-v145';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v146';" 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 aca1272..58babca 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v145" in source + assert "stackchain-dashboard-shell-v146" in source assert "BASE + 'static/today-sync.js'" in source