import json import subprocess from pathlib import Path import pytest from src.views import dashboard OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js" def run_node(script: str): result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) return json.loads(result.stdout) def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); const values = new Map(); const storage = {{ getItem:key => values.has(key) ? values.get(key) : null, setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key), }}; let sequence = 0; const outbox = createIssueOutbox({{storage, createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}}); outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}}); outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}}); const reloaded = createIssueOutbox({{storage}}).list(); process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}})); """ output = run_node(script) assert [(item["repository"], item["title"]) for item in output["items"]] == [ ("stackchain/api", "First"), ("stackchain/web", "Second"), ] assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"] assert all(item["status"] == "queued" for item in output["items"]) assert output["stored"]["version"] == 1 def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; let sequence = 0; const calls = []; const outbox = createIssueOutbox({{ storage, createOperationId:() => 'stable-' + (++sequence), fetchJson:async (url, options) => {{ calls.push({{url, key:options.headers['Idempotency-Key']}}); if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }} return {{repository:'stackchain/api',number:41,title:'First'}}; }}, }}); outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}}); outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}}); outbox.flush().then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}}))); """ output = run_node(script) assert output["result"]["confirmed"][0]["number"] == 41 assert output["calls"] == [ {"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"}, {"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"}, ] assert len(output["remaining"]) == 1 assert output["remaining"][0]["title"] == "Second" assert output["remaining"][0]["status"] == "queued" def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; let invalid = true; const calls = []; const outbox = createIssueOutbox({{ storage, createOperationId:() => 'stable-edit', fetchJson:async (_url, options) => {{ calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}}); if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }} return {{repository:'stackchain/api',number:42,title:'Fixed title'}}; }}, }}); const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}}); outbox.flush().then(async () => {{ const attention = outbox.list()[0]; outbox.update(queued.id, {{...attention,title:'Fixed title'}}); invalid = false; const result = await outbox.retry(queued.id); process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}})); }}); """ output = run_node(script) assert output["attention"]["status"] == "attention" assert output["attention"]["error"] == "Title is invalid" assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"] assert output["calls"][1]["body"]["title"] == "Fixed title" assert output["result"]["confirmed"][0]["number"] == 42 assert output["remaining"] == [] def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap(): script = f""" const createIssueOutbox = require({json.dumps(str(OUTBOX))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; let calls = 0; let release; const gate = new Promise(resolve => {{ release = resolve; }}); const outbox = createIssueOutbox({{ storage, createOperationId:() => 'single-flight', fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }}, }}); const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}}); const reconnect = outbox.flush(); const sendNow = outbox.retry(queued.id); release(); Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}}))); """ output = run_node(script) assert output["calls"] == 1 assert output["remaining"] == [] assert sum(len(result["confirmed"]) for result in output["results"]) >= 1 @pytest.mark.anyio async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions(): html = await dashboard() assert '' in html assert "createIssueOutbox({ storage: localStorage" in html assert "issueOutbox.enqueue(captureDraft)" in html assert "navigator.onLine" in html assert "window.addEventListener('online', flushIssueOutbox)" in html assert 'class="draft-send"' in html assert 'class="draft-edit"' in html assert 'Queued for sync' in html assert 'Needs attention' in html assert "issueOutbox.discard(item.outbox_id)" in html assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html