352 lines
18 KiB
Python
352 lines
18 KiB
Python
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"
|
|
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
|
|
|
|
|
|
def run_node(script):
|
|
return json.loads(
|
|
subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout
|
|
)
|
|
|
|
|
|
def test_two_tabs_cannot_erase_each_others_later_edits_or_double_drain():
|
|
script = f"""
|
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
|
const createCoordinator=require({json.dumps(str(COORDINATOR))});
|
|
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const channels=[];const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
|
|
const held=new Set();const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
|
|
let sequence=0,releaseFirst;const delivered=[];
|
|
const make=tab=>createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};const operation=JSON.parse(options.body).operations[0];delivered.push(operation);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,records:{{[operation.item_id]:operation.wake_at}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
|
|
const first=make('first'),second=make('second');first.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
|
|
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('defer','issue:r:2:','2026-08-11T09:00:00.000Z');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
|
|
"""
|
|
result = run_node(script)
|
|
assert sorted(operation["operation_id"] for operation in result["delivered"]) == ["first-1", "second-2"]
|
|
assert len(result["delivered"]) == 2
|
|
assert result["pending"] == []
|
|
assert not any(".operation." in key for key in result["keys"])
|
|
|
|
|
|
def test_concurrent_later_records_for_one_item_deliver_only_the_newest_intent():
|
|
script = f"""
|
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
|
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const prefix='stackchain.later-sync.v1.timmy.operation.';
|
|
values.set(prefix+'old',JSON.stringify({{queued_at:100,operation:{{operation_id:'old',action:'defer',item_id:'issue:r:2:',wake_at:'2026-08-10T09:00:00.000Z'}}}}));
|
|
values.set(prefix+'new',JSON.stringify({{queued_at:101,operation:{{operation_id:'new',action:'restore',item_id:'issue:r:2:',wake_at:null}}}}));
|
|
const delivered=[];const sync=createLaterSync({{storage,getLogin:()=> 'timmy',fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};delivered.push(...JSON.parse(options.body).operations);return {{revision:1,records:{{}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
|
|
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,pending:sync.pending(),keys:[...values.keys()]}}));}})();
|
|
"""
|
|
result = run_node(script)
|
|
assert [operation["operation_id"] for operation in result["delivered"]] == ["new"]
|
|
assert result["pending"] == []
|
|
assert not any(".operation." in key for key in result["keys"])
|
|
|
|
|
|
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).operations[0];
|
|
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",
|
|
]
|
|
assert result["requests"][0]["body"] == {"operations": [{
|
|
"operation_id": "offline-op",
|
|
"action": "defer",
|
|
"item_id": "issue:r:2:",
|
|
"wake_at": "2026-08-10T09:00:00.000Z",
|
|
"base_revision": 0,
|
|
}]}
|
|
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
|
|
assert result["status"] == "saved"
|
|
assert result["pending"] == []
|
|
|
|
|
|
def test_later_queue_expires_ancient_edits_before_replay():
|
|
script = f"""
|
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
|
const values=new Map();const statuses=[];const requests=[];
|
|
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
|
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const sync=createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-later',
|
|
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
|
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:4,records:{{}}}}}},
|
|
onRemoteRecords:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
|
(async()=>{{await sync.flush();sync.enqueue('restore','issue:r:2:');
|
|
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
|
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
|
await sync.flush();process.stdout.write(JSON.stringify({{requests,statuses,pending:sync.pending()}}));}})();
|
|
"""
|
|
result = run_node(script)
|
|
|
|
assert result["requests"] == ["GET", "GET"]
|
|
assert ["expired", 1] in result["statuses"]
|
|
assert result["statuses"][-1] == ["expired", 1]
|
|
assert result["pending"] == []
|
|
|
|
|
|
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
|
|
script = f"""
|
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
|
const values=new Map();const requests=[];const statuses=[];const adopted=[];
|
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const sync=createLaterSync({{
|
|
storage,getLogin:()=> 'timmy',createOperationId:()=> 'offline-old',
|
|
fetchJson:async(_url,options={{}})=>{{
|
|
if(!options.method)return {{revision:3,records:{{'issue:r:1:':'2026-08-12T09:00:00.000Z'}}}};
|
|
const body=JSON.parse(options.body);requests.push(body);
|
|
return {{revision:4,records:{{'issue:r:1:':'2026-08-13T09:00:00.000Z'}},accepted_operation_ids:[],duplicate_operation_ids:[],rejected_operations:[{{operation_id:'offline-old',reason:'stale_intent'}}]}};
|
|
}},onRemoteRecords:records=>adopted.push(records),onStatus:(state,detail={{}})=>statuses.push([state,detail]),
|
|
}});
|
|
(async()=>{{await sync.flush();sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');await sync.flush();
|
|
process.stdout.write(JSON.stringify({{requests,statuses,adopted,pending:sync.pending()}}));}})();
|
|
"""
|
|
result = run_node(script)
|
|
assert result["requests"] == [{"operations": [{
|
|
"operation_id": "offline-old",
|
|
"action": "defer",
|
|
"item_id": "issue:r:1:",
|
|
"wake_at": "2026-08-10T09:00:00.000Z",
|
|
"base_revision": 3,
|
|
}]}]
|
|
assert result["statuses"][-1] == ["conflict", {"count": 1}]
|
|
assert result["adopted"][-1] == {
|
|
"issue:r:1:": "2026-08-13T09:00:00.000Z"
|
|
}
|
|
assert result["pending"] == []
|
|
|
|
|
|
def test_pending_later_edits_are_sent_as_one_batch_without_a_preflight_get():
|
|
script = f"""
|
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
|
const values=new Map();const requests=[];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(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:2,records:{{}},accepted_operation_ids:body.operations.map(x=>x.operation_id),duplicate_operation_ids:[],rejected_operations:[]}}}},
|
|
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
|
}});
|
|
sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
|
|
sync.enqueue('restore','issue:r:2:');
|
|
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));}})();
|
|
"""
|
|
result=run_node(script)
|
|
assert len(result["requests"]) == 1
|
|
assert [item["operation_id"] for item in result["requests"][0]["operations"]] == ["op-1", "op-2"]
|
|
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,
|
|
"base_revision": 0,
|
|
}
|
|
],
|
|
"status": "retrying",
|
|
}
|
|
|
|
|
|
def test_retry_is_single_flight_and_does_not_cross_account_boundary():
|
|
script = f"""
|
|
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
|
const values=new Map(); const timers=[]; const requests=[]; let login='timmy';
|
|
const sync=createLaterSync({{
|
|
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
|
getLogin:()=>login,createOperationId:()=> 'stable-later-op',
|
|
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}},clearTimer:()=>{{}},
|
|
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');throw new Error('offline')}},
|
|
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
|
}});
|
|
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
|
(async()=>{{
|
|
const first=sync.flush(); const same=sync.flush(); await Promise.all([first,same]);
|
|
const scheduledBeforeSwitch=timers.length;
|
|
login='alexander'; await timers[0].callback();
|
|
process.stdout.write(JSON.stringify({{scheduledBeforeSwitch,timers:timers.length,requests,pendingForAlexander:sync.pending()}}));
|
|
}})();
|
|
"""
|
|
assert run_node(script) == {
|
|
"scheduledBeforeSwitch": 1,
|
|
"timers": 1,
|
|
"requests": ["PATCH"],
|
|
"pendingForAlexander": [],
|
|
}
|
|
|
|
|
|
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).operations[0]; 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": ["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 '<script src="static/later-sync.js"></script>' 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
|
|
assert "Another device changed this Later item" in html
|
|
assert "Today edit expired after 30 days offline" in html
|
|
assert "Later edit expired after 30 days offline" 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-v57" in source
|
|
assert "BASE + 'static/later-sync.js'" in source
|