Resume Human Gate evidence reviews after mobile artifact handoffs #1436
|
|
@ -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, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
|
||||
return;
|
||||
}
|
||||
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
|
||||
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
|
||||
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>'
|
||||
).join('');
|
||||
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" rel="noreferrer">' + 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 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) + '" 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 history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
|
||||
setHtml(nodes.detail,
|
||||
|
|
@ -73,11 +111,11 @@ function createHumanGates(options = {}) {
|
|||
'<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>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="artifacts_reviewed"> Artifacts reviewed</label>' +
|
||||
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"> Provenance reviewed</label>' +
|
||||
'<label>Hold reason<textarea data-gate-reason></textarea></label>' +
|
||||
'<label>Override reason<textarea data-gate-override></textarea></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"' + checked('artifacts_reviewed') + '> Artifacts reviewed</label>' +
|
||||
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
|
||||
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
|
||||
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
|
||||
'<div><button type="button" data-gate-decision="release">Release & next</button>' +
|
||||
'<button type="button" data-gate-decision="hold">Hold & next</button></div></article>'
|
||||
);
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user