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({{}});
const titles=[
createUnfiledCaptures.displayTitle({{title:'Pump failure',hasAttachment:true}}),
createUnfiledCaptures.displayTitle({{title:'',hasAttachment:true}}),
];
process.stdout.write(JSON.stringify({{planned,minimal,titles}}));
"""
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",
},
"titles": ["Pump failure", "Untitled photo draft"],
}
def test_capture_title_focus_defers_phone_keyboard_until_draft_continuation():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const shouldFocus=createUnfiledCaptures.shouldFocusTitle;
process.stdout.write(JSON.stringify({{
freshPhone:shouldFocus({{mobile:true,continuing:false}}),
resumedPhoto:shouldFocus({{mobile:true,continuing:true}}),
freshDesktop:shouldFocus({{mobile:false,continuing:false}}),
}}));
"""
assert run_node(script) == {
"freshPhone": False,
"resumedPhoto": True,
"freshDesktop": True,
}
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_capture_saves_untitled_photo_only_draft_but_rejects_empty_capture():
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 photo={{filename:'field.jpg',contentType:'image/jpeg',blob:new Blob(['photo'],{{type:'image/jpeg'}}),note:'Pump label'}};
(async()=>{{
const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'photo-only'}});
const saved=await captures.save({{title:' ',body:'',attachments:[photo]}});
const listed=captures.list()[0];
const resumed=await captures.resume(saved.id,'timmy');
const remoteValues=new Map(),remoteBlobs=new Map();
const remote=createUnfiledCaptures({{
storage:{{getItem:k=>remoteValues.get(k)||null,setItem:(k,v)=>remoteValues.set(k,v)}},
attachmentStore:{{put:async(id,value)=>remoteBlobs.set(id,value),get:async id=>remoteBlobs.get(id),delete:async id=>remoteBlobs.delete(id)}},
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'unused'
}});
await remote.mergeRemote(await captures.exportOwned('timmy'),'timmy');
const remoteResumed=await remote.resume(saved.id,'timmy');
let emptyError='';
try {{ await captures.save({{title:' ',body:''}}); }} catch(error) {{ emptyError=error.message; }}
process.stdout.write(JSON.stringify({{saved,listed,resumedTitle:resumed.title,resumedEvidence:resumed.attachments.map(item=>item.filename),remoteTitle:remoteResumed.title,remoteEvidence:remoteResumed.attachments.map(item=>item.filename),emptyError}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["saved"]["title"] == ""
assert output["listed"]["title"] == ""
assert output["resumedTitle"] == ""
assert output["resumedEvidence"] == ["field.jpg"]
assert output["remoteTitle"] == ""
assert output["remoteEvidence"] == ["field.jpg"]
assert output["emptyError"] == "Add a title or photo before saving."
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 or photo 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 "title:unfiledDraftDisplayTitle(item)" in html
assert "item.hasAttachment ? 'Photo evidence 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, qs)" in html
assert "bindDraftCapacityDialog({" 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 "window.matchMedia?.('(max-width: 600px)').matches === true" in html
assert "unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})" 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