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 title = {value:'', focused:0, focus(){this.focused++;}}; const body = {value:''}; 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)}; 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'; body.value='Investigate mobile reports'; 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()]})); """) 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"], } 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"], "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 "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"}, }