import json import subprocess from pathlib import Path import pytest from tests.dashboard_bundle import dashboard UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js" DRAFT_SESSION = Path(__file__).parents[1] / "frontend" / "draft-filing-session.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_draft_filing_session_navigates_stable_capture_ids_and_reconciles_removed_items(): script = f""" const createDraftFilingSession = require({json.dumps(str(DRAFT_SESSION))}); let captures = [{{id:'newest'}}, {{id:'middle'}}, {{id:'oldest'}}]; const session = createDraftFilingSession({{list:()=>captures}}); const opened = session.start('middle'); const skipped = session.next(); captures = [{{id:'newest'}}]; const afterFiled = session.removeCurrentAndNext('oldest'); const previous = session.previous(); process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}})); """ output = run_node(script) assert output == { "opened": {"id": "middle", "position": 2, "total": 3, "remaining": 1}, "skipped": {"id": "oldest", "position": 3, "total": 3, "remaining": 0}, "afterFiled": {"id": "newest", "position": 1, "total": 1, "remaining": 0}, "previous": {"id": "newest", "position": 1, "total": 1, "remaining": 0}, } def test_draft_filing_session_restores_all_saved_evidence(): script = f""" const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))}); const evidence=[{{filename:'one.png'}},{{filename:'two.png'}}]; const restored=[]; const elements=new Map(); const qs=selector=>{{ if(!elements.has(selector)) elements.set(selector,{{hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}}}); return elements.get(selector); }}; const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}}); session.attach(qs,{{ captures:{{resume:async()=>({{repository:'o/r',title:'Planned',body:'',labelIds:[],attachments:evidence}})}}, issueCapture:{{saveDraft:()=>{{}}}},attachment:{{restore:value=>restored.push(value),clear:()=>restored.push(null)}}, getLogin:()=>'timmy',setResumedId:()=>{{}},openSheet:async()=>{{}},setFilingMode:()=>{{}}, }}); session.start('planned'); (async()=>{{await session.nextCapture();process.stdout.write(JSON.stringify(restored));}})(); """ assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]] def test_ready_draft_summary_exposes_truthful_mobile_action_and_plan_details(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); const planned=createUnfiledCaptures.summary({{ filingPlan:{{repository:'stackchain/dashboard',completionIntent:'create-and-start',estimateMinutes:45}}, attachmentCount:2, }}); const minimal=createUnfiledCaptures.summary({{}}); process.stdout.write(JSON.stringify({{planned,minimal}})); """ assert run_node(script) == { "planned": { "label": "Ready to file", "repository": "stackchain/dashboard", "ready": True, "action": "Review & start", "details": "Create & start · 45 min · 2 screenshots", }, "minimal": { "label": "Needs filing", "repository": "", "ready": False, "action": "Choose repository", "details": "Create issue · No screenshots", }, } def test_ready_draft_opens_final_review_with_restored_intent_and_evidence(): script = f""" const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))}); const events=[]; const trigger={{id:'ready-action'}}; const resumed={{repository:'o/r',title:'Planned',body:'Evidence',labelIds:[7], completionIntent:'create-and-start',estimateMinutes:30, attachments:[{{filename:'one.png'}},{{filename:'two.png'}}]}}; const elements=new Map(); const qs=selector=>{{ if(!elements.has(selector)) elements.set(selector,{{id:selector.slice(1),hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}, requestSubmit:button=>events.push(['review',resumed,button.id])}}); return elements.get(selector); }}; const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}}); session.attach(qs,{{ captures:{{resume:async()=>resumed}}, capture:()=>({{saveDraft:value=>events.push(['save',value])}}), attachment:{{restore:value=>events.push(['evidence',value]),clear:()=>{{}}}}, getLogin:()=>'timmy',setResumedId:id=>events.push(['source',id]), openSheet:async()=>events.push(['sheet']),setFilingMode:()=>events.push(['filing']), review:async()=>{{events.push(['metadata']);events.push(['duplicates']);}}, }}); session.start('planned'); (async()=>{{await session.reviewCapture('planned',trigger);process.stdout.write(JSON.stringify({{ events,origin:session.takeTrigger().id }}));}})(); """ output = run_node(script) events = output["events"] assert [event[0] for event in events] == [ "save", "source", "sheet", "evidence", "filing", "metadata", "duplicates", "review" ] assert events[-1][1]["labelIds"] == [7] assert events[-1][1]["attachments"] == [ {"filename": "one.png"}, {"filename": "two.png"} ] assert events[-1][2] == "create-and-start-issue" assert output["origin"] == "ready-action" def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const values = new Map(); const storage = {{ getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key), }}; let sequence = 0; const captures = createUnfiledCaptures({{ storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'', createId:()=>String(++sequence), now:()=>1000 + sequence, }}); for (let index = 1; index <= 20; index += 1) {{ captures.save({{title:'Note ' + index, body:'Context ' + index}}); }} let fullError = ''; try {{ captures.save({{title:'Note 21', body:'Context 21'}}); }} catch (error) {{ fullError = error.message; }} const beforeReplace = captures.list(); const oldest = captures.capacity().oldest; const replacement = captures.replaceOldest({{title:'Note 21', body:'Context 21'}}, oldest.id); const offline = captures.list(); const restored = createUnfiledCaptures({{ storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy' }}).list(); process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement,offline,restored}})); """ output = run_node(script) assert output["fullError"] == "Drafts full — nothing was deleted." assert len(output["beforeReplace"]) == 20 assert output["beforeReplace"][-1]["title"] == "Note 1" assert output["oldest"]["title"] == "Note 1" assert len(output["offline"]) == 20 assert output["offline"][0]["title"] == "Note 21" assert output["offline"][-1]["title"] == "Note 2" assert all(item["quarantined"] for item in output["offline"]) assert all(not item["quarantined"] for item in output["restored"]) assert output["restored"][0]["ownerLogin"] == "timmy" def test_unfiled_capture_restores_ordered_evidence_bundle(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); const values=new Map(),blobs=new Map(); const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}}; const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}}),note:'Note '+name}}); (async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}}); const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}}); const resumed=await captures.resume(saved.id,'timmy'); process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),notes:resumed.attachments.map(x=>x.note),stored:blobs.get('bundle').attachments.map(x=>x.note)}}));}})(); """ output = run_node(script) assert output["listed"]["attachmentCount"] == 3 assert output["names"] == ["one", "two", "three"] assert output["notes"] == ["Note one", "Note two", "Note three"] assert output["stored"] == output["notes"] def test_unfiled_captures_export_and_import_ordered_evidence_between_devices(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); function device() {{ const values=new Map(), blobs=new Map(); return {{ captures:createUnfiledCaptures({{ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}, attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}}, getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42, }}), blobs, }}; }} (async()=>{{ const phone=device(), desktop=device(); const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}}); await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}}); const exported=await phone.captures.exportOwned('timmy'); const imported=await desktop.captures.mergeRemote(exported,'timmy'); const resumed=await desktop.captures.resume('phone-draft','timmy'); process.stdout.write(JSON.stringify({{ exported:exported[0], imported, names:resumed.attachments.map(item=>item.filename), notes:resumed.attachments.map(item=>item.note), bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())), blockers:resumed.blockers, }})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output["exported"]["id"] == "phone-draft" assert output["exported"]["saved_at"] == 42 assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"] assert output["imported"] == 1 assert output["names"] == ["one.png", "two.png"] assert output["notes"] == ["First", "Second"] assert output["bytes"] == ["one.png", "two.png"] assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}] def test_unfiled_captures_resume_complete_filing_plan_on_another_device(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); function device(id) {{ const values=new Map(); return createUnfiledCaptures({{ storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}}, getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>id,now:()=>42, }}); }} (async()=>{{ const phone=device('planned-draft'), desktop=device('unused'); await phone.save({{ repository:'stackchain/dashboard',title:'Restore my plan',body:'Do not make me plan twice', labelIds:[7,3,7],milestoneId:12,dueDate:'2026-08-21', templateName:'Bug report',templateId:'bug.yml',capturedBody:'Original field notes', assignee:'alexander',assigneeName:'Alexander',unassigned:false, estimateMinutes:45,completionIntent:'create-and-start', blockers:[{{repository:'stackchain/api',number:9,title:'API rollout'}}], }}); const exported=await phone.exportOwned('timmy'); await desktop.mergeRemote(exported,'timmy'); const resumed=await desktop.resume('planned-draft','timmy'); process.stdout.write(JSON.stringify({{exported:exported[0],resumed}})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output["exported"]["filing_plan"] == { "repository": "stackchain/dashboard", "label_ids": [7, 3], "milestone_id": 12, "due_date": "2026-08-21", "template_name": "Bug report", "template_id": "bug.yml", "captured_body": "Original field notes", "assignee": "alexander", "assignee_name": "Alexander", "estimate_minutes": 45, "completion_intent": "create-and-start", } assert output["resumed"] == { "repository": "stackchain/dashboard", "title": "Restore my plan", "body": "Do not make me plan twice", "labelIds": [7, 3], "milestoneId": 12, "dueDate": "2026-08-21", "templateName": "Bug report", "templateId": "bug.yml", "capturedBody": "Original field notes", "assignee": "alexander", "assigneeName": "Alexander", "estimateMinutes": 45, "completionIntent": "create-and-start", "blockers": [{"repository": "stackchain/api", "number": 9, "title": "API rollout"}], } def test_unfiled_captures_reconcile_remote_replaces_stale_content_and_evidence(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); const values=new Map(),blobs=new Map(); const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}}; const captures=createUnfiledCaptures({{ storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1, }}); (async()=>{{ await captures.save({{title:'Field failure',body:'phone edit',attachment:{{ filename:'phone.png',contentType:'image/png',blob:new Blob(['phone']) }}}}); await captures.reconcileRemote([{{ id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{ filename:'desktop.png',content_type:'image/png',note:'newer',data:'ZGVza3RvcA==' }}] }}], 'timmy'); const resumed=await captures.resume('field','timmy'); process.stdout.write(JSON.stringify({{ body:resumed.body,filename:resumed.attachments[0].filename,note:resumed.attachments[0].note, bytes:await resumed.attachments[0].blob.text(),count:captures.list().length, }})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output == { "body": "desktop edit", "filename": "desktop.png", "note": "newer", "bytes": "desktop", "count": 1, } def test_unfiled_captures_reconcile_rolls_back_evidence_when_metadata_write_fails(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); const values=new Map(),blobs=new Map();let failWrite=false; const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>{{if(failWrite)throw new Error('quota');values.set(k,v)}}}}; const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}}; const captures=createUnfiledCaptures({{ storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1, }}); (async()=>{{ await captures.save({{title:'Field failure',body:'phone edit',attachment:{{ filename:'phone.png',contentType:'image/png',blob:new Blob(['phone']) }}}}); failWrite=true;let error=''; try {{ await captures.reconcileRemote([{{ id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{ filename:'desktop.png',content_type:'image/png',data:'ZGVza3RvcA==' }}] }}], 'timmy'); }} catch (caught) {{ error=caught.message; }} const resumed=await captures.resume('field','timmy'); const proof=resumed.attachments?.[0] || resumed.attachment; process.stdout.write(JSON.stringify({{ error,body:resumed.body,filename:proof.filename, bytes:await proof.blob.text(), }})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output == { "error": "quota", "body": "phone edit", "filename": "phone.png", "bytes": "phone", } def test_unfiled_capture_restores_selected_blockers(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const captures=createUnfiledCaptures({{storage,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'blocked'}}); const blockers=[{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}]; const saved=captures.save({{title:'Blocked work',body:'Context',blockers}}); const resumed=captures.resume(saved.id,'timmy'); process.stdout.write(JSON.stringify({{listed:captures.list()[0],resumed}})); """ output = run_node(script) assert output["listed"]["blockerCount"] == 2 assert output["resumed"]["blockers"] == [ {"repository": "o/api", "number": 7, "title": "API ready"}, {"repository": "o/web", "number": 8, "title": "Web ready"}, ] def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const values = new Map(); const blobs = new Map(); const deleted = []; const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const attachmentStore = {{ put: async (id, attachment) => blobs.set(id, attachment), get: async id => blobs.get(id) || null, delete: async id => {{ deleted.push(id); blobs.delete(id); }}, }}; (async () => {{ let id = 0; const captures = createUnfiledCaptures({{ storage, attachmentStore, maxItems:2, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id, }}); const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}}); const first = await captures.save({{title:'First',body:'one',attachment:image('first.png')}}); const second = await captures.save({{title:'Second',body:'two',attachment:image('second.png')}}); let mismatch = ''; try {{ await captures.replaceOldest({{title:'Third',body:'three'}}, second.id); }} catch (error) {{ mismatch = error.message; }} const third = await captures.replaceOldest({{title:'Third',body:'three',attachment:image('third.png')}}, first.id); process.stdout.write(JSON.stringify({{ mismatch, titles:captures.list().map(item=>item.title), deleted, blobs:[...blobs.keys()], third:third.title, }})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ output = run_node(script) assert output == { "mismatch": "Drafts changed. Review them before replacing anything.", "titles": ["Third", "Second"], "deleted": ["1"], "blobs": ["2", "3"], "third": "Third", } def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const values = new Map(); const blobs = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const attachmentStore = {{ put: async (id, attachment) => blobs.set(id, attachment), get: async id => blobs.get(id) || null, delete: async id => {{ if (id === '1') throw new Error('IndexedDB delete failed'); blobs.delete(id); }}, }}; (async () => {{ let id = 0; const captures = createUnfiledCaptures({{ storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id, }}); const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}}); const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}}); let error = ''; try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }} catch (caught) {{ error = caught.message; }} const resumed = await captures.resume(original.id, 'timmy'); process.stdout.write(JSON.stringify({{ error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()], resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename, }})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ output = run_node(script) assert output == { "error": "IndexedDB delete failed", "titles": ["Original"], "blobs": ["1"], "resumedTitle": "Original", "resumedFilename": "original.png", } def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const values = new Map(); const blobs = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const attachmentStore = {{ put: async (id, attachment) => blobs.set(id, attachment), get: async id => blobs.get(id) || null, delete: async () => {{ throw new Error('IndexedDB delete failed'); }}, }}; (async () => {{ const captures = createUnfiledCaptures({{ storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>'capture-1', now:()=>42, }}); await captures.save({{title:'Keep this',body:'Important',attachment:{{ filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}), }}}}); let error = ''; try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }} const resumed = await captures.resume('capture-1', 'timmy'); process.stdout.write(JSON.stringify({{ error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename, }})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ output = run_node(script) assert output == { "error": "IndexedDB delete failed", "titles": ["Keep this"], "resumedTitle": "Keep this", "resumedFilename": "proof.png", } def test_discard_metadata_write_failure_restores_attachment_and_draft(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const values = new Map(); const blobs = new Map(); let failWrites = false; const storage = {{ getItem:k=>values.get(k)||null, setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); values.set(k,v); }}, }}; const attachmentStore = {{ put: async (id, attachment) => blobs.set(id, attachment), get: async id => blobs.get(id) || null, delete: async id => blobs.delete(id), }}; (async () => {{ const captures = createUnfiledCaptures({{ storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>'capture-1', now:()=>42, }}); await captures.save({{title:'Keep this',body:'Important',attachment:{{ filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}), }}}}); failWrites = true; let error = ''; try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }} failWrites = false; const resumed = await captures.resume('capture-1', 'timmy'); process.stdout.write(JSON.stringify({{ error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()], resumedFilename:resumed.attachment.filename, }})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ output = run_node(script) assert output == { "error": "Storage write failed", "titles": ["Keep this"], "blobIds": ["capture-1"], "resumedFilename": "proof.png", } def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); 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 id = 0; const captures = createUnfiledCaptures({{ storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id }}); const first = captures.save({{title:'First',body:'One'}}); const second = captures.save({{title:'Second',body:'Two'}}); let mismatch = ''; try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }} const resumed = captures.resume(first.id, 'timmy'); captures.completeResume(first.id); process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}})); """ output = run_node(script) assert output["mismatch"] == "Reconnect with the account that saved this capture." assert output["resumed"] == {"repository": "", "title": "First", "body": "One", "labelIds": []} assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]] def test_unfiled_capture_durably_restores_screenshot_before_explicit_handoff_completion(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const values = new Map(); const blobs = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const attachmentStore = {{ put: async (id, attachment) => blobs.set(id, attachment), get: async id => blobs.get(id) || null, delete: async id => blobs.delete(id), }}; (async () => {{ const captures = createUnfiledCaptures({{ storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>'capture-1', now:()=>42, }}); const screenshot = {{filename:'phone.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}; const saved = await captures.save({{title:'Broken mobile layout',body:'At 320px',attachment:screenshot}}); const listed = captures.list(); const resumed = await captures.resume(saved.id, 'timmy'); const beforeComplete = captures.list().length; await captures.completeResume(saved.id); process.stdout.write(JSON.stringify({{ listed, beforeComplete, afterComplete:captures.list().length, resumed:{{title:resumed.title,body:resumed.body,filename:resumed.attachment.filename, contentType:resumed.attachment.contentType,size:resumed.attachment.blob.size}}, blobRemoved:!blobs.has(saved.id), }})); }})().catch(error => {{ console.error(error); process.exit(1); }}); """ output = run_node(script) assert output["listed"][0]["hasAttachment"] is True assert "attachment" not in output["listed"][0] assert output["beforeComplete"] == 1 assert output["afterComplete"] == 0 assert output["blobRemoved"] is True assert output["resumed"] == { "title": "Broken mobile layout", "body": "At 320px", "filename": "phone.png", "contentType": "image/png", "size": 5, } def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const captures = createUnfiledCaptures({{storage,getCaptureLogin:()=>''}}); const errors = []; for (const note of [{{title:'',body:'context'}},{{title:'Work',body:'context'}}]) {{ try {{ captures.save(note); }} catch (error) {{ errors.push(error.message); }} }} process.stdout.write(JSON.stringify({{errors,size:values.size}})); """ output = run_node(script) assert output == { "errors": ["Add a title before saving.", "Offline identity is unavailable. Reconnect once before saving private work."], "size": 0, } @pytest.mark.anyio async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume_flow(): html = await dashboard() assert '' in html assert 'id="save-unfiled-issue"' in html assert 'Save to Drafts' in html assert "createUnfiledCaptures({" in html assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html assert "unfiledCaptures.save(captureDraft)" in html assert "const savedCapture = await unfiledCaptures.save(captureDraft)" in html assert "mobileTaskDock.select('queues')" in html assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html assert "requestAnimationFrame(() =>" in html assert "savedCard?.scrollIntoView({block:'nearest'})" in html assert "Saved to Drafts. Choose a repository when you’re ready to file it." in UNFILED.read_text() assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text() assert "await createIssueAttachmentController.serialize()" in html assert "createUnfiledAttachmentStore()" in html assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text() assert "await unfiledCaptures.completeResume(rUC)" in html assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text() assert "item.kind === 'unfiled-issue'" in html assert '.create-issue-actions button { min-height:44px;' in html assert '.draft-card:focus-visible' in html assert 'scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom))' in html assert '@media(max-width:320px)' in html @pytest.mark.anyio async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning(): html = await dashboard() feature = UNFILED.read_text() assert "const captureDraft = currentIssueCaptureDraft();" in html assert "return withFilingEstimate(issueTemplatePicker.fields(" in html assert "draft.estimateMinutes = Number(value) || null" in feature assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html assert "qs('#file-new-issue').hidden = enabled" in feature assert "qs('.create-issue-capture-actions').hidden = false" in feature assert "label:ready ? 'Ready to file' : 'Needs filing'" in feature assert "repository:plan.repository || ''" in feature assert "Saved planned Draft" in feature @pytest.mark.anyio async def test_mobile_ready_draft_card_opens_validated_final_review_in_one_tap(): html = await dashboard() assert "isUnfiled ? item.action" in html assert "[item.label, item.repository, item.details].filter(Boolean).join(' · ')" in html assert "escapeHtml([item.label" in html assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html assert "capture:()=>issueCapture" in html assert "review:r=>loadIssueFilingMetadata(r.repository,r)" in html assert ".then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates)" in html assert "qs('#create-issue-form').requestSubmit(submitter)" in DRAFT_SESSION.read_text() assert "dFS.takeTrigger() || event.submitter || qs('#submit-new-issue')" in html assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }' in html assert '@media(max-width:320px)' in html @pytest.mark.anyio async def test_mobile_drafts_expose_a_safe_sequential_filing_session(): html = await dashboard() assert '' in html assert 'id="draft-filing-session"' in html assert 'id="draft-filing-progress"' in html assert 'id="previous-draft"' in html assert 'id="skip-draft"' in html assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html assert "await dFS.advance(fS.id)" in html feature = DRAFT_SESSION.read_text() assert "Draft ${state.position} of ${state.total}" in feature assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html @pytest.mark.anyio async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision(): html = await dashboard() assert 'id="draft-capacity-sheet"' in html assert 'Drafts full — nothing was deleted.' in html assert 'id="review-full-drafts"' in html assert 'id="replace-oldest-draft"' in html assert 'id="keep-editing-draft"' in html assert "showDraftCapacityDialog(unfiledCaptures)" in html feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text() assert "const draft = currentIssueCaptureDraft();" in feature assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html assert '.draft-capacity-actions button { min-height:44px;' in html @pytest.mark.anyio async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_fields(): html = await dashboard() assert 'id="create-issue-heading">Capture work' in html assert 'class="create-issue-filing" id="create-issue-filing" hidden' in html assert 'id="save-unfiled-issue" type="button">Save to Drafts' in html assert 'id="file-new-issue" type="button">File now' in html assert "function setIssueFilingMode(enabled)" in html assert "setIssueFilingMode(Boolean(captureDraft.repository))" in html assert "qs('#create-issue-title').focus();" in html assert "qs('#file-new-issue').addEventListener('click'" in html assert '.create-issue-capture-actions button { min-height:44px;' in html assert '.create-issue-capture-actions[hidden] { display:none;' in html attachment = html.index('class="create-issue-attachment"') filing = html.index('class="create-issue-filing"') assert attachment < filing