import json import subprocess from pathlib import Path SOURCE = Path(__file__).parents[1] / "frontend" / "today-session-sync.js" def run_node(script: str) -> dict: completed = subprocess.run( ["node", "-e", SOURCE.read_text() + "\n" + script], check=True, capture_output=True, text=True, ) return json.loads(completed.stdout) def test_second_device_sees_and_claims_the_running_session(): result = run_node( r""" const calls = []; const remote = {revision:3, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:90000, running:true, updated_at:10}; let adopted = null; const offers = []; const sync = createTodaySessionSync({ getDeviceId:()=>'phone-b', fetchJson:async (url, options={}) => { calls.push({url, body:options.body ? JSON.parse(options.body) : null}); return options.method === 'PATCH' ? {...remote, revision:4, device_id:'phone-b'} : remote; }, timer:{adopt:(identity, elapsed, running)=>{adopted={identity, elapsed, running};}}, onRemote:session=>{offers.push(session);}, }); (async()=>{ await sync.refresh(); const claimed = await sync.claim(); process.stdout.write(JSON.stringify({offers, claimed, adopted, calls})); })().catch(error=>{console.error(error);process.exit(1);}); """ ) assert result["offers"][0]["device_id"] == "desktop-a" assert result["claimed"]["device_id"] == "phone-b" assert result["adopted"] == { "identity": "issue:r:42:", "elapsed": 90000, "running": True, } assert result["calls"][1] == { "url": "api/v1/today/session", "body": { "base_revision": 3, "device_id": "phone-b", "identity": "issue:r:42:", "elapsed_ms": 90000, "running": True, }, } def test_previous_owner_pauses_after_another_device_claims(): result = run_node( r""" let response = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true}; let pauses = 0; let transferred = null; const sync = createTodaySessionSync({ getDeviceId:()=> 'desktop-a', fetchJson:async()=>response, timer:{pause:()=>{pauses += 1;}}, onTransferred:session=>{transferred=session;}, }); (async()=>{ await sync.refresh(); response = {...response, revision:2, device_id:'phone-b', elapsed_ms:2500}; await sync.refresh(); await sync.refresh(); process.stdout.write(JSON.stringify({pauses, transferred})); })().catch(error=>{console.error(error);process.exit(1);}); """ ) assert result["pauses"] == 1 assert result["transferred"]["device_id"] == "phone-b" def test_owner_poll_publishes_current_elapsed_time(): result = run_node( r""" const calls = []; let tick = null; let elapsed = 1000; const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true}; const sync = createTodaySessionSync({ getDeviceId:()=> 'desktop-a', fetchJson:async (url, options={}) => { calls.push({method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}); return options.method === 'PATCH' ? {...owned, revision:2, elapsed_ms:elapsed} : owned; }, timer:{snapshot:()=>({identity:owned.identity, elapsed_ms:elapsed, running:true})}, setInterval:callback=>{tick=callback; return 7;}, }); (async()=>{ await sync.refresh(); sync.start(); elapsed = 6500; await tick(); process.stdout.write(JSON.stringify(calls)); })().catch(error=>{console.error(error);process.exit(1);}); """ ) assert result[-1] == { "method": "PATCH", "body": { "base_revision": 1, "device_id": "desktop-a", "identity": "issue:r:42:", "elapsed_ms": 6500, "running": True, }, } def test_publish_conflict_adopts_remote_owner_and_pauses_once(): result = run_node( r""" const calls = []; const owned = {revision:2, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true}; const remote = {...owned, revision:3, device_id:'phone-b', elapsed_ms:2500}; let pauses = 0; const transfers = []; const statuses = []; let reads = 0; const sync = createTodaySessionSync({ getDeviceId:()=> 'desktop-a', fetchJson:async (url, options={}) => { calls.push(options.method || 'GET'); if (options.method === 'PATCH') { const error = new Error('session changed'); error.status = 409; error.code = 'session_changed'; throw error; } reads += 1; return reads === 1 ? owned : remote; }, timer:{ snapshot:()=>({identity:owned.identity, elapsed_ms:1500, running:true}), pause:()=>{pauses += 1;}, }, onTransferred:session=>transfers.push(session.device_id), onStatus:status=>statuses.push(status), }); (async()=>{ await sync.refresh(); await sync.publish(); await sync.refresh(); process.stdout.write(JSON.stringify({calls, pauses, transfers, statuses, session:sync.session()})); })().catch(error=>{console.error(error);process.exit(1);}); """ ) assert result["calls"] == ["GET", "PATCH", "GET", "GET"] assert result["session"]["device_id"] == "phone-b" assert result["pauses"] == 1 assert result["transfers"] == ["phone-b"] assert "conflict" in result["statuses"] def test_rapid_publishes_are_single_flight_and_coalesce_latest_snapshot(): result = run_node( r""" const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:0, running:true}; const bodies = []; let active = 0; let peak = 0; let releaseFirst; let patchCount = 0; const firstPatch = new Promise(resolve=>{releaseFirst=resolve;}); const sync = createTodaySessionSync({ getDeviceId:()=> 'desktop-a', fetchJson:async (url, options={}) => { if (!options.method) return owned; active += 1; peak = Math.max(peak, active); patchCount += 1; const body = JSON.parse(options.body); bodies.push(body); if (patchCount === 1) await firstPatch; active -= 1; return {...owned, revision:1 + patchCount, elapsed_ms:body.elapsed_ms, running:body.running}; }, }); (async()=>{ await sync.refresh(); const first = sync.publish({identity:owned.identity, elapsed_ms:1000, running:true}); await Promise.resolve(); const middle = sync.publish({identity:owned.identity, elapsed_ms:2000, running:false}); const latest = sync.publish({identity:owned.identity, elapsed_ms:3000, running:true}); releaseFirst(); await Promise.all([first, middle, latest]); process.stdout.write(JSON.stringify({peak, bodies, session:sync.session()})); })().catch(error=>{console.error(error);process.exit(1);}); """ ) assert result["peak"] == 1 assert [body["elapsed_ms"] for body in result["bodies"]] == [1000, 3000] assert result["session"]["elapsed_ms"] == 3000 def test_remote_conflict_discards_queued_local_publishes_until_explicit_claim(): result = run_node( r""" const owned = {revision:2, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true}; const remote = {...owned, revision:3, device_id:'phone-b', elapsed_ms:2500}; const calls = []; let reads = 0; let releaseConflict; const conflictReady = new Promise(resolve=>{releaseConflict=resolve;}); const sync = createTodaySessionSync({ getDeviceId:()=> 'desktop-a', fetchJson:async (url, options={}) => { calls.push(options.method || 'GET'); if (options.method === 'PATCH') { await conflictReady; const error = new Error('session changed'); error.status = 409; throw error; } reads += 1; return reads === 1 ? owned : remote; }, timer:{pause:()=>{}}, }); (async()=>{ await sync.refresh(); const stale = sync.publish({identity:owned.identity, elapsed_ms:1500, running:true}); await Promise.resolve(); const queued = sync.publish({identity:owned.identity, elapsed_ms:2000, running:true}); releaseConflict(); await Promise.all([stale, queued]); process.stdout.write(JSON.stringify({calls, session:sync.session()})); })().catch(error=>{console.error(error);process.exit(1);}); """ ) assert result["calls"] == ["GET", "PATCH", "GET"] assert result["session"]["device_id"] == "phone-b" def test_transient_failure_retains_latest_intent_for_retry(): result = run_node( r""" const owned = {revision:4, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true}; const bodies = []; const statuses = []; let attempts = 0; const sync = createTodaySessionSync({ getDeviceId:()=> 'desktop-a', fetchJson:async (url, options={}) => { if (!options.method) return owned; attempts += 1; bodies.push(JSON.parse(options.body)); if (attempts === 1) { const error = new Error('temporarily unavailable'); error.status = 503; throw error; } return {...owned, revision:5, elapsed_ms:bodies.at(-1).elapsed_ms}; }, onStatus:status=>statuses.push(status), }); (async()=>{ await sync.refresh(); const first = await sync.publish({identity:owned.identity, elapsed_ms:4500, running:true}); const retried = await sync.publish(); process.stdout.write(JSON.stringify({first, retried, bodies, statuses})); })().catch(error=>{console.error(error);process.exit(1);}); """ ) assert result["first"] is None assert [body["elapsed_ms"] for body in result["bodies"]] == [4500, 4500] assert result["retried"]["revision"] == 5 assert "offline" in result["statuses"] def test_claim_transport_failure_stays_retryable_without_false_conflict(): result = run_node( r""" const remote = {revision:7, device_id:'phone-b', identity:'issue:r:42:', elapsed_ms:9000, running:true}; const calls = []; const statuses = []; const sync = createTodaySessionSync({ getDeviceId:()=> 'desktop-a', fetchJson:async (url, options={}) => { calls.push(options.method || 'GET'); if (!options.method) return remote; const error = new Error('gateway unavailable'); error.status = 503; throw error; }, onStatus:status=>statuses.push(status), }); (async()=>{ await sync.refresh(); const claimed = await sync.claim(); process.stdout.write(JSON.stringify({claimed, calls, statuses})); })().catch(error=>{console.error(error);process.exit(1);}); """ ) assert result["claimed"] is None assert result["calls"] == ["GET", "PATCH"] assert result["statuses"][-1] == "offline" assert "conflict" not in result["statuses"]