Merge pull request 'Resume Human Gate evidence reviews after mobile artifact handoffs' (#1436) from timmy/1435-resume-human-gate-review into main
All checks were successful
CI / lint (push) Successful in 4m14s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m40s
CI / release-candidate (push) Successful in 8s

Merge pull request #1436: resume Human Gate evidence reviews
This commit is contained in:
timmy 2026-08-26 15:29:27 +00:00
commit ad15f15e3c
16 changed files with 214 additions and 40 deletions

View File

@ -20,6 +20,8 @@ function createHumanGates(options = {}) {
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[character]); })[character]);
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase(); 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 setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; }; const setHtml = (node, value) => { if (node) node.innerHTML = value; };
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state); 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 (_) {} 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() { function render() {
setText(nodes.count, String(queue.pending_count)); setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) { if (!queue.pending_count) {
@ -58,11 +94,13 @@ function createHumanGates(options = {}) {
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>'); setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
return; return;
} }
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
const checks = (item.checks || []).map(check => const checks = (item.checks || []).map(check =>
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>' '<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
).join(''); ).join('');
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" rel="noreferrer">' + escape(artifact.name) + '</a></li>').join(''); const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" target="_blank" rel="noreferrer noopener">' + escape(artifact.name) + '</a></li>').join('');
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" rel="noreferrer">' + escape(link.label) + '</a></li>').join(''); const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" target="_blank" rel="noreferrer noopener">' + escape(link.label) + '</a></li>').join('');
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join(''); const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join('');
const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join(''); const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
setHtml(nodes.detail, setHtml(nodes.detail,
@ -73,11 +111,11 @@ function createHumanGates(options = {}) {
'<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' + '<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' +
'<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' + '<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' +
'<h4>History</h4><ul>' + history + '</ul>' + '<h4>History</h4><ul>' + history + '</ul>' +
'<label><input type="checkbox" data-gate-checklist="exact_hash"> Exact hash reviewed</label>' + '<label><input type="checkbox" data-gate-checklist="exact_hash"' + checked('exact_hash') + '> Exact hash reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"> Artifacts reviewed</label>' + '<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"' + checked('artifacts_reviewed') + '> Artifacts reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"> Provenance reviewed</label>' + '<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason></textarea></label>' + '<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
'<label>Override reason<textarea data-gate-override></textarea></label>' + '<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
'<div><button type="button" data-gate-decision="release">Release &amp; next</button>' + '<div><button type="button" data-gate-decision="release">Release &amp; next</button>' +
'<button type="button" data-gate-decision="hold">Hold &amp; next</button></div></article>' '<button type="button" data-gate-decision="hold">Hold &amp; next</button></div></article>'
); );
@ -183,6 +221,7 @@ function createHumanGates(options = {}) {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
decisionKeys.delete(decisionKey.operation); decisionKeys.delete(decisionKey.operation);
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id); queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1); queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render(); save(queue); render();
@ -220,7 +259,7 @@ function createHumanGates(options = {}) {
} }
return { return {
load, open, reviewNext, select, decideAndNext, current, load, open, reviewNext, select, decideAndNext, current, saveProgress,
setOnChange(callback) { onChange = callback; }, setOnChange(callback) { onChange = callback; },
restoreCached: restore, restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)), snapshot: () => JSON.parse(JSON.stringify(queue)),

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -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 = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start() fake_thread.start()
current = {"gate": "g1"} current = {"gate": "g1"}
evidence_url = {"value": "/evidence/manifest"}
list_requests: list[str] = [] list_requests: list[str] = []
browser_errors: list[str] = [] browser_errors: list[str] = []
@ -39,7 +40,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
"revision": 1, "revision": 1,
"priority": 5, "priority": 5,
"checks": [], "checks": [],
"artifacts": [], "artifacts": [{"name": "Signed manifest", "url": evidence_url["value"]}],
"links": [], "links": [],
"provenance": {}, "provenance": {},
"history": [], "history": [],
@ -49,6 +50,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
with release_server( with release_server(
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}" archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
) as origin, sync_playwright() as playwright: ) as origin, sync_playwright() as playwright:
evidence_url["value"] = origin + "/evidence/manifest"
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context( context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True 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): def human_gates_route(route):
path = route.request.url.split("?", 1)[0] 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"]) list_requests.append(current["gate"])
payload = {"pending_count": 1, "items": [gate(current["gate"])]} payload = {"pending_count": 1, "items": [gate(current["gate"])]}
else: 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)) 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.route("**/api/v1/human-gates/**", human_gates_route)
page.goto(origin + "/", wait_until="networkidle") page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Human Gates release phone") page.locator('input[name="device_label"]').fill("Human Gates release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) 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()") page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible() expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("First candidate") 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()") page.evaluate("document.querySelector('#close-human-gates').click()")
before_reopen = len(list_requests) before_reopen = len(list_requests)

View File

@ -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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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

View File

@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && 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(): def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -196,10 +196,84 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()
""") """)
assert "stackchain/dashboard" in output["html"] assert "stackchain/dashboard" in output["html"]
assert "https://forge.example/pull/1" 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 "bot" in output["html"]
assert "intake" 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(): def test_release_requires_override_for_unmet_required_checks_and_decisions_require_online_identity():
output = run_node(r""" output = run_node(r"""
let online=true, login='timmy', posts=0; 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] 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(): def test_concurrent_decision_taps_submit_once_and_advance_once():
output = run_node(r""" output = run_node(r"""
let posts=0, releasePost; const posted=new Promise(resolve=>releasePost=resolve); 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 "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
assert "counts.gate = queueCounts.gate" in dashboard assert "counts.gate = queueCounts.gate" in dashboard
assert "gate:preparationItems.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(): def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():

View File

@ -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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/later-sync.js'" in source

View File

@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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

View File

@ -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])) 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 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(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker 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-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css assert "overflow-x:hidden" in css

View File

@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() 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 assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -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 ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker 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 @pytest.mark.anyio

View File

@ -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(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() 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.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell(): def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/progressive-live-snapshot.js'" in source
def test_week_unplan_undo_rolls_the_offline_shell(): def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text() 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/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text() 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(): def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text() 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/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() 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/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.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(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() 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-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.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(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() 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/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() 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/today-completion.js'" in source
assert "BASE + 'static/dashboard.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(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() 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/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/queue-today.js'" in source

View File

@ -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(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() 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 assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/today-sync.js'" in source