feat: queue offline Search replies (Closes #959)
CI / lint (pull_request) Successful in 2m7s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 1m10s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-16 11:00:11 +00:00
1 parent 2e6c20d68d
commit 7fa43a9e50
9 files changed
+209 -22

No files matched your search

+40
View File
@@ -111,6 +111,46 @@ const photo=(filename,note)=>({{filename,contentType:'image/png',blob:new Blob([
]
def test_search_preview_reply_is_durably_bound_to_exact_kind_with_upload_checkpoints():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const mirrors = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
backgroundSync:{{reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}}}},
}});
(async()=>{{
const result=await outbox.enqueueDurably({{
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,
body:'Ready offline.',operationId:'search-42',attachments:[{{
filename:'proof.jpg',contentType:'image/jpeg',blob:new Blob(['proof']),note:'After repair',
operationId:'photo-stage-1',confirmed:{{markdown:'![proof](saved)'}},
}}],
}});
const metadata=outbox.list()[0]; const durable=mirrors[0][0];
process.stdout.write(JSON.stringify({{
item:result.item,metadata,
durable:{{targetKind:durable.targetKind,operationId:durable.attachment.operationId,
markdown:durable.attachment.confirmed?.markdown,hasBlob:durable.attachment.blob instanceof Blob}},
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["item"]["kind"] == "search-reply"
assert output["item"]["targetKind"] == "pull"
assert output["metadata"]["attachment"] == {
"filename": "proof.jpg", "contentType": "image/jpeg", "stored": True,
"note": "After repair", "operationId": "photo-stage-1",
"confirmed": {"markdown": "![proof](saved)"},
}
assert output["durable"] == {
"targetKind": "pull", "operationId": "photo-stage-1",
"markdown": "![proof](saved)", "hasBlob": True,
}
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
+49
View File
@@ -19,6 +19,55 @@ def run_node(script: str) -> dict:
return json.loads(completed.stdout)
def test_search_reply_reconnect_uses_preview_authorization_and_resumes_photo_checkpoints():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'search-42',operationId:'search-42',ownerLogin:'timmy',status:'queued',outboxLane:'authored',
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,body:'Ready offline.',attachments:[
{{filename:'one.jpg',contentType:'image/jpeg',blob:new Blob(['one']),operationId:'photo-one',confirmed:{{markdown:'![one](saved)'}}}},
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two']),operationId:'photo-two'}},
]}};
const calls=[];let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
const file=options.body instanceof FormData?options.body.get('file'):null;
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
body:file?null:JSON.parse(options.body)}});
if(file)return{{markdown:'![two](uploaded)'}};
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
return{{id:91,body:'Ready offline.'}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';
try{{await sync.flush();}}catch(error){{first=error.message;}}
const checkpoint={{...item}};const second=await sync.flush();
process.stdout.write(JSON.stringify({{calls,first,checkpoint,second}}));
}})();
"""
output = run_node(script)
assert output["first"] == "offline"
assert output["checkpoint"]["attachmentMarkdowns"] == [
"![one](saved)", "![two](uploaded)"
]
uploads = [call for call in output["calls"] if call["filename"]]
assert [(call["filename"], call["key"]) for call in uploads] == [
("two.jpg", "photo-two")
]
comments = [call for call in output["calls"] if not call["filename"]]
assert [call["url"] for call in comments] == [
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
]
assert [call["key"] for call in comments] == ["search-42:comment"] * 2
assert comments[-1]["body"] == {
"body": "Ready offline.\n\n![one](saved)\n\n![two](uploaded)"
}
assert output["second"]["confirmed"] == [{"id": 91, "body": "Ready offline."}]
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+27
View File
@@ -389,6 +389,33 @@ process.stdout.write(JSON.stringify({{
}
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async()=>{{
const items=[1,2].map(number=>({{repository:'stackchain/api',number,kind:'issue'}}));
const values=new Map();let admit;const navigated=[];const states=[];let cleared=0;
const preview=createSearchPreview({{
fetchJson:async item=>item,mutate:async()=>{{}},
queueReply:(item,body,operationId)=>new Promise(resolve=>{{admit=()=>resolve({{id:operationId,queued:true}});}}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:()=> 'offline-search-1',hasAttachments:()=>false,clearAttachments:()=>{{cleared+=1;}},
getSession:()=>({{items,more:false}}),onNavigate:item=>navigated.push(item.number),onState:state=>states.push(state),
}});
await preview.open(items[0]);preview.saveReplyDraft('Queue safely.');
const pending=preview.reply({{advance:true}});await Promise.resolve();const before=[...navigated];admit();await pending;
process.stdout.write(JSON.stringify({{before,navigated,cleared,draft:values.size,
queued:states.some(state=>state.status==='queued')}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": [], "navigated": [2], "cleared": 1, "draft": 0, "queued": True
}
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
+3
View File
@@ -130,6 +130,9 @@ def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
assert mount
assert "maxFiles: 5" in mount.group(1)
assert "'/preview/attachments?kind='" in mount.group(1)
assert "queueReply:async (item,body,operationId)" in dashboard
assert "kind:'search-reply',targetKind:item.kind" in dashboard
assert "authoredOutbox.enqueueDurably" in dashboard
assert ".search-preview-reply .conversation-photo-actions" in css