fix: authorize queued issue closures explicitly (Closes #483)
This commit is contained in:
parent
cc63fc6499
commit
62a0841eb5
|
|
@ -241,7 +241,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (item.status === 'attention' || item.kind === 'issue-close') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ function createIssueSyncStore({
|
|||
const timestamp = Number(now());
|
||||
const items = await records.getAll();
|
||||
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
|
||||
candidate.kind !== 'issue-close' &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
if (!item) return null;
|
||||
|
|
@ -114,6 +115,7 @@ function createIssueSyncStore({
|
|||
const eligible = (await records.getAll()).filter(candidate =>
|
||||
candidate.recordType !== 'receipt-preference' &&
|
||||
candidate.ownerLogin === ownerLogin &&
|
||||
candidate.kind !== 'issue-close' &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
const lanes = {
|
||||
|
|
|
|||
|
|
@ -1662,9 +1662,12 @@
|
|||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</button>' +
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||||
item.kind === 'authored-outbox' && closureOutbox ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open issue</button>' +
|
||||
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize & close</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
item.kind === 'authored-outbox' && !reviewOutbox ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
||||
(closureOutbox ? 'Open issue' : 'Open message') + '</button>' +
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
reviewOutbox ?
|
||||
|
|
@ -1676,7 +1679,8 @@
|
|||
const state = (isOutbox || isUnfiled) ?
|
||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
|
||||
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' : 'Queued for sync')) + '</span>' +
|
||||
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' :
|
||||
item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '</span>' +
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
|
||||
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
|
||||
|
|
@ -1691,7 +1695,8 @@
|
|||
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
|
||||
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
|
||||
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
|
||||
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong></p></div>' +
|
||||
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong> · ' +
|
||||
'Authorize <strong data-delivery-count="authorization">' + deliveryCenter.counts.authorization + '</strong></p></div>' +
|
||||
'<button id="retry-waiting-deliveries" type="button"' +
|
||||
(deliveryCenter.retryable.length && activeFlushLogin ? '' : ' disabled') + '>Retry waiting</button></section>';
|
||||
const deliveryCards = deliveryCenter.deliveries.length ? deliveryCenter.deliveries.map(renderDraftCard).join('') :
|
||||
|
|
@ -1769,6 +1774,18 @@
|
|||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-authorize').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.outbox_id || !activeFlushLogin) return;
|
||||
button.disabled = true;
|
||||
qs('#my-work-action-status').textContent = 'Fresh authorization required for this exact issue.';
|
||||
const result = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
|
||||
applyAuthoredOutboxResult(result);
|
||||
qs('#my-work-action-status').textContent = result.confirmed?.length ?
|
||||
'Issue closed and queued intent cleared.' : 'Issue closure was not confirmed. The queued intent is still safe.';
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
|
|
|
|||
|
|
@ -160,10 +160,12 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
|||
outbox_id: item.id,
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued'),
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure,
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
|
|
@ -237,12 +239,13 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
|||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' : 'waiting');
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
||||
(item.status === 'authorization' ? 'authorization' : 'waiting'));
|
||||
summary[state] += 1;
|
||||
return summary;
|
||||
}, { waiting:0, sending:0, attention:0 });
|
||||
}, { waiting:0, sending:0, attention:0, authorization:0 });
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v81';
|
||||
const CACHE = 'stackchain-dashboard-shell-v82';
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ if (queued) {{
|
|||
assert output["progress"] is None
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
|
||||
def test_authored_outbox_requires_explicit_retry_to_authorize_issue_closure():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
|
|
@ -162,13 +162,20 @@ const outbox = createAuthoredOutbox({{
|
|||
const queued = outbox.enqueue({{
|
||||
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
|
||||
}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
queued,calls,result,remaining:outbox.list()
|
||||
}})));
|
||||
(async()=>{{
|
||||
const automatic = await outbox.flush('timmy');
|
||||
const awaitingAuthorization = outbox.list();
|
||||
const explicit = await outbox.retry(queued.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
queued,automatic,awaitingAuthorization,explicit,calls,remaining:outbox.list()
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["kind"] == "issue-close"
|
||||
assert output["automatic"]["confirmed"] == []
|
||||
assert output["awaitingAuthorization"][0]["status"] == "queued"
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
|
||||
"options": {
|
||||
|
|
@ -176,10 +183,50 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
|||
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
|
||||
},
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_queued_closure_stays_durable_through_background_skip_then_closes_from_explicit_retry():
|
||||
sync = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
|
||||
script = f"""
|
||||
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const createBackgroundIssueSync=require({json.dumps(str(sync))});
|
||||
const values=new Map();const records=new Map();let tail=Promise.resolve();const calls=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'] || ''}});
|
||||
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
const background=createBackgroundIssueSync({{store,fetchJson}});background.requestSync=async()=>{{}};
|
||||
const outbox=createAuthoredOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',backgroundSync:background}});
|
||||
const admitted=await outbox.enqueueDurably({{kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'}});
|
||||
const automatic=await background.flush();
|
||||
outbox.reconcileBackground(await background.snapshot());
|
||||
const foregroundFlush=await outbox.flush('timmy');
|
||||
const explicit=await outbox.retry(admitted.item.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{automatic,foregroundFlush,explicit,calls,remaining:outbox.list(),snapshot:await background.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["automatic"]["confirmed"] == []
|
||||
assert output["foregroundFlush"]["confirmed"] == []
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/background-identity", "key": ""},
|
||||
{"url": "api/v1/repos/stackchain/dashboard/issues/27/close", "key": "close-op"},
|
||||
]
|
||||
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
assert output["snapshot"][0]["status"] == "sent"
|
||||
|
||||
|
||||
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
|
|||
|
|
@ -372,34 +372,44 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdo
|
|||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
|
||||
authored = {
|
||||
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
|
||||
}
|
||||
def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization():
|
||||
records = [
|
||||
{
|
||||
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
|
||||
},
|
||||
{
|
||||
"id": "comment-op", "operationId": "comment-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-comment", "repository": "stackchain/dashboard", "number": 27, "body": "Still deliver",
|
||||
},
|
||||
]
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let queued = {json.dumps(authored)}; const calls=[];
|
||||
const store = {{
|
||||
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
const records=new Map({json.dumps([[item["id"], item] for item in records])});let tail=Promise.resolve();const calls=[];
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
calls.push({{url,options}});
|
||||
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{calls,result,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
|
||||
assert mutation["options"]["method"] == "PATCH"
|
||||
assert mutation["options"]["headers"] == {
|
||||
"Accept": "application/json", "Idempotency-Key": "close-op"
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "close-op", "status": "confirmed", "kind": "message",
|
||||
"route": "#/my-work/issue/stackchain/dashboard/27",
|
||||
}]
|
||||
mutations = output["calls"][1:]
|
||||
assert [call["url"] for call in mutations] == [
|
||||
"api/v1/repos/stackchain/dashboard/issues/27/comments"
|
||||
]
|
||||
assert output["result"]["confirmed"] == [{"id": 45}]
|
||||
by_id = {item["id"]: item for item in output["snapshot"]}
|
||||
assert by_id["close-op"]["status"] == "queued"
|
||||
assert by_id["comment-op"]["status"] == "sent"
|
||||
|
||||
|
||||
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
||||
|
|
|
|||
|
|
@ -305,4 +305,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
|
|||
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-v81" in worker
|
||||
assert "stackchain-dashboard-shell-v82" in worker
|
||||
|
|
|
|||
|
|
@ -142,25 +142,32 @@ process.stdout.write(JSON.stringify(item));
|
|||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
|
||||
def test_draft_inbox_exposes_queued_issue_closure_as_awaiting_authorization():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
|
||||
ownerLogin:'timmy',status:'attention',error:'Closure rejected',queuedAt:200
|
||||
ownerLogin:'timmy',status:'queued',queuedAt:200
|
||||
}}]}})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
const item=inbox.list()[0];
|
||||
process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
item = output["item"]
|
||||
|
||||
assert output["label"] == "Issue closure needs attention"
|
||||
assert output["outbox_kind"] == "issue-close"
|
||||
assert output["title"] == "stackchain/dashboard#27"
|
||||
assert output["preview"] == "Closure rejected"
|
||||
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
|
||||
assert output["quarantined"] is False
|
||||
assert item["label"] == "Awaiting authorization"
|
||||
assert item["status"] == "authorization"
|
||||
assert item["authorization_required"] is True
|
||||
assert item["outbox_kind"] == "issue-close"
|
||||
assert item["title"] == "stackchain/dashboard#27"
|
||||
assert item["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
|
||||
assert item["quarantined"] is False
|
||||
assert output["partition"]["counts"] == {
|
||||
"waiting": 0, "sending": 0, "attention": 0, "authorization": 1
|
||||
}
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
|
|
@ -248,7 +255,7 @@ process.stdout.write(JSON.stringify(inbox.partition()));
|
|||
|
||||
assert len(output["drafts"]) == 1
|
||||
assert len(output["deliveries"]) == 5
|
||||
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2}
|
||||
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
|
||||
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
|
||||
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
|
||||
assert waiting["last_attempt_at"] == 90
|
||||
|
|
@ -270,7 +277,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
|||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
|
||||
assert "closureOutbox ? 'Open issue' : 'Open message'" in html
|
||||
assert "item.kind === 'authored-outbox' && closureOutbox" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
|
@ -284,6 +291,7 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
|||
assert 'Waiting <strong data-delivery-count="waiting">' in html
|
||||
assert 'Sending <strong data-delivery-count="sending">' in html
|
||||
assert 'Needs attention <strong data-delivery-count="attention">' in html
|
||||
assert 'Authorize <strong data-delivery-count="authorization">' in html
|
||||
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
|
||||
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||
assert "deliveryCenter.retryable.length" in html
|
||||
|
|
@ -293,6 +301,17 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
|||
assert '.delivery-center button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="draft-authorize"' in html
|
||||
assert '>Authorize & close</button>' in html
|
||||
assert "list.querySelectorAll('.draft-authorize')" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
|
|
@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v81" in worker
|
||||
assert "stackchain-dashboard-shell-v82" in worker
|
||||
|
|
|
|||
|
|
@ -35,7 +35,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-v81" in worker
|
||||
assert "stackchain-dashboard-shell-v82" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
|||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" 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-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -146,7 +146,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-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" 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-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -170,21 +170,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-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" 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-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" 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-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -385,7 +385,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-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" 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-v81';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v82';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "stackchain-dashboard-shell-v82" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user