const {test}=require('node:test'); const assert=require('node:assert/strict'); const core=require('./asset-sync-core'); const manifest=(a='a',b='b')=>({buildTag:a+b,files:{'/a.js':{h:a},'/index.html':{h:b}},groups:{core:{files:['/a.js','/index.html']}}}); function cache(){const entries=new Map();return {entries,async match(k){return entries.get(k)?.clone()},async put(k,r){entries.set(k,r.clone())},async keys(){return [...entries.keys()].map(url=>({url:'http://local'+url}))},async delete(k){return entries.delete(k)}}} const response=h=>new Response(h,{headers:{'X-Asset-Hash':h}}); test('exact diff shares unchanged URLs; retains N-1 and prunes N-2',()=>{assert.deepEqual(core.plan(manifest(),['/a.js?v=a']).missing,['/index.html?v=b']);assert.deepEqual([...core.retained(manifest('c'),manifest())].sort(),['/a.js?v=a','/a.js?v=c','/index.html?v=b']);}); test('interrupted sync resumes verified files and refuses a mismatched hash',async()=>{const c=cache();let fail=true;let calls=[];const fetchFn=async u=>{calls.push(u);if(u.includes('index')&&fail)return response('wrong');return response(u.split('=')[1])};await assert.rejects(core.syncAssets(manifest(),{cache:c,fetchFn}),/hash/);assert.ok(await c.match('/a.js?v=a'));fail=false;calls=[];await core.syncAssets(manifest(),{cache:c,fetchFn});assert.deepEqual(calls,['/index.html?v=b']);await c.delete('/a.js?v=a');assert.equal((await core.status(manifest(),c)).missing,1);}); test('commit is atomic and pruning keeps only current and previous changed versions',async()=>{const c=cache();await c.put('/a.js?v=z',response('z'));await core.syncAssets(manifest(),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest(),c);await core.syncAssets(manifest('c'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('c'),c);assert.ok(await c.match('/a.js?v=a'));assert.equal(await c.match('/a.js?v=z'),undefined);await core.syncAssets(manifest('d'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('d'),c);assert.equal(await c.match('/a.js?v=a'),undefined);}); test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));}); test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));}); test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));}); test('staged fallback permits only the previous same-contract version of a shipped group file', () => { const old = manifest(), next = manifest('c'); old.groups.core.contract = next.groups.core.contract = 1; assert.equal(core.fallback(next, old, '/a.js'), '/a.js?v=a'); next.groups.core.contract = 2; assert.equal(core.fallback(next, old, '/a.js'), null); assert.equal(core.fallback(next, old, '/missing.js'), null); }); test('background synchronization fills all groups without publishing a new current manifest', async () => { const c = cache(), m = manifest(); m.groups.optional = { contract: 1, background: true, files: ['/extra.js'] }; m.files['/extra.js'] = { h: 'e' }; const fetchFn = async key => response(key.split('=')[1]); await core.syncAssets(m, { cache: c, fetchFn }); assert.equal(await c.match('/extra.js?v=e'), undefined); await core.commit(m, c); const before = await core.state(c); const result = await core.syncAssets(m, { cache: c, fetchFn, groups: ['optional'] }); assert.equal(result.refreshed, 1); assert.ok(await c.match('/extra.js?v=e')); assert.deepEqual(await core.state(c), before); }); test('active layouts also block on shared dependencies, while other layouts stay background', () => { const m=manifest(); m.groups['layout:glass-stage']={contract:1,background:true,files:['/glass.css'],dependencies:['feature:controls']}; m.groups['feature:controls']={contract:1,background:true,files:['/controls.js']}; m.groups['layout:bento-hub']={contract:1,background:true,files:['/bento.css']}; for(const path of ['/glass.css','/controls.js','/bento.css'])m.files[path]={h:'h'}; assert.deepEqual(core.blocking(m,null,'glass-stage'),['/a.js','/controls.js','/glass.css','/index.html']); }); test('removing group membership retains the old tab URL even if the physical file is still shipped unchanged',()=>{ const previous=manifest(),current=manifest();current.groups.core.files=['/index.html']; assert.ok(core.retained(current,previous).has('/a.js?v=a')); }); test('completion includes ungrouped runtime files and exposes verified missing paths', async () => { const c=cache(), m=manifest();m.files['/worker.js']={h:'w'}; await core.syncAssets(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])}); const status=await core.status(m,c); assert.equal(status.ready,true);assert.equal(status.offlineReady,false); assert.deepEqual(status.missingFiles,['/worker.js']);assert.equal(status.cached,2);assert.equal(status.total,3); await core.complete(m,{cache:c,fetchFn:async u=>response(u.split('=')[1])}); assert.equal((await core.status(m,c)).offlineReady,true); }); test('completion persists backoff and resumes only missing files after a worker restart',async()=>{ const c=cache(),m=manifest();let clock=1000;const calls=[]; await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}}); const checkpoint=await(await c.match(core.completionKey(m))).json(); assert.equal(checkpoint.running,false);assert.equal(checkpoint.failures,1);assert.equal(checkpoint.nextRetryAt,1010); await core.complete(m,{cache:c,now:()=>clock,sleep:async ms=>{assert.equal(ms,10);clock+=ms;},fetchFn:async u=>{calls.push(u);return response(u.split('=')[1]);}}); assert.deepEqual(calls,['/index.html?v=b']); const status=await(await c.match(core.completionKey(m))).json();assert.equal(status.offlineReady,true);assert.equal(status.failures,0); await c.delete('/a.js?v=a');assert.equal((await core.status(m,c)).offlineReady,false); }); test('completion retries with exponential backoff and keeps making progress past a failed asset',async()=>{ const c=cache(),m=manifest(),delays=[];let tries=0; const job=await core.complete(m,{cache:c,baseDelay:5,sleep:async ms=>delays.push(ms),fetchFn:async u=>{if(u.includes('a.js')&&++tries<=6)throw Error('drop');return response(u.split('=')[1]);}}); assert.deepEqual(delays,[5,10]);assert.equal(job.offlineReady,true);assert.ok(await c.match('/index.html?v=b')); }); test('overlapping layout and completion jobs share a six-download ceiling',async()=>{ const c=cache(),m=manifest();m.groups.extra={background:true,files:[]}; for(let i=0;i<15;i++){const p='/extra'+i+'.js';m.files[p]={h:String(i)};m.groups.extra.files.push(p);} let active=0,peak=0;const calls=new Map(); const fetchFn=async u=>{active++;peak=Math.max(active,peak);calls.set(u,(calls.get(u)||0)+1);await new Promise(r=>setTimeout(r,3));active--;return response(u.split('=')[1]);}; await Promise.all([core.complete(m,{cache:c,fetchFn}),core.syncAssets(m,{cache:c,fetchFn,groups:['extra']})]); assert.ok(peak<=6);assert.ok([...calls.values()].every(count=>count===1)); }); test('blocking commit never waits on an optional file readiness probe',{timeout:1000},async()=>{ const c=cache(),m=manifest();m.files['/extra.js']={h:'extra'};m.groups.extra={files:['/extra.js'],background:true,contract:1}; await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b')); const match=c.match;let extraProbes=0;c.match=async key=>{if(key==='/extra.js?v=extra'){extraProbes++;return new Promise(()=>{});}return match(key);}; await core.commit(m,c);assert.equal(extraProbes,0);assert.equal((await core.state(c)).current.buildTag,m.buildTag); c.match=match;assert.equal((await core.status(m,c)).offlineReady,false); }); test('metadata probes release response streams while stored asset bodies remain readable',async()=>{ const c=cache(),m=manifest();await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b')); const match=c.match,probes=[];c.match=async key=>{const r=await match(key);if(r)probes.push(r);return r;}; assert.equal((await core.status(m,c)).offlineReady,true);assert.ok(probes.length>0);assert.ok(probes.every(r=>r.bodyUsed)); assert.equal(await(await match('/a.js?v=a')).text(),'a');assert.equal(await(await match('/index.html?v=b')).text(),'b'); });