import json import subprocess from pathlib import Path import pytest from tests.dashboard_bundle import dashboard LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js" LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js" def run_node(script): return json.loads( subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True ).stdout ) def test_offline_deferral_replays_once_and_adopts_server_records(): script = f""" const createLaterSync = require({json.dumps(str(LATER_SYNC))}); const values = new Map(); const requests = []; let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}}; const sync = createLaterSync({{ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}, getLogin:()=> 'timmy', createOperationId:()=> 'offline-op', fetchJson:async (url,options={{}})=>{{ requests.push({{url,body:options.body&&JSON.parse(options.body)}}); if (!options.method) return remote; const operation=JSON.parse(options.body); remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}}; return remote; }}, onRemoteRecords:records=>{{globalThis.records=records}}, onStatus:status=>{{globalThis.status=status}}, }}); sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z'); (async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{ requests,records:globalThis.records,status:globalThis.status,pending:sync.pending() }}));}})(); """ result = run_node(script) assert [request["url"] for request in result["requests"]] == [ "api/v1/later", "api/v1/later", "api/v1/later", ] assert result["requests"][1]["body"] == { "operation_id": "offline-op", "action": "defer", "item_id": "issue:r:2:", "wake_at": "2026-08-10T09:00:00.000Z", } assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z" assert result["status"] == "saved" assert result["pending"] == [] def test_latest_offline_intent_wins_and_failed_delivery_stays_pending(): script = f""" const createLaterSync = require({json.dumps(str(LATER_SYNC))}); const values=new Map(); let sequence=0; const sync=createLaterSync({{ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}, getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence), fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}}, }}); sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z'); sync.enqueue('restore','issue:r:2:'); (async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})(); """ assert run_node(script) == { "pending": [ { "operation_id": "op-2", "action": "restore", "item_id": "issue:r:2:", "wake_at": None, } ], "status": "pending", } def test_change_queued_during_delivery_is_drained_before_flush_settles(): script = f""" const createLaterSync=require({json.dumps(str(LATER_SYNC))}); const values=new Map(); const actions=[]; let sequence=0; let releaseFirst; const sync=createLaterSync({{ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}, getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence), fetchJson:async (_url,options={{}})=>{{ if (!options.method) return {{revision:0,records:{{}}}}; const operation=JSON.parse(options.body); actions.push(operation.action); if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve); return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}}; }},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}}, }}); sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z'); (async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve(); sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing; process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})(); """ assert run_node(script) == { "actions": ["defer", "restore"], "pending": [], "records": {}, } def test_tabs_reject_an_older_snapshot_after_a_newer_revision(): script = f""" const createLaterSync=require({json.dumps(str(LATER_SYNC))}); const values=new Map(); const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const listeners=[]; const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}}); let resolveOld,resolveNew; const oldHistory=[],newHistory=[]; const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}}); const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}}); (async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve(); resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush; resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush; process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})(); """ result = run_node(script) assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"} assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"} def test_existing_browser_records_migrate_once_and_lifecycle_replays(): script = f""" const createLaterSync=require({json.dumps(str(LATER_SYNC))}); const values=new Map(); const handlers={{}}; let sequence=0; const requests=[]; const sync=createLaterSync({{ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}, getLogin:()=> 'timmy',createOperationId:()=> 'migration-'+(++sequence), fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}}, onRemoteRecords:()=>{{}},onStatus:()=>{{}}, }}); const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}}; const first=sync.migrate(records),second=sync.migrate(records); sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}}); (async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})(); """ assert run_node(script) == { "first": True, "second": False, "requests": ["GET", "PATCH"], "pending": [], } def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry(): script = f""" const createLaterWork=require({json.dumps(str(LATER_WORK))}); const values=new Map(); const changes=[]; const expired=[]; let clock=new Date('2026-08-08T12:00:00Z'); const item={{kind:'issue',repository:'stackchain/api',number:17}}; const work=createLaterWork({{ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}, getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}}, onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]), onExpire:ids=>expired.push(ids), }}); work.defer(item,new Date('2026-08-08T16:00:00Z')); work.restore(item); work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}}); const remote=work.partition([item]); clock=new Date('2026-08-09T09:00:01Z'); const awake=work.partition([item]); process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}})); """ result = run_node(script) assert result["changes"] == [ ["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"], ["restore", "issue:stackchain/api:17:", None], ] assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z" assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}] assert result["expired"] == [["issue:stackchain/api:17:"]] @pytest.mark.anyio async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): html = await dashboard() assert '' in html assert 'id="later-sync-status"' in html assert "const laterSync = createLaterSync({" in html assert "onRemoteRecords: records =>" in html assert "onChange: (action, itemId, wakeAt) =>" in html assert "onExpire: ids =>" in html assert "laterSync.enqueue(action, itemId, wakeAt)" in html assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html assert "laterSync.migrate(laterWork.read());" in html assert "laterSync.flush();" in html assert "Later saved to account." in html def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() assert "stackchain-dashboard-shell-v48" in source assert "BASE + 'static/later-sync.js'" in source