351 lines
12 KiB
Python
351 lines
12 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
RECOVERY = ROOT / "frontend" / "mobile-delivery-recovery.js"
|
|
|
|
|
|
def run_node(script: str) -> dict:
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_recovery_orders_deliveries_by_safest_required_intervention():
|
|
script = f"""
|
|
const createRecovery = require({json.dumps(str(RECOVERY))});
|
|
const recovery = createRecovery({{
|
|
getItems: () => [
|
|
{{outbox_id:'waiting', delivery_state:'waiting', title:'Waiting'}},
|
|
{{outbox_id:'uncertain', delivery_state:'uncertain', title:'Uncertain'}},
|
|
{{outbox_id:'attention', status:'attention', title:'Attention'}},
|
|
{{outbox_id:'authorization', status:'authorization', title:'Authorization'}},
|
|
],
|
|
}});
|
|
process.stdout.write(JSON.stringify(recovery.snapshot()));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"count": 4,
|
|
"current": {
|
|
"id": "authorization",
|
|
"state": "authorization",
|
|
"primary": "Authorize & send",
|
|
"position": 1,
|
|
"total": 4,
|
|
},
|
|
}
|
|
|
|
|
|
def test_recovery_advances_only_after_the_current_delivery_is_resolved():
|
|
script = f"""
|
|
const createRecovery = require({json.dumps(str(RECOVERY))});
|
|
(async () => {{
|
|
let items = [
|
|
{{outbox_id:'authorize', status:'authorization', title:'Approve review'}},
|
|
{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}},
|
|
];
|
|
let resolves = false;
|
|
const recovery = createRecovery({{
|
|
getItems: () => items,
|
|
activate: async item => {{
|
|
if (resolves) items = items.filter(candidate => candidate.outbox_id !== item.outbox_id);
|
|
return resolves;
|
|
}},
|
|
}});
|
|
recovery.open();
|
|
const retained = await recovery.activate();
|
|
resolves = true;
|
|
const advanced = await recovery.activate();
|
|
process.stdout.write(JSON.stringify({{retained, advanced}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"retained": {
|
|
"count": 2,
|
|
"current": {
|
|
"id": "authorize",
|
|
"state": "authorization",
|
|
"primary": "Authorize & send",
|
|
"position": 1,
|
|
"total": 2,
|
|
},
|
|
},
|
|
"advanced": {
|
|
"count": 1,
|
|
"current": {
|
|
"id": "retry",
|
|
"state": "waiting",
|
|
"primary": "Retry now",
|
|
"position": 1,
|
|
"total": 1,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def test_recovery_keeps_one_attempt_locked_until_delivery_settles():
|
|
script = f"""
|
|
const createRecovery = require({json.dumps(str(RECOVERY))});
|
|
(async () => {{
|
|
let finish;
|
|
let attempts = 0;
|
|
const settled = new Promise(resolve => {{finish = resolve}});
|
|
const element = () => ({{textContent:'', disabled:false}});
|
|
const elements = {{
|
|
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
|
title:element(), destination:element(), reason:element(), progress:element(),
|
|
action:element(), status:element(),
|
|
}};
|
|
const recovery = createRecovery({{
|
|
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
|
|
activate: async () => {{attempts += 1; await settled; return true}},
|
|
elements,
|
|
}});
|
|
recovery.open();
|
|
const first = recovery.activate();
|
|
const second = recovery.activate();
|
|
await Promise.resolve();
|
|
const pending = {{attempts, disabled:elements.action.disabled, status:elements.status.textContent}};
|
|
finish();
|
|
await Promise.all([first, second]);
|
|
process.stdout.write(JSON.stringify({{
|
|
pending, attempts, disabled:elements.action.disabled,
|
|
}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"pending": {"attempts": 1, "disabled": True, "status": "Working…"},
|
|
"attempts": 1,
|
|
"disabled": False,
|
|
}
|
|
|
|
|
|
def test_attention_handoff_closes_modal_focuses_destination_and_awaits_work():
|
|
script = f"""
|
|
const createRecovery = require({json.dumps(str(RECOVERY))});
|
|
(async () => {{
|
|
let finish;
|
|
let handoffs = 0;
|
|
const settled = new Promise(resolve => {{finish = resolve}});
|
|
const destination = {{
|
|
focused:false, scrolled:false,
|
|
focus(){{this.focused=true}}, scrollIntoView(){{this.scrolled=true}},
|
|
async onclick(){{handoffs += 1; await settled; return true}},
|
|
}};
|
|
global.document = {{
|
|
getElementById: () => null,
|
|
querySelector: selector => selector.includes('draft-resume') ? destination : null,
|
|
}};
|
|
const element = () => ({{textContent:'', disabled:false}});
|
|
const elements = {{
|
|
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
|
title:element(), destination:element(), reason:element(), progress:element(),
|
|
action:element(), status:element(),
|
|
}};
|
|
const recovery = createRecovery({{
|
|
getItems: () => [{{outbox_id:'review', status:'attention', title:'Review feedback'}}],
|
|
getIndex: () => 3,
|
|
elements,
|
|
}});
|
|
recovery.open();
|
|
const first = recovery.activate();
|
|
const second = recovery.activate();
|
|
await Promise.resolve();
|
|
const pending = {{
|
|
dialogOpen:elements.dialog.open, disabled:elements.action.disabled,
|
|
focused:destination.focused, scrolled:destination.scrolled, handoffs,
|
|
}};
|
|
finish();
|
|
await Promise.all([first, second]);
|
|
process.stdout.write(JSON.stringify({{pending, handoffs, dialogOpen:elements.dialog.open}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"pending": {
|
|
"dialogOpen": False,
|
|
"disabled": True,
|
|
"focused": True,
|
|
"scrolled": True,
|
|
"handoffs": 1,
|
|
},
|
|
"handoffs": 1,
|
|
"dialogOpen": False,
|
|
}
|
|
|
|
|
|
def test_failed_attention_handoff_reopens_the_same_retryable_item():
|
|
script = f"""
|
|
const createRecovery = require({json.dumps(str(RECOVERY))});
|
|
(async () => {{
|
|
const destination = {{
|
|
focus(){{}}, scrollIntoView(){{}},
|
|
async onclick(){{throw new Error('Latest checklist unavailable')}},
|
|
}};
|
|
global.document = {{
|
|
getElementById: () => null,
|
|
querySelector: () => destination,
|
|
}};
|
|
const element = () => ({{textContent:'', disabled:false}});
|
|
const elements = {{
|
|
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
|
title:element(), destination:element(), reason:element(), progress:element(),
|
|
action:element(), status:element(),
|
|
}};
|
|
const recovery = createRecovery({{
|
|
getItems: () => [{{outbox_id:'conflict', checklist_conflict:true, title:'Checklist conflict'}}],
|
|
getIndex: () => 0,
|
|
elements,
|
|
}});
|
|
recovery.open();
|
|
const result = await recovery.activate();
|
|
process.stdout.write(JSON.stringify({{
|
|
result, dialogOpen:elements.dialog.open, status:elements.status.textContent,
|
|
action:elements.action.textContent,
|
|
}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"result": {
|
|
"count": 1,
|
|
"current": {
|
|
"id": "conflict",
|
|
"state": "attention",
|
|
"primary": "Review changes",
|
|
"position": 1,
|
|
"total": 1,
|
|
},
|
|
},
|
|
"dialogOpen": True,
|
|
"status": "Latest checklist unavailable Try again.",
|
|
"action": "Review changes",
|
|
}
|
|
|
|
|
|
def test_recovery_retains_failed_delivery_with_actionable_status():
|
|
script = f"""
|
|
const createRecovery = require({json.dumps(str(RECOVERY))});
|
|
(async () => {{
|
|
const element = () => ({{textContent:'', disabled:false}});
|
|
const elements = {{
|
|
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
|
title:element(), destination:element(), reason:element(), progress:element(),
|
|
action:element(), status:element(),
|
|
}};
|
|
const recovery = createRecovery({{
|
|
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
|
|
activate: async () => {{throw new Error('Network unavailable')}},
|
|
elements,
|
|
}});
|
|
recovery.open();
|
|
const result = await recovery.activate();
|
|
process.stdout.write(JSON.stringify({{
|
|
result, disabled:elements.action.disabled, status:elements.status.textContent,
|
|
}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"result": {
|
|
"count": 1,
|
|
"current": {
|
|
"id": "retry",
|
|
"state": "waiting",
|
|
"primary": "Retry now",
|
|
"position": 1,
|
|
"total": 1,
|
|
},
|
|
},
|
|
"disabled": False,
|
|
"status": "Network unavailable Try again.",
|
|
}
|
|
|
|
|
|
def test_recovery_renders_delivery_context_and_hands_off_when_cleared():
|
|
script = f"""
|
|
const createRecovery = require({json.dumps(str(RECOVERY))});
|
|
(async () => {{
|
|
let items = [{{
|
|
outbox_id:'close-7', status:'authorization', outbox_kind:'issue-close',
|
|
title:'Close resolved incident', repository:'stackchain/ops',
|
|
details:'#7', last_attempt_error:'Fresh authorization expired',
|
|
}}];
|
|
let completed = 0;
|
|
const element = () => ({{textContent:'', hidden:false}});
|
|
const elements = {{
|
|
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
|
title: element(), destination: element(), reason: element(), progress: element(),
|
|
action: element(), status: element(),
|
|
}};
|
|
const recovery = createRecovery({{
|
|
getItems: () => items,
|
|
activate: async () => {{items = []; return true}},
|
|
onComplete: () => {{completed += 1}},
|
|
elements,
|
|
}});
|
|
const opened = recovery.open();
|
|
const rendered = {{
|
|
opened, dialogOpen:elements.dialog.open, title:elements.title.textContent,
|
|
destination:elements.destination.textContent, reason:elements.reason.textContent,
|
|
progress:elements.progress.textContent, action:elements.action.textContent,
|
|
}};
|
|
const cleared = await recovery.activate();
|
|
process.stdout.write(JSON.stringify({{rendered, cleared, completed, dialogOpen:elements.dialog.open}}));
|
|
}})();
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"rendered": {
|
|
"opened": "opened",
|
|
"dialogOpen": True,
|
|
"title": "Close resolved incident",
|
|
"destination": "stackchain/ops · #7",
|
|
"reason": "Fresh authorization expired",
|
|
"progress": "Delivery 1 of 1 · Authorization required",
|
|
"action": "Authorize & close",
|
|
},
|
|
"cleared": {"count": 0, "current": None},
|
|
"completed": 1,
|
|
"dialogOpen": False,
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_packages_phone_safe_guided_delivery_recovery():
|
|
html = await dashboard()
|
|
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
|
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
|
|
|
assert '<dialog id="mobile-delivery-recovery"' in html
|
|
assert 'aria-labelledby="mobile-delivery-recovery-title"' in html
|
|
assert 'id="mobile-delivery-recovery-action"' in html
|
|
assert '<script src="static/mobile-delivery-recovery.js"></script>' in html
|
|
assert "const mobileDeliveryRecovery = createMobileDeliveryRecovery({" in html
|
|
assert "activate: recoverMobileDelivery," in html
|
|
assert "button.onclick=async()=>" in html
|
|
assert "await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin)" in html
|
|
assert "(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result)" in html
|
|
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
|
|
assert "mobileStartDay.completePhase('delivery')" in html
|
|
assert "BASE + 'static/mobile-delivery-recovery.js'" in service_worker
|
|
assert '"static/mobile-delivery-recovery.js"' in bundle
|
|
assert ".mobile-delivery-recovery-panel" in html
|
|
assert "min-height:48px" in html
|
|
assert "env(safe-area-inset-bottom)" in html
|