diff --git a/frontend/app-bootstrap.test.js b/frontend/app-bootstrap.test.js
new file mode 100644
index 0000000..77fe7f6
--- /dev/null
+++ b/frontend/app-bootstrap.test.js
@@ -0,0 +1,84 @@
+const {test}=require('node:test');
+const assert=require('node:assert/strict');
+const {webcrypto,createHash}=require('node:crypto');
+const vm=require('node:vm');
+const {readFileSync}=require('node:fs');
+function fixture({cached=false,tampered=false,enabled=true,loading=false,quota=false,insecure=false,previous=false,contract=1,capture=true,controller=true,parser=false}={}){
+ const source='window.appRuns=(window.appRuns||0)+1; window.boot=()=>window.bootRuns=(window.bootRuns||0)+1; document.addEventListener("DOMContentLoaded",boot);',hash=createHash('sha256').update(source).digest('hex'), key='/app.js?v='+hash.slice(0,10);
+ const oldSource='window.oldApp=true;'+source, oldHash=createHash('sha256').update(oldSource).digest('hex').slice(0,10), oldKey='/app.js?v='+oldHash;
+ const held=new Map(), appended=[],writes=[],listeners={};let calls=0;
+ const response=()=>new Response(tampered?'bad':source,{headers:{'X-Asset-Hash':hash.slice(0,10)}});
+ if(cached)held.set(key,response());
+ if(previous){held.set(oldKey,new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}));held.set('/__ytp_asset_state',Response.json({previous:{files:{'/app.js':{h:oldHash}},groups:{core:{contract,files:['/app.js']}}}}));}
+ const root={crypto:insecure?undefined:webcrypto,TextDecoder,Uint8Array,btoa,console,AssetSyncCore:require('./asset-sync-core'),navigator:{serviceWorker:{controller:controller?{}:null}},Lazy:{captureApp:capture,manifest:{groups:{core:{contract:1,files:['/app.js']}},appCache:enabled,files:{'/app.js':{h:hash.slice(0,10)}}},url:()=>key},fetch:async()=>{calls++;return previous?new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}):response();},caches:{open:async()=>({match:async k=>held.get(k)?.clone(),put:async(k,r)=>{if(quota)throw Error('quota');held.set(k,r.clone());}})}};
+ const doc=root.document={readyState:loading?'loading':'complete',currentScript:parser?{}:null,write:value=>{writes.push(value);vm.runInContext(source,context);},querySelector:()=>({content:"script-src 'self' 'sha256-"+Buffer.from(hash,'hex').toString('base64')+"'"}),addEventListener:(name,fn)=>(listeners[name] ||= []).push(fn),createElement:()=>({remove(){this.removed=true;}}),head:{append(node){appended.push(node);if(node.textContent)vm.runInContext(node.textContent,context);else {queueMicrotask(async()=>{if(previous && node.src===key){const state=await held.get('/__ytp_asset_state').clone().json();if(!root.AssetSyncCore.fallback(root.Lazy.manifest,state.previous,'/app.js'))return node.onerror();vm.runInContext(oldSource,context);}else vm.runInContext(node.src===oldKey?oldSource:source,context);node.onload();});}}}};
+ root.window=root;const context=vm.createContext(root);
+ vm.runInContext(readFileSync(require.resolve('./section-rail.js'),'utf8'),context);
+ return {root,held,appended,writes,listeners,key,calls:()=>calls,doc,response,oldKey};
+}
+test('cold boot caches verified app before execution so worker skips its download',async()=>{
+ const f=fixture();await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);assert.equal(f.appended[0].src,undefined);assert.equal(f.appended[0].removed,true);
+});
+test('offline boot reads the exact cached app without network or re-evaluation',async()=>{
+ const f=fixture({cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
+});
+test('tampered cached bytes are rejected before execution',async()=>{
+ const f=fixture({cached:true,tampered:true});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
+});
+test('native and rollback boot use the original external classic script',async()=>{
+ const f=fixture({enabled:false});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
+});
+test('boot waits for the normal DOM event when the parser has not finished',async()=>{
+ const f=fixture({loading:true});await f.root.AppBootstrap.ready;assert.equal(f.root.bootRuns,undefined);for(const fn of f.listeners.DOMContentLoaded)fn();assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
+});
+test('quota failure still executes the verified response without deleting saved data',async()=>{
+ const f=fixture({quota:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
+});
+
+
+test('the early head response is consumed without a second page fetch',async()=>{
+ const f=fixture();f.root.Lazy.appResponse=Promise.resolve(f.response());await f.root.AppBootstrap.ready;
+ assert.equal(f.calls(),0);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);
+});
+
+
+test('insecure local HTTP boot retains external execution without WebCrypto',async()=>{
+ const f=fixture({insecure:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
+});
+
+
+test('missing current app uses verified same-contract N-1 offline through the controlling worker',async()=>{
+ const f=fixture({previous:true});await f.root.AppBootstrap.ready;
+ assert.equal(f.appended[0].src,f.oldKey);assert.equal(f.root.oldApp,true);assert.equal(f.root.bootRuns,1);assert.equal(f.held.has(f.key),false);
+});
+test('an incompatible previous core never executes against the new shell',async()=>{
+ const f=fixture({previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
+});
+
+
+test('a feature contract change also prevents stale core execution',async()=>{
+ const f=fixture({previous:true});f.root.Lazy.manifest.groups['feature:test']={contract:2,files:[]};
+ const state=await f.held.get('/__ytp_asset_state').json();state.previous.groups['feature:test']={contract:1,files:[]};f.held.set('/__ytp_asset_state',Response.json(state));
+ await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
+});
+
+
+test('native-cache engines use their external script and keep normal code-cache behavior',async()=>{
+ const f=fixture({capture:false,controller:false});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
+});
+
+
+test('native-cache engines still reject an incompatible retained core',async()=>{
+ const f=fixture({capture:false,previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/Unable to load player/);assert.equal(f.root.appRuns,undefined);
+});
+test('native-cache engines use the validated current cache entry without decoding its body',async()=>{
+ const f=fixture({capture:false,cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
+});
+
+test('native parser boot preserves classic evaluation before the original DOM event',async()=>{
+ const f=fixture({capture:false,loading:true,parser:true});
+ assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,undefined);
+ assert.deepEqual(f.writes,['']);assert.equal(f.appended.length,0);
+ for(const fn of f.listeners.DOMContentLoaded)fn();await f.root.AppBootstrap.ready;
+ assert.equal(f.root.bootRuns,1);assert.equal(f.calls(),0);
+});
diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js
index ced5ccd..f6dc72e 100644
--- a/frontend/app-seams.test.js
+++ b/frontend/app-seams.test.js
@@ -30,8 +30,8 @@ test('Phase 4 preserves the exact player, continuity, tracking and queue bodies'
test('eager shell and Settings definitions load before app state and defer reading it',()=>{
const html=readFileSync(join(__dirname,'index.html'),'utf8');
for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
- assert.ok(html.indexOf('src="'+file+'"')({}),head:{append(){}}};const globals={document:doc,Lazy:{manifest:{appCache:false},url:()=>'/app.js'},console};globals.window=globals;const context=vm.createContext(globals);
// Evaluation before any data/$/els/Player declaration must be safe.
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
}
diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js
index 9f34ce7..0c6ede2 100644
--- a/frontend/asset-sync-core.js
+++ b/frontend/asset-sync-core.js
@@ -41,6 +41,9 @@
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
if (!group || !previous.groups[group]?.files.includes(path) ||
manifest.groups[group].contract !== previous.groups[group].contract) return null;
+ // A stale core must understand every group contract in the new shell.
+ if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) =>
+ previous.groups[name]?.contract !== entry.contract)) return null;
return url(path, previous.files[path]);
}
diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js
index b5abdb6..60b366d 100644
--- a/frontend/asset-sync-core.test.js
+++ b/frontend/asset-sync-core.test.js
@@ -96,3 +96,9 @@ test('metadata probes release response streams while stored asset bodies remain
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');
});
+
+test('stale app core requires every feature contract to match the new shell',()=>{
+ const old={files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
+ const next=structuredClone(old);next.files['/app.js'].h='new';next.groups.extra.contract=2;
+ assert.equal(core.fallback(next,old,'/app.js'),null);next.groups.extra.contract=1;assert.equal(core.fallback(next,old,'/app.js'),'/app.js?v=old');
+});
diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js
index 77186ee..d7d9978 100644
--- a/frontend/asset-worker.test.js
+++ b/frontend/asset-worker.test.js
@@ -15,11 +15,11 @@ test('staged same-contract responses use verified N-1 without poisoning the new
const e=environment(); const cache=await e.storage.open(core.CACHE);
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'};
- await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}}));
+ await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old','Cache-Control':'public, max-age=31536000, immutable','Content-Encoding':'gzip','Content-Length':'9'}}));
await e.dispatch('install'); await e.dispatch('activate');await e.complete();
assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new');
assert.equal(await cache.match('/extra.js?v=new'),undefined);
- const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(await cache.match('/extra.js?v=new'),undefined);
+ const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(stale.headers.get('Cache-Control'),'no-store');assert.equal(stale.headers.get('Content-Encoding'),null);assert.equal(stale.headers.get('Content-Length'),null);assert.equal(await cache.match('/extra.js?v=new'),undefined);
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.ok(await cache.match('/extra.js?v=new'));
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
assert.equal((await e.request('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
@@ -56,3 +56,22 @@ test('waiting status does not start completion and activation delegates it to it
const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job;
assert.equal((await core.status(e.m,cache)).offlineReady,true);
});
+
+test('page-captured app is reused on first install without a worker download',async()=>{
+ const e=environment(),cache=await e.storage.open(core.CACHE);
+ await cache.put('/app.js?v=app',new Response('page captured app',{headers:{'X-Asset-Hash':'app'}}));
+ await e.dispatch('install');assert.equal(e.fetches.includes('/app.js?v=app'),false);
+ assert.equal(await(await cache.match('/app.js?v=app')).text(),'page captured app');
+ assert.equal(e.skips.length,0);assert.equal(await core.state(cache),null);
+});
+
+
+test('worker never supplies stale app to a native script when a feature contract changed',async()=>{
+ const e=environment(),cache=await e.storage.open(core.CACHE);
+ const old={buildTag:'old',files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
+ e.m.groups.core.contract=1;e.m.groups.extra={contract:2,background:true,files:[]};
+ await cache.put(core.STATE,Response.json({current:old}));await cache.put('/app.js?v=old',new Response('old app',{headers:{'X-Asset-Hash':'old'}}));
+ await e.dispatch('install');await e.dispatch('activate');await cache.delete('/app.js?v=app');
+ e.sandbox.fetch=async()=>{throw Error('offline')};
+ const response=await e.request('/app.js?v=app');assert.equal(response.status,503);assert.equal(await response.text(),'Offline');
+});
diff --git a/frontend/assets.json b/frontend/assets.json
index c8469f1..cdf4327 100644
--- a/frontend/assets.json
+++ b/frontend/assets.json
@@ -1,7 +1,7 @@
{
"groups": {
"core": {
- "contract": 1,
+ "contract": 2,
"eager": true,
"files": [
"/index.html",
diff --git a/frontend/index.html b/frontend/index.html
index 9d79586..d345ac8 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -13,13 +13,15 @@
YT Player
-
+
+
+
@@ -672,6 +674,5 @@
-