747 lines
32 KiB
Python
747 lines
32 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle 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_binds_items_and_only_flushes_for_the_matching_account():
|
|
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)}};
|
|
const calls = [];
|
|
let confirmedLogin = 'timmy';
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:() => confirmedLogin, createOperationId:() => 'bound-op',
|
|
fetchJson:async url => {{ calls.push(url); return {{number:1}}; }},
|
|
}});
|
|
const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bound',body:'Context'}});
|
|
confirmedLogin = 'alexander';
|
|
(async () => {{
|
|
const mismatch = await outbox.flush('alexander');
|
|
const unknown = await outbox.retry(queued.id, '');
|
|
const matched = await outbox.flush('timmy');
|
|
process.stdout.write(JSON.stringify({{queued,mismatch,unknown,matched,calls}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["queued"]["ownerLogin"] == "timmy"
|
|
assert output["mismatch"]["blocked"] == 1
|
|
assert output["unknown"]["blocked"] == 1
|
|
assert len(output["matched"]["confirmed"]) == 1
|
|
assert len(output["calls"]) == 1
|
|
|
|
|
|
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, getOwnerLogin:()=>'timmy', 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"] == 3
|
|
|
|
|
|
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
|
|
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)}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'capture-image-1',
|
|
}});
|
|
outbox.enqueue({{
|
|
repository:'stackchain/dashboard',title:'Layout breaks',body:'At 320px',
|
|
attachment:{{filename:'phone.webp',contentType:'image/webp',data:'UklGRg=='}},
|
|
}});
|
|
process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["attachment"] == {
|
|
"filename": "phone.webp",
|
|
"contentType": "image/webp",
|
|
"data": "UklGRg==",
|
|
}
|
|
|
|
|
|
def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
|
|
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)}};
|
|
values.set('stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
|
id:'capture-1',operationId:'capture-1',repository:'o/r',title:'Before',body:'',
|
|
ownerLogin:'timmy',status:'queued',attachment:{{filename:'phone.png',contentType:'image/png',stored:true}},
|
|
}}]}}));
|
|
const gets=[];
|
|
const outbox=createIssueOutbox({{storage,backgroundSync:{{
|
|
get:async id=>{{gets.push(id);return {{id,operationId:'capture-1',attachment:{{filename:'phone.png',contentType:'image/png',data:'durable-image-bytes'}}}};}},
|
|
}}}});
|
|
(async()=>{{
|
|
const hydrated=await outbox.hydrateForEdit('capture-1');
|
|
process.stdout.write(JSON.stringify({{hydrated,gets,stored:values.get('stackchain.issue-outbox.v1')}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["hydrated"]["attachment"]["data"] == "durable-image-bytes"
|
|
assert output["gets"] == ["capture-1"]
|
|
assert "durable-image-bytes" not in output["stored"]
|
|
|
|
|
|
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
|
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)}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'create-start-1',
|
|
fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}}),
|
|
}});
|
|
outbox.enqueue({{
|
|
repository:'stackchain/dashboard',title:'Resume me',body:'',completionIntent:'create-and-start'
|
|
}});
|
|
const reloaded = createIssueOutbox({{
|
|
storage, fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}})
|
|
}});
|
|
reloaded.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
|
persisted:JSON.parse(values.get('stackchain.issue-outbox.v1')),
|
|
result,
|
|
}})));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["persisted"]["version"] == 3
|
|
completion = output["result"]["completions"][0]
|
|
assert completion["intent"] == "create-and-start"
|
|
assert completion["ownerLogin"] == "timmy"
|
|
assert completion["operationId"] == "create-start-1"
|
|
assert completion["issue"]["number"] == 389
|
|
|
|
|
|
def test_editing_a_queued_issue_updates_its_completion_intent_without_rekeying_delivery():
|
|
script = f"""
|
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
const values=new Map();let sequence=0;
|
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
const outbox=createIssueOutbox({{
|
|
storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'intent-' + (++sequence)
|
|
}});
|
|
const queued=outbox.enqueue({{repository:'o/r',title:'Work',body:''}});
|
|
const started=outbox.update(queued.id,{{...queued,completionIntent:'create-and-start'}});
|
|
const ordinary=outbox.update(queued.id,{{...started,completionIntent:undefined}});
|
|
process.stdout.write(JSON.stringify({{started,ordinary}}));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["started"]["completionIntent"] == "create-and-start"
|
|
assert "completionIntent" not in output["ordinary"]
|
|
assert output["started"]["operationId"] == output["ordinary"]["operationId"] == "intent-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,
|
|
getOwnerLogin:()=>'timmy',
|
|
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('timmy').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_exposes_sending_then_preserves_transient_attempt_details():
|
|
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)}};
|
|
let release; const gate = new Promise(resolve => release = resolve);
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=>'timmy', createOperationId:()=>'attempt', now:()=>888,
|
|
fetchJson:async()=>{{await gate;const error=new Error('Gateway timeout');error.status=503;throw error;}},
|
|
}});
|
|
outbox.enqueue({{repository:'o/r',title:'Wait',body:'Context'}});
|
|
const pending=outbox.flush('timmy');
|
|
const sending=outbox.list()[0];
|
|
release();
|
|
pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}})));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["sending"]["status"] == "sending"
|
|
assert output["after"]["status"] == "queued"
|
|
assert output["after"]["lastAttemptAt"] == 888
|
|
assert output["after"]["lastAttemptError"] == "Gateway timeout"
|
|
|
|
|
|
def test_foreground_attachment_retry_does_not_create_a_second_issue():
|
|
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)}};
|
|
const calls=[];let uploadAttempts=0;
|
|
const outbox=createIssueOutbox({{
|
|
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'image-op',
|
|
fetchJson:async(url,options={{}})=>{{
|
|
const form=options.body instanceof FormData ? options.body : null;
|
|
const file=form?.get('file');
|
|
calls.push({{url,key:options.headers?.['Idempotency-Key'],multipart:Boolean(form),
|
|
filename:file?.name,text:file?await file.text():null}});
|
|
if(url.endsWith('/issues'))return{{repository:'o/r',number:7,title:'Visual bug'}};
|
|
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('offline');error.status=503;throw error;}}
|
|
if(url.endsWith('/attachments'))return{{markdown:''}};
|
|
return{{id:8}};
|
|
}},
|
|
}});
|
|
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'b2ZmbGluZQ=='}}}});
|
|
(async()=>{{await outbox.flush('timmy');const partial=outbox.list()[0];const result=await outbox.retry(queued.id,'timmy');process.stdout.write(JSON.stringify({{calls,partial,result,remaining:outbox.list()}}));}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["partial"]["deliveredIssue"]["number"] == 7
|
|
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
|
uploads = [call for call in output["calls"] if call["url"].endswith("/attachments")]
|
|
assert uploads[-1]["multipart"] is True
|
|
assert uploads[-1]["filename"] == "screen.png"
|
|
assert uploads[-1]["text"] == "offline"
|
|
assert output["calls"][-1]["url"] == "api/v1/repos/o/r/issues/7/comments"
|
|
assert output["result"]["confirmed"][0]["number"] == 7
|
|
assert output["remaining"] == []
|
|
|
|
|
|
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
|
|
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)}};let sequence=0;
|
|
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++sequence)}});
|
|
const queued=outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'old.png',contentType:'image/png',data:'old'}}}});
|
|
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{...queued,deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:''}}]}}));
|
|
const updated=outbox.update(queued.id,{{...queued,attachment:{{filename:'new.png',contentType:'image/png',data:'new'}}}});
|
|
process.stdout.write(JSON.stringify(updated));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["deliveredIssue"]["number"] == 7
|
|
assert output["attachment"]["filename"] == "new.png"
|
|
assert "attachmentMarkdown" not in output
|
|
|
|
|
|
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;
|
|
let operationSequence = 0;
|
|
const calls = [];
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'edit-' + (++operationSequence),
|
|
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('timmy').then(async () => {{
|
|
const attention = outbox.list()[0];
|
|
outbox.update(queued.id, {{...attention,title:'Fixed title'}});
|
|
invalid = false;
|
|
const result = await outbox.retry(queued.id, 'timmy');
|
|
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"]] == ["edit-1", "edit-2"]
|
|
assert output["calls"][1]["body"]["title"] == "Fixed title"
|
|
assert output["result"]["confirmed"][0]["number"] == 42
|
|
assert output["remaining"] == []
|
|
|
|
|
|
def test_issue_outbox_rekeys_only_after_user_retries_uncertain_delivery():
|
|
script = f"""
|
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
const values = new Map(); let sequence=0; const calls=[];
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'operation-' + (++sequence),
|
|
fetchJson:async (_url, options) => {{
|
|
const key=options.headers['Idempotency-Key']; calls.push(key);
|
|
if (key === 'operation-1') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }}
|
|
return {{number:319}};
|
|
}},
|
|
}});
|
|
const item=outbox.enqueue({{repository:'o/r',title:'Possibly sent',body:'Check first'}});
|
|
(async()=>{{
|
|
await outbox.flush('timmy'); const attention=outbox.list()[0];
|
|
await outbox.retry(item.id,'timmy');
|
|
process.stdout.write(JSON.stringify({{attention,calls,remaining:outbox.list()}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["attention"]["status"] == "attention"
|
|
assert output["attention"]["deliveryState"] == "uncertain"
|
|
assert output["calls"] == ["operation-1", "operation-2"]
|
|
assert output["remaining"] == []
|
|
|
|
|
|
def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes():
|
|
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)}};
|
|
let sequence = 0;
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'payload-' + (++sequence),
|
|
}});
|
|
const queued = outbox.enqueue({{
|
|
repository:'stackchain/api',title:'Plan',body:'Context',labelIds:[1],milestoneId:2,dueDate:'2026-08-09'
|
|
}});
|
|
const unchanged = outbox.update(queued.id, {{...queued}});
|
|
const fields = [
|
|
['repository', 'stackchain/web'], ['title', 'Revised'], ['body', 'More context'],
|
|
['labelIds', [1, 3]], ['milestoneId', 4], ['dueDate', '2026-08-10'],
|
|
];
|
|
const edits = [];
|
|
for (const [field, value] of fields) {{
|
|
const current = outbox.list()[0];
|
|
edits.push(outbox.update(queued.id, {{...current, [field]:value}}));
|
|
}}
|
|
process.stdout.write(JSON.stringify({{queued,unchanged,edits,final:outbox.list()[0]}}));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["unchanged"]["operationId"] == output["queued"]["operationId"] == "payload-1"
|
|
assert [item["operationId"] for item in output["edits"]] == [
|
|
"payload-2", "payload-3", "payload-4", "payload-5", "payload-6", "payload-7"
|
|
]
|
|
assert output["final"]["milestoneId"] == 4
|
|
assert output["final"]["dueDate"] == "2026-08-10"
|
|
|
|
|
|
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, getOwnerLogin:()=>'timmy', 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('timmy');
|
|
const sendNow = outbox.retry(queued.id, 'timmy');
|
|
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
|
|
|
|
|
|
def test_issue_outbox_mirrors_queue_and_registers_background_sync():
|
|
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)}};
|
|
const state = {{snapshots:[],syncs:0}};
|
|
const backgroundSync = {{
|
|
reconcile: async items => state.snapshots.push(items.map(item => ({{...item}}))),
|
|
requestSync: async () => {{ state.syncs += 1; }},
|
|
}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'background-1',
|
|
}});
|
|
outbox.enqueue({{repository:'stackchain/api',title:'Close the app',body:'Still deliver'}});
|
|
setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["snapshots"][0][0]["operationId"] == "background-1"
|
|
assert output["snapshots"][0][0]["ownerLogin"] == "timmy"
|
|
assert output["syncs"] == 1
|
|
|
|
|
|
def test_issue_outbox_confirms_durable_admission_only_after_mirror_and_sync():
|
|
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)}};
|
|
let releaseMirror; let releaseSync;
|
|
const mirrorGate = new Promise(resolve => releaseMirror = resolve);
|
|
const syncGate = new Promise(resolve => releaseSync = resolve);
|
|
const events = [];
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-1',
|
|
backgroundSync: {{
|
|
reconcile: async items => {{ events.push('mirror-start'); await mirrorGate; events.push('mirror-committed'); }},
|
|
requestSync: async () => {{ events.push('sync-start'); await syncGate; events.push('sync-registered'); }},
|
|
}},
|
|
}});
|
|
const admission = outbox.enqueueDurably({{repository:'stackchain/api',title:'Keep this',body:'Draft'}})
|
|
.then(result => {{ events.push('confirmed'); return result; }});
|
|
Promise.resolve().then(async () => {{
|
|
const beforeMirror = events.slice();
|
|
releaseMirror(); await new Promise(resolve => setTimeout(resolve, 0));
|
|
const beforeSync = events.slice();
|
|
releaseSync();
|
|
const result = await admission;
|
|
process.stdout.write(JSON.stringify({{beforeMirror,beforeSync,events,result,items:outbox.list()}}));
|
|
}});
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["beforeMirror"] == ["mirror-start"]
|
|
assert output["beforeSync"] == ["mirror-start", "mirror-committed", "sync-start"]
|
|
assert output["events"][-1] == "confirmed"
|
|
assert output["result"]["background"] is True
|
|
assert output["result"]["item"]["id"] == "durable-1"
|
|
assert output["items"][0]["title"] == "Keep this"
|
|
|
|
|
|
def test_durable_screenshot_is_mirrored_as_blob_before_a_payload_free_local_index_is_committed():
|
|
script = f"""
|
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
const values = new Map(); const events = []; const snapshots = [];
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => {{
|
|
if (value.includes('binary-screenshot-bytes')) throw new Error('screenshot leaked into localStorage');
|
|
events.push('local-index'); values.set(key,value);
|
|
}},
|
|
}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'quota-safe-1',
|
|
backgroundSync: {{
|
|
reconcile: async items => {{ events.push('indexeddb'); snapshots.push(items); }},
|
|
requestSync: async () => {{ events.push('sync'); }},
|
|
}},
|
|
}});
|
|
(async () => {{
|
|
const blob=new Blob(['binary-screenshot-bytes'],{{type:'image/webp'}});
|
|
const result = await outbox.enqueueDurably({{
|
|
repository:'stackchain/dashboard', title:'Mobile layout',
|
|
attachment:{{filename:'phone.webp',contentType:'image/webp',blob}},
|
|
}});
|
|
const durable=snapshots[0][0].attachment;
|
|
process.stdout.write(JSON.stringify({{
|
|
events, mirrored:{{filename:durable.filename,contentType:durable.contentType,
|
|
isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}},
|
|
local:outbox.list()[0], result:result.item, raw:values.get('stackchain.issue-outbox.v1'),
|
|
}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["events"] == ["indexeddb", "local-index", "sync"]
|
|
assert output["mirrored"] == {
|
|
"filename": "phone.webp",
|
|
"contentType": "image/webp",
|
|
"isBlob": True,
|
|
"size": 23,
|
|
"text": "binary-screenshot-bytes",
|
|
}
|
|
assert output["local"]["attachment"] == {
|
|
"filename": "phone.webp",
|
|
"contentType": "image/webp",
|
|
"stored": True,
|
|
}
|
|
assert "binary-screenshot-bytes" not in output["raw"]
|
|
assert output["result"]["attachment"] == output["local"]["attachment"]
|
|
|
|
|
|
def test_replacing_a_durable_screenshot_updates_indexeddb_before_the_local_reference():
|
|
script = f"""
|
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
const values = new Map(); const events = []; const snapshots = [];
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => {{
|
|
if (value.includes('new-image-bytes')) throw new Error('replacement leaked into localStorage');
|
|
events.push('local-index'); values.set(key,value);
|
|
}},
|
|
}};
|
|
const backgroundSync = {{
|
|
reconcile:async items => {{ events.push('indexeddb'); snapshots.push(items); }},
|
|
requestSync:async () => {{ events.push('sync'); }},
|
|
}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, backgroundSync, getOwnerLogin:()=> 'timmy',
|
|
createOperationId:(() => {{ let sequence=0; return () => 'replace-' + (++sequence); }})(),
|
|
}});
|
|
(async () => {{
|
|
const admitted = await outbox.enqueueDurably({{repository:'o/r',title:'Visual'}});
|
|
events.length = 0;
|
|
const result = await outbox.updateDurably(admitted.item.id, {{
|
|
...admitted.item,
|
|
attachment:{{filename:'new.png',contentType:'image/png',data:'new-image-bytes'}},
|
|
}});
|
|
process.stdout.write(JSON.stringify({{
|
|
events, mirrored:snapshots.at(-1)[0], local:outbox.list()[0], result:result.item,
|
|
}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["events"] == ["indexeddb", "local-index", "sync"]
|
|
assert output["mirrored"]["attachment"]["data"] == "new-image-bytes"
|
|
assert output["local"]["attachment"]["stored"] is True
|
|
assert "data" not in output["local"]["attachment"]
|
|
assert output["result"] == output["local"]
|
|
|
|
|
|
def test_next_durable_admission_migrates_legacy_screenshot_payloads_out_of_localstorage():
|
|
script = f"""
|
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
|
id:'legacy',operationId:'legacy',repository:'o/r',title:'Queued before upgrade',body:'',
|
|
labelIds:[],ownerLogin:'timmy',status:'queued',queuedAt:1,
|
|
attachment:{{filename:'old.png',contentType:'image/png',data:'legacy-image-bytes'}},
|
|
}}]}})]]);
|
|
const mirrored = [];
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'new-item',
|
|
backgroundSync:{{reconcile:async items=>mirrored.push(items),requestSync:async()=>{{}}}},
|
|
}});
|
|
(async()=>{{
|
|
await outbox.enqueueDurably({{repository:'o/r',title:'New queue item'}});
|
|
process.stdout.write(JSON.stringify({{
|
|
mirrored:mirrored[0],local:outbox.list(),raw:values.get('stackchain.issue-outbox.v1'),
|
|
}}));
|
|
}})();
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["mirrored"][0]["attachment"]["data"] == "legacy-image-bytes"
|
|
assert output["local"][0]["attachment"]["stored"] is True
|
|
assert "data" not in output["local"][0]["attachment"]
|
|
assert "legacy-image-bytes" not in output["raw"]
|
|
|
|
|
|
def test_issue_outbox_reports_degraded_admission_without_losing_foreground_item():
|
|
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)}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'foreground-1',
|
|
backgroundSync: {{
|
|
reconcile: async () => {{}},
|
|
requestSync: async () => {{ throw new Error('Background Sync unavailable'); }},
|
|
}},
|
|
}});
|
|
outbox.enqueueDurably({{repository:'stackchain/api',title:'Retain me',body:'Draft'}}).then(result =>
|
|
process.stdout.write(JSON.stringify({{
|
|
background:result.background, durability:result.durability,
|
|
error:result.error.message, item:result.item, persisted:outbox.list()
|
|
}}))
|
|
);
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["background"] is False
|
|
assert output["durability"] == "foreground-only"
|
|
assert output["error"] == "Background Sync unavailable"
|
|
assert output["persisted"] == [output["item"]]
|
|
|
|
|
|
def test_foreground_delivery_uses_same_atomic_background_claim():
|
|
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)}};
|
|
const state = {{backgroundCalls:0,directCalls:0}};
|
|
const backgroundSync = {{
|
|
reconcile: async () => {{}}, requestSync: async () => {{}},
|
|
send: async (item, login) => {{ state.backgroundCalls += 1; return {{issue:{{number:9}}, claimed:item.id + ':' + login}}; }},
|
|
}};
|
|
const outbox = createIssueOutbox({{
|
|
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'shared-claim',
|
|
fetchJson:async () => {{ state.directCalls += 1; return {{number:10}}; }},
|
|
}});
|
|
outbox.enqueue({{repository:'o/r',title:'One owner'}});
|
|
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{state,result,remaining:outbox.list()}})));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["state"] == {"backgroundCalls": 1, "directCalls": 0}
|
|
assert output["result"]["confirmed"][0]["number"] == 9
|
|
assert output["remaining"] == []
|
|
|
|
|
|
def test_foreground_background_delivery_surfaces_permanent_failure_immediately():
|
|
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)}};
|
|
const error=new Error('Title is invalid');error.status=422;
|
|
const backgroundSync={{reconcile:async()=>{{}},requestSync:async()=>{{}},send:async()=>({{attention:true,error}})}};
|
|
const outbox=createIssueOutbox({{storage,backgroundSync,getOwnerLogin:()=>'timmy',createOperationId:()=>'invalid'}});
|
|
outbox.enqueue({{repository:'o/r',title:'Bad'}});
|
|
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,items:outbox.list()}})));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["items"][0]["status"] == "attention"
|
|
assert output["items"][0]["error"] == "Title is invalid"
|
|
assert output["result"]["confirmed"] == []
|
|
|
|
|
|
def test_page_reconciles_worker_success_and_attention_into_visible_outbox():
|
|
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)}};
|
|
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:2,items:[
|
|
{{id:'sent',operationId:'sent',ownerLogin:'timmy',status:'queued',title:'Done'}},
|
|
{{id:'bad',operationId:'bad',ownerLogin:'timmy',status:'queued',title:'Fix me'}},
|
|
]}}));
|
|
const outbox=createIssueOutbox({{storage}});
|
|
outbox.reconcileBackground([
|
|
{{id:'sent',status:'sent'}},
|
|
{{id:'bad',status:'attention',error:'Title is invalid'}},
|
|
]);
|
|
process.stdout.write(JSON.stringify(outbox.list()));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output == [
|
|
{
|
|
"id": "bad",
|
|
"operationId": "bad",
|
|
"ownerLogin": "timmy",
|
|
"status": "attention",
|
|
"title": "Fix me",
|
|
"error": "Title is invalid",
|
|
}
|
|
]
|
|
|
|
|
|
def test_page_reconciliation_keeps_confirmed_attachment_delivery_stages():
|
|
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)}};
|
|
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'staged'}});
|
|
outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});
|
|
outbox.reconcileBackground([{{id:'staged',status:'queued',deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:''}}]);
|
|
process.stdout.write(JSON.stringify(outbox.list()[0]));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["deliveredIssue"]["number"] == 7
|
|
assert output["attachmentMarkdown"] == ""
|
|
|
|
|
|
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
|
|
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)}};
|
|
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{
|
|
id:'resume',operationId:'resume',ownerLogin:'timmy',status:'queued',
|
|
repository:'stackchain/dashboard',title:'Resume',body:'',completionIntent:'create-and-start'
|
|
}}]}}));
|
|
const outbox=createIssueOutbox({{storage}});
|
|
outbox.reconcileBackground([{{
|
|
id:'resume',status:'sent',ownerLogin:'timmy',completionIntent:'create-and-start',
|
|
deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume'}}
|
|
}}]);
|
|
const blocked=outbox.pendingCompletions('alexander');
|
|
const pending=outbox.pendingCompletions('timmy');
|
|
const retained=outbox.list();
|
|
outbox.completeIntent('resume','timmy');
|
|
process.stdout.write(JSON.stringify({{blocked,pending,retained,remaining:outbox.list()}}));
|
|
"""
|
|
output = run_node(script)
|
|
|
|
assert output["blocked"] == []
|
|
assert output["pending"] == [{
|
|
"id": "resume", "intent": "create-and-start", "ownerLogin": "timmy",
|
|
"operationId": "resume",
|
|
"issue": {"repository": "stackchain/dashboard", "number": 389, "title": "Resume"},
|
|
}]
|
|
assert output["retained"][0]["status"] == "completion"
|
|
assert output["remaining"] == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/issue-outbox.js"></script>' in html
|
|
assert "const issueOutbox = createIssueOutbox({" in html
|
|
assert "await issueOutbox.enqueueDurably(durableDraft)" in html
|
|
assert "Saving for background delivery…" in html
|
|
assert "Saved for next launch; background delivery unavailable." in html
|
|
assert "throw new Error('Background Sync unavailable')" in html
|
|
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
|
|
assert "navigator.onLine" in html
|
|
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
|
assert "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
|