import json import subprocess from pathlib import Path SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js" HTML = Path(__file__).parents[1] / "frontend" / "index.html" 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_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete(): script = f""" const createSync=require({json.dumps(str(SYNC))}); let remote={{revision:0,drafts:[]}}; function api(url,init={{}}) {{ if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote))); const body=JSON.parse(init.body); if (body.revision!==remote.revision) {{ const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error); }} remote={{revision:remote.revision+1,drafts:body.drafts}}; return Promise.resolve(JSON.parse(JSON.stringify(remote))); }} function device(initial=[]) {{ let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[]; const captures={{ exportOwned:async()=>drafts.map(item=>({{...item}})), mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}}, discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}}, }}; const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}}; const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}}); return {{sync,states,drafts:()=>drafts}}; }} (async()=>{{ const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]); await phone.sync.sync('timmy'); const desktop=device(); await desktop.sync.sync('timmy'); await desktop.sync.remove('field','timmy'); await phone.sync.sync('timmy'); process.stdout.write(JSON.stringify({{ remote,desktop:desktop.drafts(),phone:phone.drafts(), phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status), }})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output["remote"]["revision"] == 2 assert output["remote"]["drafts"] == [] assert output["desktop"] == [] assert output["phone"] == [] assert output["phoneStates"][-1] == "ready" assert output["desktopStates"][-1] == "ready" def test_unfiled_draft_sync_rebases_and_retries_a_revision_race_without_user_action(): script = f""" const createSync=require({json.dumps(str(SYNC))}); let calls=0, puts=0;const states=[]; let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}]; let remote={{revision:1,drafts:[]}}; const sync=createSync({{ captures:{{ exportOwned:async()=>drafts, reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}}, discard:async()=>true, }}, storage:{{getItem:()=>null,setItem:()=>{{}}}}, fetchJson:async(_url,init)=>{{ calls++; if(!init)return JSON.parse(JSON.stringify(remote)); puts++; const body=JSON.parse(init.body); if(puts===1){{ remote={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}}; const error=new Error('conflict');error.status=409; error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(remote))}}}}; throw error; }} if(body.revision!==remote.revision)throw new Error('stale retry'); remote={{revision:remote.revision+1,drafts:body.drafts}}; return JSON.parse(JSON.stringify(remote)); }}, onState:state=>states.push(state), }}); (async()=>{{ const result=await sync.sync('timmy'); process.stdout.write(JSON.stringify({{calls,puts,states,result,drafts,remote}})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output["result"] is True assert output["puts"] == 2 assert output["calls"] == 4 assert [item["id"] for item in output["remote"]["drafts"]] == ["remote", "local"] assert output["drafts"] == output["remote"]["drafts"] assert output["states"][-1]["status"] == "ready" def test_unfiled_draft_sync_bounds_conflicts_and_exposes_a_retry_path(): script = f""" const createSync=require({json.dumps(str(SYNC))}); let contended=true, puts=0; let drafts=[{{id:'local',title:'Local',body:'kept',saved_at:1}}]; let remote={{revision:1,drafts:[]}};const states=[]; const controller=createSync({{ captures:{{ exportOwned:async()=>JSON.parse(JSON.stringify(drafts)), reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}}, discard:async()=>true, }}, storage:{{getItem:()=>null,setItem:()=>{{}}}},getLogin:()=> 'timmy',maxConflictRetries:1, fetchJson:async(_url,init)=>{{ if(!init)return JSON.parse(JSON.stringify(remote)); puts++;const body=JSON.parse(init.body); if(contended){{ remote={{revision:remote.revision+1,drafts:remote.drafts}}; const error=new Error('conflict');error.status=409;throw error; }} remote={{revision:remote.revision+1,drafts:body.drafts}}; return JSON.parse(JSON.stringify(remote)); }}, onState:state=>states.push(state), }}); (async()=>{{ let rejected=false; try{{await controller.sync('timmy')}}catch(error){{rejected=error.status===409}} const afterExhaustion={{puts,rejected,state:states.at(-1),drafts:JSON.parse(JSON.stringify(drafts))}}; contended=false; const retried=await controller.retry(); process.stdout.write(JSON.stringify({{afterExhaustion,retried,puts,state:states.at(-1),drafts,remote}})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output["afterExhaustion"]["puts"] == 2 assert output["afterExhaustion"]["rejected"] is True assert output["afterExhaustion"]["state"] == { "status": "conflict", "message": "Draft sync needs attention. Retry when your devices finish saving.", "retryable": True, } assert output["afterExhaustion"]["drafts"][0]["body"] == "kept" assert output["retried"] is True assert output["state"]["status"] == "ready" assert output["drafts"] == output["remote"]["drafts"] def test_mounted_unfiled_draft_sync_shows_a_working_retry_action_after_exhaustion(): assert 'id="retry-unfiled-draft-sync"' in HTML.read_text() script = f""" let clickHandler;const status={{textContent:''}}; const button={{hidden:true,disabled:false,textContent:'Retry Draft sync',addEventListener:(_name,handler)=>clickHandler=handler}}; global.document={{querySelector:selector=>selector==='#retry-unfiled-draft-sync'?button:(selector==='#my-work-action-status'?status:null)}}; global.localStorage={{getItem:()=>null,setItem:()=>{{}}}}; global.setTimeout=()=>0;global.addEventListener=()=>{{}}; const createSync=require({json.dumps(str(SYNC))}); let contended=true;let remote={{revision:0,drafts:[]}}; const captures={{ currentLogin:()=> 'timmy',exportOwned:async()=>[{{id:'local',title:'Local',body:'',saved_at:1}}], reconcileRemote:async()=>{{}},discard:async()=>true, }}; const api=async(_url,init)=>{{ if(!init)return remote; if(contended){{remote={{revision:remote.revision+1,drafts:[]}};const error=new Error('conflict');error.status=409;throw error}} const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return remote; }}; (async()=>{{ const controller=createSync.mount(captures,api); try{{await controller.sync('timmy')}}catch(_error){{}} const exhausted={{hidden:button.hidden,label:button.textContent,status:status.textContent}}; contended=false; await clickHandler(); process.stdout.write(JSON.stringify({{exhausted,after:{{hidden:button.hidden,disabled:button.disabled,status:status.textContent}},remote}})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output["exhausted"] == { "hidden": False, "label": "Retry Draft sync", "status": "Draft sync needs attention. Retry when your devices finish saving.", } assert output["after"]["hidden"] is True assert output["after"]["disabled"] is False assert [draft["id"] for draft in output["remote"]["drafts"]] == ["local"] def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict(): script = f""" const createSync=require({json.dumps(str(SYNC))}); let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release; const gate=new Promise(resolve=>release=resolve); const api=async(_url,init)=>{{ if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}} puts++;const body=JSON.parse(init.body); if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}} remote={{revision:remote.revision+1,drafts:body.drafts}};return remote; }}; const draft={{id:'local',title:'Local',body:'',saved_at:1}}; const sync=createSync({{ captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}}, storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api, }}); (async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})(); """ output = run_node(script) assert output["puts"] == 1 assert output["remote"]["drafts"][0]["id"] == "local" assert output["results"] == [True, True] def test_unfiled_draft_sync_does_not_drop_an_account_transition_during_an_active_sync(): script = f""" const createSync=require({json.dumps(str(SYNC))}); let remote={{revision:0,drafts:[]}}, release; const gate=new Promise(resolve=>release=resolve);let firstGet=true;const exported=[]; const api=async(_url,init)=>{{ if(!init){{if(firstGet){{firstGet=false;await gate}}return JSON.parse(JSON.stringify(remote))}} const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return JSON.parse(JSON.stringify(remote)); }}; const captures={{ exportOwned:async owner=>{{exported.push(owner);return [{{id:owner,title:owner,body:'',saved_at:owner==='alice'?1:2}}]}}, reconcileRemote:async()=>{{}},discard:async()=>true, }}; const sync=createSync({{captures,fetchJson:api,storage:{{getItem:()=>null,setItem:()=>{{}}}}}}); (async()=>{{ const alice=sync.sync('alice');const bob=sync.sync('bob');release(); const results=await Promise.all([alice,bob]); process.stdout.write(JSON.stringify({{exported,results,remote}})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output["exported"] == ["alice", "bob"] assert output["results"] == [True, True] assert [draft["id"] for draft in output["remote"]["drafts"]] == ["bob", "alice"] def test_unfiled_draft_sync_preserves_divergent_same_draft_edits_as_a_conflict_copy(): script = f""" const createSync=require({json.dumps(str(SYNC))}); let remote={{revision:0,drafts:[]}}; async function api(_url,init) {{ if(!init) return JSON.parse(JSON.stringify(remote)); const body=JSON.parse(init.body); if(body.revision!==remote.revision) {{ const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error; }} remote={{revision:remote.revision+1,drafts:body.drafts}}; return JSON.parse(JSON.stringify(remote)); }} function device(initial=[]) {{ let drafts=JSON.parse(JSON.stringify(initial)); const values=new Map(), states=[]; const captures={{ exportOwned:async()=>JSON.parse(JSON.stringify(drafts)), mergeRemote:async items=>{{for(const item of items)if(!drafts.some(x=>x.id===item.id))drafts.push(JSON.parse(JSON.stringify(item))) }}, reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}}, discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}}, }}; const sync=createSync({{ captures,fetchJson:api, storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}}, createConflictId:()=> 'field-conflict-phone',onState:state=>states.push(state), }}); return {{sync,states,edit(body,saved_at){{drafts[0].body=body;drafts[0].saved_at=saved_at}},drafts:()=>drafts}}; }} (async()=>{{ const original={{id:'field',title:'Field failure',body:'original',saved_at:1}}; const phone=device([original]); await phone.sync.sync('timmy'); const desktop=device(); await desktop.sync.sync('timmy'); desktop.edit('desktop edit',2); await desktop.sync.sync('timmy'); await phone.sync.sync('timmy'); phone.edit('phone edit',3); desktop.edit('desktop second edit',4); await desktop.sync.sync('timmy'); await phone.sync.sync('timmy'); process.stdout.write(JSON.stringify({{remote,phone:phone.drafts(),states:phone.states}})); }})().catch(error=>{{console.error(error);process.exit(1)}}); """ output = run_node(script) assert output["remote"]["revision"] == 4 assert [(item["id"], item["body"]) for item in output["remote"]["drafts"]] == [ ("field", "desktop second edit"), ("field-conflict-phone", "phone edit"), ] assert output["remote"]["drafts"][1]["title"] == "Field failure (conflict copy)" assert output["phone"] == output["remote"]["drafts"] assert output["states"][-1] == { "status": "conflict-preserved", "message": "Draft changed on two devices ยท both versions were saved.", }