feat: queue screenshot comments offline (Closes #477)
CI / lint (pull_request) Successful in 58s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 10:56:02 +00:00
1 parent ec339d242d
commit 7ec3330175
9 files changed
+265 -20

No files matched your search

+49
View File
@@ -425,3 +425,52 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
assert "if (result?.queued)" in html
assert "backgroundSync: backgroundIssueSync" in html
assert "authoredOutbox.reconcileBackground(records)" in html
def test_screenshot_comment_admits_bytes_to_indexeddb_without_putting_them_in_localstorage():
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),removeItem:k=>values.delete(k)}};
const outbox=createAuthoredOutbox({{
storage,getOwnerLogin:()=>'timmy',
backgroundSync:{{reconcile:async(items,lane)=>mirrors.push({{items,lane}}),requestSync:async()=>{{}}}},
}});
(async()=>{{
const admission=await outbox.enqueueDurably({{
kind:'issue-comment',repository:'stackchain/dashboard',number:477,
body:'Broken at 320px',operationId:'comment-image-477',
attachment:{{filename:'phone.png',contentType:'image/png',data:'PRIVATE-IMAGE-BYTES'}},
}});
process.stdout.write(JSON.stringify({{admission,local:values.get('stackchain.authored-outbox.v1'),mirrored:mirrors[0]}}));
}})();
"""
output = run_node(script)
assert "PRIVATE-IMAGE-BYTES" not in output["local"]
local_item = json.loads(output["local"])["items"][0]
assert local_item["attachment"] == {
"filename": "phone.png", "contentType": "image/png", "stored": True
}
assert output["mirrored"]["lane"] == "authored"
assert output["mirrored"]["items"][0]["attachment"]["data"] == "PRIVATE-IMAGE-BYTES"
assert output["admission"]["durability"] == "background"
def test_screenshot_comment_failed_indexeddb_admission_is_not_accepted_locally():
script = f"""
const createAuthoredOutbox=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)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
reconcile:async()=>{{throw new Error('IndexedDB unavailable');}},requestSync:async()=>{{}},
}}}});
(async()=>{{
let error='';try{{await outbox.enqueueDurably({{kind:'issue-comment',repository:'o/r',number:1,
body:'Keep me',operationId:'image-op',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});}}
catch(caught){{error=caught.message;}}
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output == {"error": "IndexedDB unavailable", "items": []}
+37
View File
@@ -129,6 +129,43 @@ createBackgroundIssueSync({{store,fetchJson}}).flush().then(()=>process.stdout.w
assert all(len(key) <= 128 for key in keys)
def test_screenshot_comment_retry_reuses_upload_checkpoint_and_posts_combined_body_once():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'message-image',operationId:'message-image',ownerLogin:'timmy',status:'queued',
kind:'issue-comment',repository:'stackchain/dashboard',number:477,body:'Broken at 320px',
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}}}};
const state={{calls:[],released:0,completed:0}};let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{state.completed++;item=null;}},release:async()=>{{state.released++;item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
if(url.endsWith('/attachments'))return{{markdown:'![phone.png](https://forge.example/phone.png)'}};
if(commentAttempts++===0){{const error=new Error('Comment unavailable');error.status=503;throw error;}}
return{{id:91}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let firstError='';
try{{await sync.flush();}}catch(error){{firstError=error.message;}}
const checkpoint={{...item}};const second=await sync.flush();
process.stdout.write(JSON.stringify({{state,firstError,checkpoint,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "Comment unavailable"
assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.png]")
assert sum(call["url"].endswith("/attachments") for call in output["state"]["calls"]) == 1
comments = [call for call in output["state"]["calls"] if call["url"].endswith("/comments")]
assert [call["key"] for call in comments] == ["message-image:comment"] * 2
assert comments[-1]["body"] == {
"body": "Broken at 320px\n\n![phone.png](https://forge.example/phone.png)"
}
assert output["state"]["completed"] == 1
assert output["second"]["confirmed"] == [{"id": 91}]
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+24
View File
@@ -254,6 +254,30 @@ process.stdout.write(JSON.stringify({{
}
def test_comment_and_next_can_admit_a_complete_screenshot_message_before_advancing():
script = f"""
const createCommentNext=require({json.dumps(str(COMMENT_NEXT))});
const calls=[];
const controller=createCommentNext({{
post:()=>{{throw new Error('must not post');}},canQueue:()=>true,
queue:async message=>{{calls.push({{queue:message}});return{{item:{{id:'queued'}},background:true}};}},
accept:item=>calls.push({{accept:item.number}}),
complete:item=>{{calls.push({{complete:item.number}});return true;}},
}});
(async()=>{{const item={{kind:'issue',repository:'stackchain/dashboard',number:477}};
const result=await controller.admit(item,{{kind:'issue-comment',repository:item.repository,number:item.number,
body:'Mobile evidence',operationId:'image-op',attachment:{{filename:'phone.png',contentType:'image/png',data:'abc'}}}});
process.stdout.write(JSON.stringify({{result,calls}}));
}})();
"""
output = json.loads(run_node(script))
assert output["result"] == {
"accepted": True, "delivery": "queued", "background": True, "completed": True
}
assert output["calls"][0]["queue"]["attachment"]["data"] == "abc"
assert output["calls"][1:] == [{"accept": 477}, {"complete": 477}]
@pytest.mark.anyio
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
html = await dashboard()
+10
View File
@@ -252,3 +252,13 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
assert "uploads before the comment is posted" in readme
assert "New issue" in readme
assert "retry resumes with the confirmed issue" in readme
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
source = DASHBOARD.read_text()
assert "async function queueIssueScreenshotComment" in source
assert "attachment: await issueAttachmentController.serialize()" in source
assert "await authoredOutbox.enqueueDurably(message)" in source
assert "await issueCommentNext.admit(item, message)" in source
assert "navigator.onLine === false" in source