stackchain-dashboard/tests/test_progressive_capture.py
timmy 8b56618e62
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m18s
CI / release-candidate (pull_request) Has been skipped
feat: recover progressive mobile captures (Closes #1409)
2026-08-25 21:29:50 +00:00

300 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 youre 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"},
}