import json import subprocess from pathlib import Path MODULE = Path(__file__).parents[1] / "frontend" / "progressive-capture.js" UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js" DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" def run_capture(scenario: str) -> dict: harness = f""" const createProgressiveCapture = require({json.dumps(str(MODULE))}); const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); (async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}}); """ completed = subprocess.run( ["node", "-e", harness], check=True, capture_output=True, text=True ) return json.loads(completed.stdout) def test_progressive_mobile_new_opens_and_saves_one_account_bound_draft(): result = run_capture(r""" const listeners = new Map(); const button = {dataset:{mobileTask:'new'}, addEventListener(name, callback){listeners.set('new:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('new:'+name)===callback) listeners.delete('new:'+name);}, setAttribute(){}, removeAttribute(){}}; const save = {disabled:false, addEventListener(name, callback){listeners.set('save:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('save:'+name)===callback) listeners.delete('save:'+name);}}; const file = {disabled:false, addEventListener(){}, removeEventListener(){}}; const cancel = {addEventListener(){}, removeEventListener(){}}; const field = name => ({value:'', focused:0, focus(){this.focused++;}, addEventListener(event, callback){listeners.set(name+':'+event, callback);}, removeEventListener(event, callback){if(listeners.get(name+':'+event)===callback) listeners.delete(name+':'+event);}, }); const title = field('title'); const body = field('body'); const heading = {textContent:''}; const status = {textContent:''}; const classes = new Set(); const root = {classList:{add:name=>classes.add(name), remove:name=>classes.delete(name), contains:name=>classes.has(name)}}; const nodes = { '#create-issue-sheet':root, '#create-issue-title':title, '#create-issue-body':body, '#create-issue-heading':heading, '#save-unfiled-issue':save, '#file-new-issue':file, '#cancel-new-issue':cancel, '#my-work-action-status':status, }; const document = { querySelector(selector){return selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null);}, }; const values = new Map(); const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const flow = createProgressiveCapture({document, storage, getLogin:()=> 'timmy', createCaptures:createUnfiledCaptures, createId:()=> 'draft-1', now:()=> 42}); flow.start(); listeners.get('new:click')({currentTarget:button}); title.value='Production outage'; listeners.get('title:input')(); body.value='Investigate mobile reports'; listeners.get('body:input')(); await listeners.get('save:click')(); await listeners.get('save:click')(); const stored=JSON.parse(values.get('stackchain.unfiled-issues.v1')); console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()],checkpoint:values.get('stackchain.progressive-capture.v1')||null})); """) assert result == { "open": False, "focused": 1, "heading": "Capture work", "status": "Saved to Drafts. Choose a repository when you’re ready to file it.", "items": [{ "id": "draft-1", "ownerLogin": "timmy", "title": "Production outage", "body": "Investigate mobile reports", "savedAt": 42, }], "listeners": ["new:click", "save:click", "title:input", "body:input"], "checkpoint": None, } def test_failed_progressive_save_keeps_rendered_fields_and_checkpoint_for_retry(): result = run_capture(r""" const listeners={}; const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){this.focused=true;}}); const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); const title=control('title'); const body=control('body'); const status={textContent:''}; const classes=new Set(); const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}}; const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, '#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file, '#cancel-new-issue':cancel,'#my-work-action-status':status}; const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]}; const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:()=>({save(){throw new Error('Phone storage is full.');}})}); flow.start(); listeners['new:click'](); title.value='Keep title'; listeners['title:input'](); body.value='Keep note'; listeners['body:input'](); const saved=await listeners['save:click'](); console.log(JSON.stringify({saved,open:classes.has('open'),title:title.value,body:body.value,status:status.textContent, checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1'))})); """) assert result == { "saved": False, "open": True, "title": "Keep title", "body": "Keep note", "status": "Phone storage is full.", "checkpoint": { "version": 1, "items": [{ "ownerLogin": "timmy", "title": "Keep title", "body": "Keep note", }], }, } def test_progressive_capture_restores_latest_checkpoint_only_for_its_account(): result = run_capture(r""" const values = new Map(); const storage = { getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value), removeItem:key=>values.delete(key), }; function mount(login) { const listeners = new Map(); const control = name => ({disabled:false,value:'', addEventListener(event, callback){listeners.set(name+':'+event, callback);}, removeEventListener(){},focus(){}, }); const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); const title=control('title'); const body=control('body'); const status={textContent:''}; const classes=new Set(); const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}}; const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, '#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file, '#cancel-new-issue':cancel,'#my-work-action-status':status}; const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]}; const flow=createProgressiveCapture({document,storage,getLogin:()=>login,createCaptures:createUnfiledCaptures}); flow.start(); return {flow,listeners,title,body,status,button}; } const first=mount('timmy'); first.listeners.get('new:click')(); first.title.value='Production outage'; first.listeners.get('title:input')?.(); first.body.value='Latest phone note'; first.listeners.get('body:input')?.(); const restored=mount('timmy'); restored.listeners.get('new:click')(); const other=mount('alexander'); other.listeners.get('new:click')(); other.title.value='Alexander note'; other.listeners.get('title:input')?.(); const timmyAgain=mount('timmy'); timmyAgain.listeners.get('new:click')(); console.log(JSON.stringify({ restored:{title:restored.title.value,body:restored.body.value,status:restored.status.textContent}, other:{title:other.title.value,body:other.body.value,status:other.status.textContent}, timmyAgain:{title:timmyAgain.title.value,body:timmyAgain.body.value}, checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1') || 'null'), })); """) assert result == { "restored": { "title": "Production outage", "body": "Latest phone note", "status": "Unfinished capture restored from this phone.", }, "other": {"title": "Alexander note", "body": "", "status": ""}, "timmyAgain": {"title": "Production outage", "body": "Latest phone note"}, "checkpoint": { "version": 1, "items": [ {"ownerLogin": "timmy", "title": "Production outage", "body": "Latest phone note"}, {"ownerLogin": "alexander", "title": "Alexander note", "body": ""}, ], }, } def test_hydration_handoff_clears_checkpoint_only_after_explicit_completion(): result = run_capture(r""" const listeners={}; const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){}}); const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); const title=control('title'); const body=control('body'); const root={classList:{add(){},remove(){},contains:()=>true}}; const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, '#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file, '#cancel-new-issue':cancel,'#my-work-action-status':{textContent:''}}; const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]}; const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:createUnfiledCaptures}); flow.start(); title.value='Hand off'; listeners['title:input'](); body.value='Preserve me'; listeners['body:input'](); const handoff=flow.handoff(); const before=JSON.parse(values.get('stackchain.progressive-capture.v1')); if (typeof handoff.complete === 'function') handoff.complete(); console.log(JSON.stringify({before,after:values.get('stackchain.progressive-capture.v1')||null})); """) assert result == { "before": { "version": 1, "items": [{ "ownerLogin": "timmy", "title": "Hand off", "body": "Preserve me", }], }, "after": None, } def test_progressive_file_now_preserves_capture_on_failure_and_hands_it_off_once(): result = run_capture(r""" const listeners = new Map(); const control = name => ({disabled:false, addEventListener(event, callback){listeners.set(name+':'+event, callback);}, removeEventListener(event, callback){if(listeners.get(name+':'+event)===callback) listeners.delete(name+':'+event);}, focus(){this.focused=(this.focused||0)+1;}, }); const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); const title=control('title'); title.value='Keep this'; const body={value:'Do not lose this note'}; const status={textContent:''}; const heading={textContent:''}; const classes=new Set(); const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}}; const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, '#create-issue-heading':heading,'#save-unfiled-issue':save,'#file-new-issue':file, '#cancel-new-issue':cancel,'#my-work-action-status':status}; const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null)}; let requests=0; const flow=createProgressiveCapture({ document,storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',createCaptures:createUnfiledCaptures, requestFullWorkspace:async()=>{requests++;throw new Error('offline');}, }); flow.start(); listeners.get('new:click')({currentTarget:button}); const filed=await listeners.get('file:click')(); const before=Array.from(listeners.keys()).sort(); const handoff=flow.handoff(); const second=flow.handoff(); console.log(JSON.stringify({filed,requests,open:classes.has('open'),status:status.textContent, fileDisabled:file.disabled,focused:title.focused,title:title.value,body:body.value,before, after:Array.from(listeners.keys()),handoff,second})); """) assert result == { "filed": False, "requests": 1, "open": True, "status": "Filing tools unavailable. Your capture is still editable; retry when connected.", "fileDisabled": False, "focused": 2, "title": "Keep this", "body": "Do not lose this note", "before": ["cancel:click", "file:click", "new:click", "save:click", "title:input"], "after": [], "handoff": {"open": True, "title": "Keep this", "body": "Do not lose this note"}, "second": None, } def test_hydrated_dashboard_adopts_open_progressive_capture_without_losing_fields(): source = DASHBOARD.read_text() assert "const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();" in source assert "issueCapture.saveDraft({repository:'', labelIds:[]," in source assert "title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body" in source assert "progressiveCaptureHandoff.complete?.();" in source assert "await openCreateIssueSheet(false);" in source def test_progressive_capture_is_mobile_only_and_hides_unhydrated_filing_controls(): source = MODULE.read_text() css = CSS.read_text() assert "window.matchMedia?.('(max-width: 600px)').matches === true" in source assert "root?.classList.add('progressive-capture')" in source assert "root?.classList.remove('progressive-capture')" in source assert ".create-issue-sheet.progressive-capture .mobile-create-issue-nav" in css assert ".create-issue-sheet.progressive-capture .create-issue-attachment" in css assert ".create-issue-sheet.progressive-capture .create-issue-filing" in css assert ".create-issue-sheet.progressive-capture #switch-to-create-pull" in css def test_successful_filing_handoff_reenables_the_hydrated_file_control(): result = run_capture(r""" const listeners={}; const control=name=>({disabled:false,addEventListener(event,callback){listeners[name+event]=callback;},removeEventListener(){},focus(){}}); const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); const title={value:'Ready to file',focus(){}}; const body={value:'Context'}; const status={textContent:''}; const classes=new Set(); const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}}; const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, '#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file, '#cancel-new-issue':cancel,'#my-work-action-status':status}; const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]}; const flow=createProgressiveCapture({document,storage:{getItem:()=>null,setItem(){}},getLogin:()=>'timmy', createCaptures:createUnfiledCaptures,requestFullWorkspace:async()=>true}); flow.start(); listeners.newclick({currentTarget:button}); const filed=await listeners.fileclick(); const before=file.disabled; const handoff=flow.handoff(); console.log(JSON.stringify({filed,before,after:file.disabled,handoff})); """) assert result == { "filed": True, "before": True, "after": False, "handoff": {"open": True, "title": "Ready to file", "body": "Context"}, }