feat: attach screenshots during issue capture (#469)
CI / lint (pull_request) Successful in 56s
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 09:35:09 +00:00
1 parent 11341ec5d9
commit 5bf491c725
19 files changed
+488 -51

No files matched your search

+84
View File
@@ -64,6 +64,71 @@ const fetchJson = async (url, options = {{}}) => {{
}
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item = {{
id:'capture-image',operationId:'capture-image',ownerLogin:'timmy',status:'queued',
repository:'stackchain/dashboard',title:'Broken mobile layout',body:'At 320px',labelIds:[],
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}},
}};
const state = {{calls:[],completed:0,released:0}};
const store = {{
claimNext:async()=>item ? {{...item}} : null,
update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{state.completed+=1;item=null;}},
release:async()=>{{state.released+=1;item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0,
}};
let uploadAttempts=0;
const fetchJson=async(url,options={{}})=>{{
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
if(url==='api/v1/background-identity')return{{login:'timmy'}};
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
if(url.endsWith('/attachments'))return{{markdown:'![phone.png](<https://forge.example/a/phone.png>)'}};
if(url.endsWith('/comments'))return{{id:91}};
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
let firstError='';try{{await sync.flush();}}catch(error){{firstError=error.message;}}
const afterFirst={{...item}};
const second=await sync.flush();
process.stdout.write(JSON.stringify({{state,firstError,afterFirst,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "Upload unavailable"
assert output["afterFirst"]["deliveredIssue"]["number"] == 469
assert [call["url"] for call in output["state"]["calls"]].count(
"api/v1/repos/stackchain/dashboard/issues"
) == 1
assert output["state"]["calls"][-2]["key"] == "capture-image:attachment"
assert output["state"]["calls"][-1]["key"] == "capture-image:attachment-comment"
assert output["state"]["calls"][-1]["body"] == {
"body": "![phone.png](<https://forge.example/a/phone.png>)"
}
assert output["state"]["completed"] == 1
assert output["second"]["confirmed"][0]["number"] == 469
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const item={{id:'long',operationId:'x'.repeat(128),ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
const keys=[];let queued=true;
const store={{claimNext:async()=>queued?(queued=false,item):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};if(options.headers?.['Idempotency-Key'])keys.push(options.headers['Idempotency-Key']);if(url.endsWith('/issues'))return{{repository:'o/r',number:1}};if(url.endsWith('/attachments'))return{{markdown:'![a](url)'}};return{{id:1}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(()=>process.stdout.write(JSON.stringify(keys)));
"""
keys = run_node(script)
assert len(keys) == 3
assert len(set(keys)) == 3
assert all(len(key) <= 128 for key in keys)
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -289,6 +354,25 @@ const transaction=work=>{{const run=tail.then(()=>work({{
]
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
await store.update('capture',item=>({{...item,deliveredIssue:{{number:7}},attachmentMarkdown:'![a](url)'}}));
const result=await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachmentMarkdown"] == "![a](url)"
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+1 -1
View File
@@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v80" in worker
assert "stackchain-dashboard-shell-v81" in worker
+56
View File
@@ -54,6 +54,43 @@ controller.select(file);
}
def test_selected_screenshot_serializes_for_durable_issue_capture_without_uploading():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const calls=[];
const controller=attachment.create({{
readDataUrl:async file=>{{calls.push('read:'+file.name);return 'data:image/webp;base64,UklGRg==';}},
upload:async()=>{{calls.push('upload');}},
}});
controller.select({{name:'phone.webp',type:'image/webp',size:8}});
controller.serialize().then(value=>process.stdout.write(JSON.stringify({{value,calls}})));
"""
output = json.loads(run_node(script))
assert output == {
"value": {
"filename": "phone.webp",
"contentType": "image/webp",
"data": "UklGRg==",
},
"calls": ["read:phone.webp"],
}
def test_serialized_capture_attachment_can_be_restored_or_removed_while_editing():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
const controller=attachment.create({{readDataUrl:async()=>{{throw new Error('must not reread');}},upload:async()=>{{}}}});
controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0KGgo='}});
(async()=>{{const restored={{state:controller.state(),value:await controller.serialize()}};controller.clear();process.stdout.write(JSON.stringify({{restored,removed:await controller.serialize()}}));}})();
"""
output = json.loads(run_node(script))
assert output["restored"]["state"]["name"] == "saved.png"
assert output["restored"]["value"]["data"] == "iVBORw0KGgo="
assert output["removed"] is None
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
html = INDEX.read_text()
css = CSS.read_text()
@@ -69,6 +106,23 @@ def test_issue_composer_renders_thumb_reachable_screenshot_preview():
assert 'min-height:44px' in css
def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="create-issue-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachment: await createIssueAttachmentController.serialize()" in source
assert "createIssueAttachmentController.restore(queued.attachment);" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert "overflow-x:hidden" in css
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@@ -132,3 +186,5 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
assert "PNG, JPEG, or WebP" in readme
assert "2 MB" in readme
assert "uploads before the comment is posted" in readme
assert "New issue" in readme
assert "retry resumes with the confirmed issue" in readme
+82
View File
@@ -71,6 +71,29 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
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_persists_create_and_start_intent_and_returns_it_with_confirmation():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -154,6 +177,50 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul
assert output["remaining"][0]["status"] == "queued"
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={{}})=>{{
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
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:'![screen.png](<https://forge/a.png>)'}};
return{{id:8}};
}},
}});
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'abc'}}}});
(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
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:'![old](url)'}}]}}));
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))});
@@ -434,6 +501,21 @@ process.stdout.write(JSON.stringify(outbox.list()));
]
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:'![a](url)'}}]);
process.stdout.write(JSON.stringify(outbox.list()[0]));
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachmentMarkdown"] == "![a](url)"
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v80" in worker
assert "stackchain-dashboard-shell-v81" in worker
+1 -1
View File
@@ -35,7 +35,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v80" in worker
assert "stackchain-dashboard-shell-v81" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+10 -10
View File
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -385,7 +385,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/queue-today.js'" in source
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v80';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v81';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/today-sync.js'" in source