448 lines
19 KiB
Python
448 lines
19 KiB
Python
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_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_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 '<script src="static/unfiled-captures.js"></script>' 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 html
|
||
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[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" 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 "dependencies.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_drafts_expose_a_safe_sequential_filing_session():
|
||
html = await dashboard()
|
||
|
||
assert '<script src="static/draft-filing-session.js"></script>' 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 "dFS.start(item.capture_id)" in html
|
||
assert "await dFS.nextCapture()" 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-filing-session-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 "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
|