Merge Phase 6 and reconcile offline completion, response capture and contract tests

This commit is contained in:
Jonathan Sykes
2026-10-09 15:12:14 +08:00
62 changed files with 229652 additions and 43 deletions

View File

@@ -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,['<script src="'+f.key+'"></script>']);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);
});

View File

@@ -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',()=>{ test('eager shell and Settings definitions load before app state and defer reading it',()=>{
const html=readFileSync(join(__dirname,'index.html'),'utf8'); const html=readFileSync(join(__dirname,'index.html'),'utf8');
for(const file of ['shell-core.js','views-core.js','section-rail.js']) { for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
assert.ok(html.indexOf('src="'+file+'"')<html.indexOf('src="app.js"')); assert.ok(html.indexOf('src="'+file+'"')<=html.indexOf('src="section-rail.js"'));
const vm=require('node:vm');const context=vm.createContext({window:{},document:{}}); const vm=require('node:vm');const doc={readyState:'complete',addEventListener(){},createElement:()=>({}),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. // Evaluation before any data/$/els/Player declaration must be safe.
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context); vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
} }

View File

@@ -41,6 +41,9 @@
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path)); const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
if (!group || !previous.groups[group]?.files.includes(path) || if (!group || !previous.groups[group]?.files.includes(path) ||
manifest.groups[group].contract !== previous.groups[group].contract) return null; 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]); return url(path, previous.files[path]);
} }

View File

@@ -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 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'); 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');
});

View File

@@ -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 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}}}; 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'}; 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(); 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.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); 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: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'); 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'); 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; 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); 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');
});

View File

@@ -1,7 +1,7 @@
{ {
"groups": { "groups": {
"core": { "core": {
"contract": 1, "contract": 2,
"eager": true, "eager": true,
"files": [ "files": [
"/index.html", "/index.html",

File diff suppressed because one or more lines are too long

View File

@@ -123,6 +123,24 @@
for (const type of ['pointerenter', 'focus', 'touchstart']) element.addEventListener(type, () => load(name).catch(() => {}), { once: true, passive: true }); for (const type of ['pointerenter', 'focus', 'touchstart']) element.addEventListener(type, () => load(name).catch(() => {}), { once: true, passive: true });
}, },
}; };
// Own this response once. Classic-script and fetch preloads do not share
// their cached bodies in WebKit; the later bootstrap consumes this promise.
const agent=root.navigator.userAgent || '';
api.captureApp=!!(manifest.appCache && !root.navigator.serviceWorker?.controller && root.crypto?.subtle && /AppleWebKit\//.test(agent) && !/(?:Chrome|Chromium|Edg|OPR)\//.test(agent));
if(api.captureApp) {
api.appResponse=(async()=>{
const key=url('/app.js');
try {
const cache=await root.caches?.open('ytplayer-assets'), held=await cache?.match(key);
if(held?.headers.get('X-Asset-Hash')===manifest.files['/app.js']?.h && held)return held;
} catch {}
return root.fetch(key,{credentials:'same-origin'});
})();
api.appResponse.catch(()=>{});
} else if(manifest.appCache) {
// Preserve native script/code-cache reuse in engines without the WebKit gap.
const preload=doc.createElement('link');preload.rel='preload';preload.as='script';preload.fetchPriority='low';preload.href=url('/app.js');doc.head.append(preload);
}
root.Lazy = api; root.Lazy = api;
root.navigator.serviceWorker?.addEventListener('message',event=>{ root.navigator.serviceWorker?.addEventListener('message',event=>{
if(event.data?.type==='ASSET_LAYOUT_REQUEST') event.source?.postMessage({type:'LAYOUT',value:api.layout}); if(event.data?.type==='ASSET_LAYOUT_REQUEST') event.source?.postMessage({type:'LAYOUT',value:api.layout});

View File

@@ -2,16 +2,16 @@ const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs'); const { readFileSync } = require('node:fs');
const vm = require('node:vm'); const vm = require('node:vm');
function fixture(current, held = []) { function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/605.1 Safari/605.1', controlled = false) {
const inserted = [], listeners = {}, writes = []; const inserted = [], listeners = {}, writes = [], fetched = [];
const manifest = { buildTag: 'own', groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } }; const manifest = { buildTag: 'own', appCache, groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/app.js':{h:'a'}, '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f }; const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f };
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } }; doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, setTimeout, clearTimeout, navigator:{}, addEventListener(){} }; const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, fetch:async key=>{fetched.push(key);return new Response('app');}, setTimeout, clearTimeout, navigator:{userAgent,serviceWorker:{ready:Promise.resolve({}),controller:controlled?{}:null,addEventListener(){}}}, addEventListener(){} };
if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) }; if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) };
root.window=root; root.globalThis=root; root.window=root; root.globalThis=root;
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root); vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
return { root, inserted, writes, listeners, manifest }; return { root, inserted, writes, listeners, manifest, fetched };
} }
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => { test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
const {root,writes}=fixture(); const {root,writes}=fixture();
@@ -95,3 +95,31 @@ test('online launches request worker completion even with Save-Data and skip off
assert.deepEqual(sent.map(m=>m.type),['COMPLETE_ASSETS','COMPLETE_ASSETS']); assert.deepEqual(sent.map(m=>m.type),['COMPLETE_ASSETS','COMPLETE_ASSETS']);
f.root.navigator.onLine=false;f.root.Lazy.warm();await Promise.resolve();assert.equal(sent.length,2); f.root.navigator.onLine=false;f.root.Lazy.warm();await Promise.resolve();assert.equal(sent.length,2);
}); });
test('server-enabled head capture owns exactly one app response; native and rollback do not fetch',async()=>{
const modern=fixture(null,[],true);await modern.root.Lazy.appResponse;
assert.deepEqual(modern.fetched,['/app.js?v=a']);
const legacy=fixture();assert.deepEqual(legacy.fetched,[]);assert.equal(legacy.root.Lazy.appResponse,undefined);
});
test('head capture is cache-first even before a worker controls the page',async()=>{
const f=fixture(fixture().manifest,['/app.js?v=a'],true);const response=await f.root.Lazy.appResponse;
assert.equal(response.headers.get('X-Asset-Hash'),'a');assert.deepEqual(f.fetched,[]);
});
test('Chromium retains native script loading and only preloads the current app URL',async()=>{
const f=fixture(null,[],true,'AppleWebKit/537.36 Chrome/140.0 Safari/537.36');
assert.equal(f.root.Lazy.captureApp,false);assert.equal(f.root.Lazy.appResponse,undefined);assert.deepEqual(f.fetched,[]);
assert.equal(f.inserted.length,1);assert.equal(f.inserted[0].rel,'preload');assert.equal(f.inserted[0].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low');
});
test('iOS Chromium-branded browsers still use their WebKit response capture',async()=>{
const f=fixture(null,[],true,'AppleWebKit/605.1 CriOS/140.0 Mobile Safari/605.1');await f.root.Lazy.appResponse;
assert.equal(f.root.Lazy.captureApp,true);assert.deepEqual(f.fetched,['/app.js?v=a']);
});
test('controlled WebKit visits use worker-cached native app scripts without a response handoff',async()=>{
const f=fixture(null,[],true,'AppleWebKit/605.1 Safari/605.1',true);
assert.equal(f.root.Lazy.captureApp,false);assert.equal(f.root.Lazy.appResponse,undefined);assert.deepEqual(f.fetched,[]);
assert.equal(f.inserted[0].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low');
});

View File

@@ -245,3 +245,77 @@ const SectionRail = (() => {
return { boot, refresh, setFolded, isFolded }; return { boot, refresh, setFolded, isFolded };
})(); })();
/* Cache the single page-owned app response before it registers the worker. */
(function (root) {
'use strict';
const doc=root.document, manifest=root.Lazy.manifest, key=root.Lazy.url('/app.js');
let domReady=doc.readyState==='complete', appSawDom=false;
doc.addEventListener('DOMContentLoaded',()=>{domReady=true;appSawDom=typeof root.boot==='function';},{once:true});
function execute(node) {
doc.head.append(node);
node.remove();
delete root.Lazy.appResponse;
// app.js keeps its original DOMContentLoaded listener. Async response
// consumption can finish after that event; boot exactly once in that case.
if(domReady)root.boot();
}
async function external(url) {
await new Promise((resolve,reject)=>{
const node=doc.createElement('script');node.src=url;
node.onload=()=>{if(domReady&&!appSawDom)root.boot();resolve();};node.onerror=()=>reject(Error('Unable to load player'));
doc.head.append(node);
});
}
function native() {
if(doc.readyState!=='loading' || !doc.currentScript)return external(key);
return new Promise((resolve,reject)=>{
doc.addEventListener('DOMContentLoaded',()=>typeof root.boot==='function'?resolve():reject(Error('Unable to load player')),{once:true});
const escaped=key.replaceAll('&','&amp;').replaceAll('"','&quot;').replaceAll('<','&lt;');
doc.write('<script src="'+escaped+'"></script>');
});
}
async function verified(response, expected) {
const bytes=await response.clone().arrayBuffer();
const digest=new Uint8Array(await root.crypto.subtle.digest('SHA-256',bytes));
const hex=Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('');
if(hex.slice(0,10)!==expected)throw Error('Player body hash mismatch');
return {bytes,digest};
}
async function start() {
if(!manifest.appCache || !root.crypto?.subtle || !root.Lazy.captureApp)return native();
const expected=manifest.files['/app.js'].h;
let cache;
try { cache=await root.caches?.open('ytplayer-assets'); } catch {}
let response=await cache?.match(key);
if(!response || response.headers.get('X-Asset-Hash')!==expected) {
try { response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'})); } catch {}
}
if(!response?.ok || response.headers.get('X-Asset-Hash')!==expected) {
// Preserve staged N-1 boot after an individual cache eviction. Only the
// controller can serve this old URL safely; never fetch stale URLs bare.
const state=cache && await root.AssetSyncCore.state(cache);
const previousKey=state && root.AssetSyncCore.fallback(manifest,state.previous,'/app.js');
const previous=previousKey && await cache.match(previousKey);
const previousHash=state?.previous?.files['/app.js']?.h;
const contractsMatch=state?.previous && Object.entries(manifest.groups).every(([name,group])=>state.previous.groups[name]?.contract===group.contract);
if(contractsMatch && root.navigator?.serviceWorker?.controller && previous?.ok && previous.headers.get('X-Asset-Hash')===previousHash) {
await verified(previous,previousHash);
delete root.Lazy.appResponse;
return external(previousKey);
}
throw Error('Player asset hash mismatch');
}
const {bytes,digest}=await verified(response,expected);
const permission="'sha256-"+root.btoa(String.fromCharCode(...digest))+"'";
if(!doc.querySelector('meta[http-equiv="Content-Security-Policy"]').content.includes(permission))throw Error('Player body hash mismatch');
// CacheStorage may share a full quota with saved music. Never remove data
// to make room; boot from these verified bytes even if caching is refused.
try { await cache?.put(key,response); } catch(error) { root.console.warn('[app-cache]',error.message); }
const node=doc.createElement('script');node.textContent=new root.TextDecoder().decode(bytes);
execute(node);
}
const ready=start();
root.AppBootstrap={ready};
ready.catch(error=>root.console.error('[app-cache]',error.message));
})(typeof window!=='undefined'?window:globalThis);

View File

@@ -32,7 +32,7 @@ test('assets.json describes the complete existing shell exactly once with eager
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8')); const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
const described = new Set(); const described = new Set();
for (const [name, group] of Object.entries(groups)) { for (const [name, group] of Object.entries(groups)) {
assert.equal(group.contract, 1, `${name} initial contract`); assert.equal(group.contract, name === 'core' ? 2 : 1, `${name} contract (core boot packing changed)`);
assert.equal(typeof group.eager, 'boolean'); assert.equal(typeof group.eager, 'boolean');
for (const asset of group.files) { for (const asset of group.files) {
assert.ok(!described.has(asset), `${asset} has one group`); assert.ok(!described.has(asset), `${asset} has one group`);
@@ -70,6 +70,8 @@ test('only core scripts and styles remain eager; native fallback and active layo
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8')); const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
const background=new Set(Object.values(groups).filter(g=>g.background).flatMap(g=>g.files)); const background=new Set(Object.values(groups).filter(g=>g.background).flatMap(g=>g.files));
for(const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) { for(const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) {
// A style preload discovers bytes without applying or blocking another layout.
if(match[0].includes('rel="preload"'))continue;
const path='/'+match[1].replace(/^\//,'').split(/[?#]/)[0]; const path='/'+match[1].replace(/^\//,'').split(/[?#]/)[0];
assert.ok(!background.has(path),`${path} is not eager`); assert.ok(!background.has(path),`${path} is not eager`);
} }
@@ -88,3 +90,25 @@ test('layout-independent visibility and transport defaults stay in eager CSS',()
for(const selector of ['.stg-overlay','.deck-chips','.np-btn.done .i-save','.sleep-status .sl-ic','.ab-group','.mc-tune'])assert.ok(shared.includes(selector)); for(const selector of ['.stg-overlay','.deck-chips','.np-btn.done .i-save','.sleep-status .sl-ic','.ab-group','.mc-tune'])assert.ok(shared.includes(selector));
assert.ok(html.includes('href="layout-base.css"')); assert.ok(html.includes('href="layout-base.css"'));
}); });
test('classic first-visit style preload is discovered before the head loader without applying another layout',()=>{
const preload=html.match(/<link\b[^>]*rel="preload"[^>]*href="theme-classic.css"[^>]*as="style"[^>]*>/);
assert.ok(preload,'classic stylesheet has an early preload');
assert.ok(html.indexOf(preload[0])<html.indexOf('src="lazy.js"'));
assert.ok(!/<link\b[^>]*rel="stylesheet"[^>]*href="theme-classic.css"/.test(html),'the selected layout remains parser-applied by the loader');
});
test('the last core script owns boot without an extra request or unsafe stale packing',()=>{
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
assert.equal(groups.core.contract,2,'old core packing cannot fall back without the loader');
assert.ok(groups.core.files.includes('/app.js'));
assert.ok(groups.core.files.includes('/section-rail.js'));
assert.equal(groups.core.files.includes('/app-bootstrap.js'),false);
assert.equal(listed.has('/app-bootstrap.js'),false);
const scripts=[...html.matchAll(/<script src="([^"]+)"/g)].map(m=>m[1]);
assert.equal(scripts.at(-1),'section-rail.js');
assert.ok(html.includes("'sha256-__APP_SCRIPT_HASH__'"));
assert.ok(readFileSync(join(__dirname,'section-rail.js'),'utf8').includes('root.AppBootstrap={ready}'));
});

View File

@@ -600,7 +600,13 @@ async function assetFetch(request, clientId) {
if(state.current.files[path]?.h === h) { if(state.current.files[path]?.h === h) {
const previousKey=AssetSyncCore.fallback(state.current,state.previous,path); const previousKey=AssetSyncCore.fallback(state.current,state.previous,path);
const previousResponse=previousKey && await cache.match(previousKey); const previousResponse=previousKey && await cache.match(previousKey);
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) return previousResponse; if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) {
// A stale body returned under a new URL must not poison the browser's
// resource cache either. Its decoded stream has no transport encoding.
const headers=new Headers(previousResponse.headers);headers.set('Cache-Control','no-store');
for(const name of ['Content-Encoding','Content-Length','Transfer-Encoding'])headers.delete(name);
return new Response(previousResponse.body,{status:previousResponse.status,headers});
}
} }
try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') !== h) return new Response('Asset version unavailable',{status:409}); if(r.ok && request.method !== 'HEAD') await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); } try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') !== h) return new Response('Asset version unavailable',{status:409}); if(r.ok && request.method !== 'HEAD') await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); }
} }

View File

@@ -347,3 +347,25 @@ node --test frontend/media-icons.test.js
Both harnesses run Chromium and WebKit at mobile dimensions with stubbed SpeechRecognition and local Saved/Queue metadata. They serve only the frontend, block service workers and external requests, and never download media. Geometry tests cold and switched layouts, six widths (320–430), mic visible/hidden and update icon visible/hidden: center spread ≤1px, touch targets ≥40px, Saved image/text gap ≥8px, no horizontal overflow or clipped car control. `--quick` limits widths and speech cases; `--browser` selects an engine; `--frontend` selects historical fixtures. `--measure-only --label before` records known broken geometry without exiting nonzero. Both harnesses run Chromium and WebKit at mobile dimensions with stubbed SpeechRecognition and local Saved/Queue metadata. They serve only the frontend, block service workers and external requests, and never download media. Geometry tests cold and switched layouts, six widths (320–430), mic visible/hidden and update icon visible/hidden: center spread ≤1px, touch targets ≥40px, Saved image/text gap ≥8px, no horizontal overflow or clipped car control. `--quick` limits widths and speech cases; `--browser` selects an engine; `--frontend` selects historical fixtures. `--measure-only --label before` records known broken geometry without exiting nonzero.
Icon checks switch the same page through four layouts × dark/light/contrast, assert SVG definitions, inherited colour, stroke family and save/progress/offline state, and save Queue/Now Playing/navigation shots. Glass Stage intentionally hides bottom navigation, so its full page is recorded instead. Any assertion failure exits nonzero. Shots and raw measurements live in git-ignored plans/ui-shots/. Full production inventory: plans/ui-icon-inventory.md. Native speech recognition, installed-PWA safe-area insets and iPhone rendering still need device verification. Icon checks switch the same page through four layouts × dark/light/contrast, assert SVG definitions, inherited colour, stroke family and save/progress/offline state, and save Queue/Now Playing/navigation shots. Glass Stage intentionally hides bottom navigation, so its full page is recorded instead. Any assertion failure exits nonzero. Shots and raw measurements live in git-ignored plans/ui-shots/. Full production inventory: plans/ui-icon-inventory.md. Native speech recognition, installed-PWA safe-area insets and iPhone rendering still need device verification.
### Cold waterfall diagnosis
`--source-root perf/.tmp/<historical-worktree>` runs that worktree's matching
frontend **and server**; `--frontend-commit` instead combines archived frontend
with the current backend. Run historical comparisons sequentially to avoid CPU
contention. The result records `sourceCommit`, `sourceRoot` and the served build.
Every cold sample has `waterfalls`: proxy request order/start/headers/end times
and page Resource Timing discovery/start/end times. Proxy times start when
tracking begins; page times start at navigation. Compare within each clock,
then use the page FCP to locate the rendering dependency.
Pass `--assert-cold` on new builds to require exactly one positive-byte app.js
response in every cold sample, including worker installation. Omit this assertion
when diagnosing historical regressions.
Phase 6 counts its fetch-owned app.js response in initial JS/CSS bytes. Since the
bootstrap consumes that response asynchronously, DOMContentLoaded no longer means
app execution is complete; compare the existing actual boot-done landmark.
`--scenario warm,offline,update-js,update-css,update-feature` runs a regression
sweep without repeating an already-completed cold acceptance run.

View File

@@ -26,7 +26,7 @@
*/ */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertChangedAssets, assertWarmAssets, waitForIdleAssets } from './measurement.mjs'; import { assertColdAssets, assertChangedAssets, assertWarmAssets, waitForIdleAssets } from './measurement.mjs';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { spawn, spawnSync, execFileSync } from 'node:child_process'; import { spawn, spawnSync, execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs'; import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
@@ -62,6 +62,8 @@ function parseArgs() {
out: null, out: null,
compare: null, compare: null,
frontendCommit: null, frontendCommit: null,
sourceRoot: null,
assertCold: false,
}; };
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
@@ -71,6 +73,8 @@ function parseArgs() {
else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i]; else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i];
else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i]; else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i];
else if (a === '--out' && i + 1 < args.length) options.out = args[++i]; else if (a === '--out' && i + 1 < args.length) options.out = args[++i];
else if (a === '--assert-cold') options.assertCold = true;
else if (a === '--source-root' && i + 1 < args.length) options.sourceRoot = path.resolve(args[++i]);
else if (a === '--frontend-commit' && i + 1 < args.length) options.frontendCommit = args[++i]; else if (a === '--frontend-commit' && i + 1 < args.length) options.frontendCommit = args[++i];
else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i]; else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i];
} }
@@ -151,7 +155,8 @@ async function seedDatabase(dbPath, mediaFiles) {
* Bun Server Manager * Bun Server Manager
*/ */
class ServerManager { class ServerManager {
constructor(scratchDir, publicDir) { constructor(scratchDir, publicDir, sourceRoot = REPO_ROOT) {
this.sourceRoot = sourceRoot;
this.scratchDir = scratchDir; this.scratchDir = scratchDir;
this.publicDir = publicDir; this.publicDir = publicDir;
this.dataDir = path.join(scratchDir, 'data'); this.dataDir = path.join(scratchDir, 'data');
@@ -184,7 +189,7 @@ class ServerManager {
DB_PATH: this.dbPath, DB_PATH: this.dbPath,
}; };
const serverScript = path.join(REPO_ROOT, 'server', 'server.js'); const serverScript = path.join(this.sourceRoot, 'server', 'server.js');
this.serverProcess = spawn('bun', [serverScript], { this.serverProcess = spawn('bun', [serverScript], {
cwd: srvCwd, cwd: srvCwd,
env, env,
@@ -372,8 +377,8 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
const fcp = paint.find((p) => p.name === 'first-contentful-paint'); const fcp = paint.find((p) => p.name === 'first-contentful-paint');
return { return {
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0), initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})), initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})),
fcp: fcp ? Math.round(fcp.startTime) : null, fcp: fcp ? Math.round(fcp.startTime) : null,
lcp: window.__lcp ? Math.round(window.__lcp) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null,
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
@@ -481,8 +486,8 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
const fcp = paint.find((p) => p.name === 'first-contentful-paint'); const fcp = paint.find((p) => p.name === 'first-contentful-paint');
return { return {
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0), initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})), initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})),
fcp: fcp ? Math.round(fcp.startTime) : null, fcp: fcp ? Math.round(fcp.startTime) : null,
lcp: window.__lcp ? Math.round(window.__lcp) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null,
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
@@ -562,8 +567,8 @@ async function runOfflineScenario({ browserType, server, proxy, profile }) {
const fcp = paint.find((p) => p.name === 'first-contentful-paint'); const fcp = paint.find((p) => p.name === 'first-contentful-paint');
return { return {
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0), initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})), initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&(['script','link','css'].includes(entry.initiatorType)||(entry.initiatorType==='fetch'&&new URL(entry.name).pathname==='/app.js'))).map(entry=>({url:entry.name,bytes:entry.encodedBodySize,start:entry.startTime,responseStart:entry.responseStart,end:entry.responseEnd,initiator:entry.initiatorType})),
fcp: fcp ? Math.round(fcp.startTime) : null, fcp: fcp ? Math.round(fcp.startTime) : null,
lcp: window.__lcp ? Math.round(window.__lcp) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null,
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
@@ -704,6 +709,8 @@ async function main() {
if (options.browser === 'all' || options.browser === 'chromium') browsers.push(pw.chromium); if (options.browser === 'all' || options.browser === 'chromium') browsers.push(pw.chromium);
if (options.browser === 'all' || options.browser === 'webkit') browsers.push(pw.webkit); if (options.browser === 'all' || options.browser === 'webkit') browsers.push(pw.webkit);
const requestedScenarios=new Set(options.scenario.split(','));
const wants=name=>requestedScenarios.has('all')||requestedScenarios.has(name);
const profiles = []; const profiles = [];
if (options.profile === 'all' || options.profile === 'lte') profiles.push('lte'); if (options.profile === 'all' || options.profile === 'lte') profiles.push('lte');
if (options.profile === 'all' || options.profile === 'lossy') profiles.push('lossy'); if (options.profile === 'all' || options.profile === 'lossy') profiles.push('lossy');
@@ -717,7 +724,7 @@ async function main() {
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css'); const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
const treeUpdateFeature = path.join(tmpRoot, 'tree-update-feature'); const treeUpdateFeature = path.join(tmpRoot, 'tree-update-feature');
let frontendSource=path.join(REPO_ROOT,'frontend'); let frontendSource=path.join(options.sourceRoot || REPO_ROOT,'frontend');
if(options.frontendCommit) { if(options.frontendCommit) {
const archive=path.join(tmpRoot,'frontend.tar'); const archive=path.join(tmpRoot,'frontend.tar');
writeFileSync(archive,execFileSync('git',['archive',options.frontendCommit,'frontend'],{cwd:REPO_ROOT,maxBuffer:20*1024*1024})); writeFileSync(archive,execFileSync('git',['archive',options.frontendCommit,'frontend'],{cwd:REPO_ROOT,maxBuffer:20*1024*1024}));
@@ -735,13 +742,15 @@ async function main() {
const trees = { treeBase, treeUpdateJs, treeUpdateCss, treeUpdateFeature }; const trees = { treeBase, treeUpdateJs, treeUpdateCss, treeUpdateFeature };
const serverScratch = path.join(tmpRoot, 'server-run'); const serverScratch = path.join(tmpRoot, 'server-run');
const server = new ServerManager(serverScratch, treeBase); const server = new ServerManager(serverScratch, treeBase, options.sourceRoot || REPO_ROOT);
await server.start(); await server.start();
console.log(`Bun server running on ephemeral port ${server.port}`); console.log(`Bun server running on ephemeral port ${server.port}`);
const results = { const results = {
date: new Date().toISOString(), date: new Date().toISOString(),
frontendCommit: options.frontendCommit, frontendCommit: options.frontendCommit,
sourceRoot: options.sourceRoot,
sourceCommit: spawnSync('git', ['rev-parse','HEAD'], { cwd: options.sourceRoot || REPO_ROOT }).stdout.toString().trim(),
frontendBuildTag: (await (await fetch('http://127.0.0.1:'+server.port+'/api/version')).json()).buildTag, frontendBuildTag: (await (await fetch('http://127.0.0.1:'+server.port+'/api/version')).json()).buildTag,
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(), commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
runsConfigured: options.runs, runsConfigured: options.runs,
@@ -754,6 +763,9 @@ async function main() {
results.browsers[bName] = {}; results.browsers[bName] = {};
for (const prof of profiles) { for (const prof of profiles) {
// The preceding profile can end on a synthetic feature-update tree.
// Always measure the actual base build on the next cold visit.
await server.restartWithPublic(treeBase);
console.log(`\n>>> Running Benchmark: Browser=[${bName}] Profile=[${prof}]`); console.log(`\n>>> Running Benchmark: Browser=[${bName}] Profile=[${prof}]`);
results.browsers[bName][prof] = {}; results.browsers[bName][prof] = {};
@@ -763,14 +775,15 @@ async function main() {
console.log(`Proxy listening on port ${proxy.port} (mode: ${proxyProfile})`); console.log(`Proxy listening on port ${proxy.port} (mode: ${proxyProfile})`);
// 1. Cold Scenario // 1. Cold Scenario
if (options.scenario === 'all' || options.scenario === 'cold') { if (wants('cold')) {
console.log(` [Scenario: Cold Load] running ${options.runs} iterations...`); console.log(` [Scenario: Cold Load] running ${options.runs} iterations...`);
const coldRuns = []; const coldRuns = [];
for (let r = 0; r < options.runs; r++) { for (let r = 0; r < options.runs; r++) {
process.stdout.write(` run ${r + 1}/${options.runs}... `); process.stdout.write(` run ${r + 1}/${options.runs}... `);
const res = await runColdScenario({ browserType: bType, server, proxy, profile: prof }); const res = await runColdScenario({ browserType: bType, server, proxy, profile: prof });
if(options.assertCold)assertColdAssets(res.requests);
coldRuns.push(res); coldRuns.push(res);
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, boot=${res.bootDone}ms)`); console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, fcp=${res.fcp}ms, boot=${res.bootDone}ms)`);
} }
results.browsers[bName][prof].cold = { results.browsers[bName][prof].cold = {
@@ -791,11 +804,12 @@ async function main() {
requests: coldRuns[0]?.requests || [], requests: coldRuns[0]?.requests || [],
initialJsCss: summarizeList(coldRuns.map(r=>r.initialJsCss)), initialJsCss: summarizeList(coldRuns.map(r=>r.initialJsCss)),
initialAssetRequests: coldRuns[0]?.initialAssetRequests || [], initialAssetRequests: coldRuns[0]?.initialAssetRequests || [],
waterfalls: coldRuns.map(r=>({fcp:r.fcp,requests:r.requests,page:r.initialAssetRequests})),
}; };
} }
// 2. Warm Scenario // 2. Warm Scenario
if (options.scenario === 'all' || options.scenario === 'warm') { if (wants('warm')) {
console.log(` [Scenario: Warm Load] running ${options.runs} iterations...`); console.log(` [Scenario: Warm Load] running ${options.runs} iterations...`);
const warmRuns = []; const warmRuns = [];
for (let r = 0; r < options.runs; r++) { for (let r = 0; r < options.runs; r++) {
@@ -817,7 +831,7 @@ async function main() {
} }
// 3. Offline Scenario // 3. Offline Scenario
if (options.scenario === 'all' || options.scenario === 'offline') { if (wants('offline')) {
console.log(` [Scenario: Offline Reload] running ${options.runs} iterations...`); console.log(` [Scenario: Offline Reload] running ${options.runs} iterations...`);
const offRuns = []; const offRuns = [];
for (let r = 0; r < options.runs; r++) { for (let r = 0; r < options.runs; r++) {
@@ -839,7 +853,7 @@ async function main() {
} }
// 4. Update N -> N+1 (JS) // 4. Update N -> N+1 (JS)
if (options.scenario === 'all' || options.scenario === 'update-js') { if (wants('update-js')) {
console.log(` [Scenario: Update N->N+1 (1-line app.js)] running ${options.runs} iterations...`); console.log(` [Scenario: Update N->N+1 (1-line app.js)] running ${options.runs} iterations...`);
const updJsRuns = []; const updJsRuns = [];
for (let r = 0; r < options.runs; r++) { for (let r = 0; r < options.runs; r++) {
@@ -866,7 +880,7 @@ async function main() {
} }
// 5. Update N -> N+1 (CSS) // 5. Update N -> N+1 (CSS)
if (options.scenario === 'all' || options.scenario === 'update-css') { if (wants('update-css')) {
console.log(` [Scenario: Update N->N+1 (1-line theme-glass.css)] running ${options.runs} iterations...`); console.log(` [Scenario: Update N->N+1 (1-line theme-glass.css)] running ${options.runs} iterations...`);
const updCssRuns = []; const updCssRuns = [];
for (let r = 0; r < options.runs; r++) { for (let r = 0; r < options.runs; r++) {
@@ -894,7 +908,7 @@ async function main() {
}; };
} }
if ((options.scenario === 'all' || options.scenario === 'update-feature') && existsSync(path.join(treeBase,'presenter.js'))) { if ((wants('update-feature')) && existsSync(path.join(treeBase,'presenter.js'))) {
console.log(` [Scenario: Update split-out presenter, including idle cache fill] running ${options.runs} iterations...`); console.log(` [Scenario: Update split-out presenter, including idle cache fill] running ${options.runs} iterations...`);
const runs=[]; const runs=[];
for(let r=0;r<options.runs;r++) { for(let r=0;r<options.runs;r++) {

View File

@@ -84,6 +84,23 @@ async function run(name){
await page.evaluate(async()=>{await Lazy.load('feature:export');await Lazy.load('feature:video-edit');}); await page.evaluate(async()=>{await Lazy.load('feature:export');await Lazy.load('feature:video-edit');});
assert.equal(proxy.getStats().requestCount,before,'all warmed extension assets stay offline'); assert.equal(proxy.getStats().requestCount,before,'all warmed extension assets stay offline');
await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline')); await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline'));
// Change only app bytes, then prove loss of its current cache entry can boot
// the compatible N-1 core offline without weakening the inline CSP policy.
await stop();writeFileSync(join(cwd,'public/app.js'),readFileSync(join(cwd,'public/app.js'),'utf8')+'\n// phase6 app eviction probe\n');await start();
const appTarget=await(await fetch(origin+'/api/manifest')).json();
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();await r.update();});
await wait(page,async tag=>{const r=await navigator.serviceWorker.getRegistration();return r?.waiting&&(await SwUpdate.askCacheStatus(r.waiting))?.version===tag;},appTarget.buildTag);
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
await wait(page,async tag=>{const c=await caches.open('ytplayer-assets');return(await(await c.match('/__ytp_asset_state')).json()).current.buildTag===tag;},appTarget.buildTag);
const appKey='/app.js?v='+appTarget.files['/app.js'].h;
await page.evaluate(async key=>{await(await caches.open('ytplayer-assets')).delete(key);},appKey);
await stop();disconnected=true;await proxy.setOffline(true);
const probe=await context.newPage();probe.on('pageerror',e=>errors.push({message:e.message,disconnected}));
const offlineBefore=proxy.getStats().requestCount;await probe.goto(origin);await probe.waitForFunction(()=>typeof data!=='undefined'&&typeof Player!=='undefined'&&document.querySelector('#carModeBtn'));
assert.equal(await probe.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),appKey),false,'N-1 never poisons the current app key');
assert.equal(proxy.getStats().requestCount,offlineBefore,'N-1 app boot is entirely offline');
await probe.close();await start();disconnected=false;await proxy.setOffline(false);
await page.evaluate(async key=>{const r=await fetch(key+'&__ytpfresh=1');if(r.headers.get('X-Asset-Hash')!==key.split('v=')[1])throw Error('Wrong repair body');await(await caches.open('ytplayer-assets')).put(key,r);},appKey);
// Keep an old tab whose core and group contracts remain those of build N. // Keep an old tab whose core and group contracts remain those of build N.
const old=await context.newPage();old.on('pageerror',e=>errors.push({message:e.message,disconnected}));await old.goto(origin);await old.waitForSelector('#carModeBtn'); const old=await context.newPage();old.on('pageerror',e=>errors.push({message:e.message,disconnected}));await old.goto(origin);await old.waitForSelector('#carModeBtn');
await old.evaluate(async()=>{window.__saveData=true;await Lazy.load('feature:setlist-import');window.__pinned=window.SetlistImport;}); await old.evaluate(async()=>{window.__saveData=true;await Lazy.load('feature:setlist-import');window.__pinned=window.SetlistImport;});
@@ -133,7 +150,7 @@ async function run(name){
const offlineTransportErrors=errors.filter(error=>name==='webkit'&&error.disconnected&&(error.message==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message))); const offlineTransportErrors=errors.filter(error=>name==='webkit'&&error.disconnected&&(error.message==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message)));
assert.deepEqual(errors.filter(error=>!offlineTransportErrors.includes(error)),[]); assert.deepEqual(errors.filter(error=>!offlineTransportErrors.includes(error)),[]);
if(offlineTransportErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url))); if(offlineTransportErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url)));
results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors}); results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,appEvictionN1Offline:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors});
console.log(name,'PASS'); console.log(name,'PASS');
} catch(error){console.error(name,error.stack);throw error;} finally {await browser?.close();proxy?.server.closeAllConnections();await proxy?.close();await stop();} } catch(error){console.error(name,error.stack);throw error;} finally {await browser?.close();proxy?.server.closeAllConnections();await proxy?.close();await stop();}
} }

View File

@@ -27,3 +27,8 @@ export function assertWarmAssets(requests) {
const assets=requests.filter(r=>r.bytes>0&&/\.(?:js|css|woff2?|png|svg)(?:[?#]|$)/.test(r.url)&&!new URL(r.url,'http://localhost').pathname.endsWith('/sw.js')); const assets=requests.filter(r=>r.bytes>0&&/\.(?:js|css|woff2?|png|svg)(?:[?#]|$)/.test(r.url)&&!new URL(r.url,'http://localhost').pathname.endsWith('/sw.js'));
assert.deepEqual(assets,[],'warm reload does not transfer unfinished shell assets'); assert.deepEqual(assets,[],'warm reload does not transfer unfinished shell assets');
} }
export function assertColdAssets(requests) {
const transferred=requests.filter(r=>r.bytes>0&&new URL(r.url,'http://localhost').pathname==='/app.js');
assert.equal(transferred.length,1,'cold app.js transfers its body exactly once across page and worker');
}

View File

@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { assertChangedAssets, assertWarmAssets } from './measurement.mjs'; import { assertChangedAssets, assertWarmAssets, assertColdAssets } from './measurement.mjs';
test('changed CSS and feature measurements reject unrelated application transfers',()=>{ test('changed CSS and feature measurements reject unrelated application transfers',()=>{
const requests=[{url:'/sw.js',bytes:7000},{url:'/asset-sync-core.js',bytes:2000},{url:'/index.html',bytes:12000},{url:'/api/manifest',bytes:3000},{url:'/theme-glass.css?v=new',bytes:5000},{url:'/fonts/a.woff2',bytes:0}]; const requests=[{url:'/sw.js',bytes:7000},{url:'/asset-sync-core.js',bytes:2000},{url:'/index.html',bytes:12000},{url:'/api/manifest',bytes:3000},{url:'/theme-glass.css?v=new',bytes:5000},{url:'/fonts/a.woff2',bytes:0}];
assertChangedAssets(requests,'/theme-glass.css'); assertChangedAssets(requests,'/theme-glass.css');
@@ -14,3 +14,10 @@ test('warm measurements reject unfinished shell downloads but allow API checks',
assertWarmAssets([{url:'/api/version',bytes:62},{url:'/app.js?v=current',bytes:0}]); assertWarmAssets([{url:'/api/version',bytes:62},{url:'/app.js?v=current',bytes:0}]);
assert.throws(()=>assertWarmAssets([{url:'/theme-glass.css?v=current',bytes:5000}])); assert.throws(()=>assertWarmAssets([{url:'/theme-glass.css?v=current',bytes:5000}]));
}); });
test('cold acceptance rejects a second positive app response while allowing cache revalidation',()=>{
assertColdAssets([{url:'/app.js?v=current',bytes:12000},{url:'/app.js?v=current',bytes:0}]);
assert.throws(()=>assertColdAssets([{url:'/app.js?v=current',bytes:12000},{url:'/app.js?v=current',bytes:12000}]));
assert.throws(()=>assertColdAssets([]));
});

View File

@@ -47,6 +47,7 @@ export function categorizeContentType(contentType, urlPath) {
*/ */
export function createThrottleProxy({ targetPort, profile = 'lte' }) { export function createThrottleProxy({ targetPort, profile = 'lte' }) {
let activeTracking = false; let activeTracking = false;
let trackingStart = 0;
let requestCount = 0; let requestCount = 0;
let requests = []; let requests = [];
let totalWireBytes = 0; let totalWireBytes = 0;
@@ -79,6 +80,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
return; return;
} }
const requestStart = Date.now();
reqIndex++; reqIndex++;
const currentReqIndex = reqIndex; const currentReqIndex = reqIndex;
@@ -114,7 +116,8 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
let recorded; let recorded;
if (activeTracking) { if (activeTracking) {
requestCount++; requestCount++;
recorded={url:clientReq.url,status:upRes.statusCode,bytes:0}; recorded={url:clientReq.url,status:upRes.statusCode,bytes:0,index:currentReqIndex,startMs:requestStart-trackingStart,headersMs:Date.now()-trackingStart};
clientRes.once('finish',()=>{recorded.endMs=Date.now()-trackingStart;});
requests.push(recorded); requests.push(recorded);
} }
@@ -176,6 +179,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
server.close(resolve); server.close(resolve);
}), }),
startTracking: () => { startTracking: () => {
trackingStart = Date.now();
activeTracking = true; activeTracking = true;
}, },
stopTracking: () => { stopTracking: () => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
{
"date": "2026-10-08T04:01:24.068Z",
"frontendCommit": null,
"sourceRoot": null,
"sourceCommit": "4b1f5204f8fcd207305ee50fff29985497e58e3b",
"frontendBuildTag": "bb9fea44045d",
"commit": "4b1f5204f8fcd207305ee50fff29985497e58e3b",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 40,
"min": 40,
"max": 40,
"values": [
40
]
},
"lcp": {
"median": 40,
"min": 40,
"max": 40,
"values": [
40
]
},
"domContentLoaded": {
"median": 147,
"min": 147,
"max": 147,
"values": [
147
]
},
"bootDone": {
"median": 152,
"min": 152,
"max": 152,
"values": [
152
]
},
"reloadDuration": {
"median": 169,
"min": 169,
"max": 169,
"values": [
169
]
}
}
}
},
"webkit": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 63,
"min": 63,
"max": 63,
"values": [
63
]
},
"lcp": {
"median": 97,
"min": 97,
"max": 97,
"values": [
97
]
},
"domContentLoaded": {
"median": 93,
"min": 93,
"max": 93,
"values": [
93
]
},
"bootDone": {
"median": 99,
"min": 99,
"max": 99,
"values": [
99
]
},
"reloadDuration": {
"median": 118,
"min": 118,
"max": 118,
"values": [
118
]
}
}
}
}
}
}

View File

@@ -0,0 +1,135 @@
{
"date": "2026-10-09T01:16:09.809Z",
"frontendCommit": null,
"sourceRoot": null,
"sourceCommit": "71608c2ce81207ca21c8adbc49edbc2893f5170e",
"frontendBuildTag": "bb9fea44045d",
"commit": "71608c2ce81207ca21c8adbc49edbc2893f5170e",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 108,
"min": 108,
"max": 108,
"values": [
108
]
},
"lcp": {
"median": 108,
"min": 108,
"max": 108,
"values": [
108
]
},
"domContentLoaded": {
"median": 345,
"min": 345,
"max": 345,
"values": [
345
]
},
"bootDone": {
"median": 353,
"min": 353,
"max": 353,
"values": [
353
]
},
"reloadDuration": {
"median": 401,
"min": 401,
"max": 401,
"values": [
401
]
}
}
}
},
"webkit": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 127,
"min": 127,
"max": 127,
"values": [
127
]
},
"lcp": {
"median": 184,
"min": 184,
"max": 184,
"values": [
184
]
},
"domContentLoaded": {
"median": 180,
"min": 180,
"max": 180,
"values": [
180
]
},
"bootDone": {
"median": 187,
"min": 187,
"max": 187,
"values": [
187
]
},
"reloadDuration": {
"median": 213,
"min": 213,
"max": 213,
"values": [
213
]
}
}
}
}
}
}

View File

@@ -0,0 +1,97 @@
{
"results": [
{
"browser": "chromium",
"sourceBuildTag": "31617662d2f0",
"classicSharedDefaults": true,
"cssOrderPreserved": true,
"settingsSearch": true,
"carControls": 4,
"offlineFeature": true,
"firstUseNetwork": 0,
"layoutsOffline": [
"glass-stage",
"bento-hub",
"f7-swipe",
"classic"
],
"pinned": true,
"compatibleUnexecutedNewURL": true,
"incompatibleN1ReloadRequired": true,
"stagedN1": true,
"appEvictionN1Offline": true,
"playbackGuard": true,
"prePaintLayout": true,
"offlineSettingsPanels": true,
"scriptErrors": [],
"offlineTransportErrors": []
},
{
"browser": "webkit",
"sourceBuildTag": "31617662d2f0",
"classicSharedDefaults": true,
"cssOrderPreserved": true,
"settingsSearch": true,
"carControls": 4,
"offlineFeature": true,
"firstUseNetwork": 0,
"layoutsOffline": [
"glass-stage",
"bento-hub",
"f7-swipe",
"classic"
],
"pinned": true,
"compatibleUnexecutedNewURL": true,
"incompatibleN1ReloadRequired": true,
"stagedN1": true,
"appEvictionN1Offline": true,
"playbackGuard": true,
"prePaintLayout": true,
"offlineSettingsPanels": true,
"scriptErrors": [],
"offlineTransportErrors": [
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:41337/api/version.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:41337/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:41337/api/version.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:41337/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:41337/api/version.",
"disconnected": true
}
]
}
]
}

View File

@@ -0,0 +1,97 @@
{
"results": [
{
"browser": "chromium",
"sourceBuildTag": "31617662d2f0",
"classicSharedDefaults": true,
"cssOrderPreserved": true,
"settingsSearch": true,
"carControls": 4,
"offlineFeature": true,
"firstUseNetwork": 0,
"layoutsOffline": [
"glass-stage",
"bento-hub",
"f7-swipe",
"classic"
],
"pinned": true,
"compatibleUnexecutedNewURL": true,
"incompatibleN1ReloadRequired": true,
"stagedN1": true,
"appEvictionN1Offline": true,
"playbackGuard": true,
"prePaintLayout": true,
"offlineSettingsPanels": true,
"scriptErrors": [],
"offlineTransportErrors": []
},
{
"browser": "webkit",
"sourceBuildTag": "31617662d2f0",
"classicSharedDefaults": true,
"cssOrderPreserved": true,
"settingsSearch": true,
"carControls": 4,
"offlineFeature": true,
"firstUseNetwork": 0,
"layoutsOffline": [
"glass-stage",
"bento-hub",
"f7-swipe",
"classic"
],
"pinned": true,
"compatibleUnexecutedNewURL": true,
"incompatibleN1ReloadRequired": true,
"stagedN1": true,
"appEvictionN1Offline": true,
"playbackGuard": true,
"prePaintLayout": true,
"offlineSettingsPanels": true,
"scriptErrors": [],
"offlineTransportErrors": [
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:38297/api/version.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:38297/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:38297/api/version.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:38297/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:38297/api/version.",
"disconnected": true
}
]
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
{
"date": "2026-10-08T03:15:23.029Z",
"frontendCommit": null,
"sourceRoot": "/home/josh/development/personal/ytplayer-codex/wt-assets/perf/.tmp/phase6-merged-bootstrap",
"sourceCommit": "9408ee07e56db5bbb8319f8da862b549ab948bce",
"frontendBuildTag": "c4bb42682ab8",
"commit": "9408ee07e56db5bbb8319f8da862b549ab948bce",
"runsConfigured": 5,
"browsers": {
"chromium": {
"lte": {
"warm": {
"requestCount": {
"median": 4,
"min": 3,
"max": 5,
"values": [
4,
4,
5,
4,
3
]
},
"wireBytes": {
"median": 7990,
"min": 7979,
"max": 7990,
"values": [
7990,
7990,
7990,
7990,
7979
]
},
"fcp": {
"median": 136,
"min": 108,
"max": 144,
"values": [
140,
116,
136,
108,
144
]
},
"lcp": {
"median": 148,
"min": 136,
"max": 168,
"values": [
168,
148,
136,
156,
144
]
},
"domContentLoaded": {
"median": 409,
"min": 382,
"max": 418,
"values": [
409,
382,
418,
412,
402
]
},
"bootDone": {
"median": 425,
"min": 398,
"max": 433,
"values": [
425,
398,
433,
430,
417
]
},
"longTaskTotal": {
"median": 63,
"min": 56,
"max": 65,
"values": [
63,
65,
63,
57,
56
]
}
}
}
},
"webkit": {
"lte": {
"warm": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
},
"fcp": {
"median": 92,
"min": 79,
"max": 94,
"values": [
92,
92,
87,
94,
79
]
},
"lcp": {
"median": 92,
"min": 79,
"max": 94,
"values": [
92,
92,
87,
94,
79
]
},
"domContentLoaded": {
"median": 89,
"min": 77,
"max": 92,
"values": [
89,
90,
84,
92,
77
]
},
"bootDone": {
"median": 95,
"min": 83,
"max": 98,
"values": [
95,
96,
91,
98,
83
]
},
"longTaskTotal": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
}
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "31617662d2f0",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 122,
"durationMs": 25233,
"wireBytes": 631091,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "31617662d2f0",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 122,
"durationMs": 22860,
"wireBytes": 741571,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 4
}
]
}

View File

@@ -0,0 +1,41 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "31617662d2f0",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 122,
"durationMs": 27355,
"wireBytes": 631091,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "31617662d2f0",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 122,
"durationMs": 24426,
"wireBytes": 730395,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 4
}
]
}

View File

@@ -0,0 +1,41 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "31617662d2f0",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 122,
"durationMs": 9809,
"wireBytes": 640960,
"offline": true,
"legacyURLs": 21,
"profile": "unthrottled",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "31617662d2f0",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 122,
"durationMs": 9902,
"wireBytes": 755652,
"offline": true,
"legacyURLs": 21,
"profile": "unthrottled",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 4
}
]
}

View File

@@ -0,0 +1,41 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "31617662d2f0",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 122,
"durationMs": 13290,
"wireBytes": 640960,
"offline": true,
"legacyURLs": 21,
"profile": "unthrottled",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "31617662d2f0",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 122,
"durationMs": 11711,
"wireBytes": 766828,
"offline": true,
"legacyURLs": 21,
"profile": "unthrottled",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 4
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
{
"date": "2026-10-08T03:05:33.492Z",
"frontendCommit": null,
"sourceRoot": "/home/josh/development/personal/ytplayer-codex/wt-assets/perf/.tmp/phase6-native-cached",
"sourceCommit": "9408ee07e56db5bbb8319f8da862b549ab948bce",
"frontendBuildTag": "46ccefbb78f5",
"commit": "9408ee07e56db5bbb8319f8da862b549ab948bce",
"runsConfigured": 5,
"browsers": {
"chromium": {
"lte": {
"warm": {
"requestCount": {
"median": 4,
"min": 2,
"max": 4,
"values": [
4,
4,
2,
3,
4
]
},
"wireBytes": {
"median": 7996,
"min": 86,
"max": 7996,
"values": [
7996,
7996,
86,
7985,
7996
]
},
"fcp": {
"median": 124,
"min": 116,
"max": 132,
"values": [
128,
124,
116,
124,
132
]
},
"lcp": {
"median": 148,
"min": 148,
"max": 156,
"values": [
156,
148,
148,
148,
156
]
},
"domContentLoaded": {
"median": 432,
"min": 397,
"max": 464,
"values": [
464,
397,
432,
438,
413
]
},
"bootDone": {
"median": 448,
"min": 413,
"max": 482,
"values": [
482,
413,
448,
457,
430
]
},
"longTaskTotal": {
"median": 61,
"min": 58,
"max": 79,
"values": [
79,
58,
60,
70,
61
]
}
}
}
},
"webkit": {
"lte": {
"warm": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
},
"fcp": {
"median": 65,
"min": 61,
"max": 66,
"values": [
66,
63,
65,
61,
65
]
},
"lcp": {
"median": 96,
"min": 92,
"max": 97,
"values": [
97,
96,
92,
93,
96
]
},
"domContentLoaded": {
"median": 93,
"min": 90,
"max": 94,
"values": [
94,
93,
90,
91,
93
]
},
"bootDone": {
"median": 98,
"min": 94,
"max": 98,
"values": [
98,
98,
94,
95,
98
]
},
"longTaskTotal": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
}
}
}
}
}
}

View File

@@ -0,0 +1,101 @@
{
"date": "2026-10-08T01:29:49.903Z",
"frontendCommit": null,
"sourceRoot": null,
"sourceCommit": "c4a512bea63da4c9c875501038727768080496c1",
"frontendBuildTag": "5fe61861d759",
"commit": "c4a512bea63da4c9c875501038727768080496c1",
"runsConfigured": 5,
"browsers": {
"chromium": {
"lte": {
"warm": {
"requestCount": {
"median": 4,
"min": 3,
"max": 5,
"values": [
5,
4,
3,
4,
3
]
},
"wireBytes": {
"median": 7869,
"min": 97,
"max": 7869,
"values": [
7869,
7869,
97,
7869,
7858
]
},
"fcp": {
"median": 140,
"min": 120,
"max": 152,
"values": [
152,
148,
120,
120,
140
]
},
"lcp": {
"median": 148,
"min": 140,
"max": 156,
"values": [
152,
148,
148,
156,
140
]
},
"domContentLoaded": {
"median": 398,
"min": 391,
"max": 406,
"values": [
404,
398,
396,
406,
391
]
},
"bootDone": {
"median": 414,
"min": 411,
"max": 420,
"values": [
418,
414,
411,
420,
412
]
},
"longTaskTotal": {
"median": 62,
"min": 57,
"max": 65,
"values": [
65,
59,
63,
57,
62
]
}
}
}
}
}
}

View File

@@ -0,0 +1,191 @@
{
"date": "2026-10-08T03:01:26.249Z",
"frontendCommit": null,
"sourceRoot": "/home/josh/development/personal/ytplayer-codex/wt-assets/perf/.tmp/phase6-native-parser",
"sourceCommit": "9408ee07e56db5bbb8319f8da862b549ab948bce",
"frontendBuildTag": "4ce3d20db0c7",
"commit": "9408ee07e56db5bbb8319f8da862b549ab948bce",
"runsConfigured": 5,
"browsers": {
"chromium": {
"lte": {
"warm": {
"requestCount": {
"median": 4,
"min": 3,
"max": 5,
"values": [
5,
4,
3,
4,
4
]
},
"wireBytes": {
"median": 8001,
"min": 7990,
"max": 8001,
"values": [
8001,
8001,
7990,
8001,
8001
]
},
"fcp": {
"median": 124,
"min": 120,
"max": 140,
"values": [
132,
124,
124,
140,
120
]
},
"lcp": {
"median": 156,
"min": 140,
"max": 160,
"values": [
160,
148,
156,
140,
160
]
},
"domContentLoaded": {
"median": 428,
"min": 405,
"max": 442,
"values": [
442,
428,
429,
405,
417
]
},
"bootDone": {
"median": 446,
"min": 422,
"max": 464,
"values": [
464,
446,
448,
422,
432
]
},
"longTaskTotal": {
"median": 65,
"min": 60,
"max": 79,
"values": [
79,
69,
65,
65,
60
]
}
}
}
},
"webkit": {
"lte": {
"warm": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
},
"fcp": {
"median": 83,
"min": 81,
"max": 91,
"values": [
82,
91,
83,
88,
81
]
},
"lcp": {
"median": 83,
"min": 81,
"max": 91,
"values": [
82,
91,
83,
88,
81
]
},
"domContentLoaded": {
"median": 80,
"min": 79,
"max": 89,
"values": [
79,
89,
80,
86,
79
]
},
"bootDone": {
"median": 87,
"min": 86,
"max": 96,
"values": [
86,
96,
87,
93,
86
]
},
"longTaskTotal": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0,
0,
0,
0,
0
]
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
[
{
"browser": "chromium",
"checked": [
"shell",
"presenter",
"remote",
"party",
"share",
"stats"
],
"offlineNetworkRequests": 0,
"errors": []
},
{
"browser": "webkit",
"checked": [
"shell",
"presenter",
"remote",
"party",
"share",
"stats"
],
"offlineNetworkRequests": 0,
"errors": []
}
]

View File

@@ -0,0 +1,28 @@
[
{
"browser": "chromium",
"checked": [
"shell",
"presenter",
"remote",
"party",
"share",
"stats"
],
"offlineNetworkRequests": 0,
"errors": []
},
{
"browser": "webkit",
"checked": [
"shell",
"presenter",
"remote",
"party",
"share",
"stats"
],
"offlineNetworkRequests": 0,
"errors": []
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
{
"date": "2026-10-08T04:01:09.552Z",
"frontendCommit": null,
"sourceRoot": null,
"sourceCommit": "4b1f5204f8fcd207305ee50fff29985497e58e3b",
"frontendBuildTag": "2bf76ad9d08e",
"commit": "4b1f5204f8fcd207305ee50fff29985497e58e3b",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 44,
"min": 44,
"max": 44,
"values": [
44
]
},
"lcp": {
"median": 44,
"min": 44,
"max": 44,
"values": [
44
]
},
"domContentLoaded": {
"median": 151,
"min": 151,
"max": 151,
"values": [
151
]
},
"bootDone": {
"median": 157,
"min": 157,
"max": 157,
"values": [
157
]
},
"reloadDuration": {
"median": 173,
"min": 173,
"max": 173,
"values": [
173
]
}
}
}
},
"webkit": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 68,
"min": 68,
"max": 68,
"values": [
68
]
},
"lcp": {
"median": 101,
"min": 101,
"max": 101,
"values": [
101
]
},
"domContentLoaded": {
"median": 98,
"min": 98,
"max": 98,
"values": [
98
]
},
"bootDone": {
"median": 102,
"min": 102,
"max": 102,
"values": [
102
]
},
"reloadDuration": {
"median": 118,
"min": 118,
"max": 118,
"values": [
118
]
}
}
}
}
}
}

View File

@@ -0,0 +1,135 @@
{
"date": "2026-10-09T01:15:49.802Z",
"frontendCommit": null,
"sourceRoot": null,
"sourceCommit": "71608c2ce81207ca21c8adbc49edbc2893f5170e",
"frontendBuildTag": "2bf76ad9d08e",
"commit": "71608c2ce81207ca21c8adbc49edbc2893f5170e",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 88,
"min": 88,
"max": 88,
"values": [
88
]
},
"lcp": {
"median": 88,
"min": 88,
"max": 88,
"values": [
88
]
},
"domContentLoaded": {
"median": 299,
"min": 299,
"max": 299,
"values": [
299
]
},
"bootDone": {
"median": 305,
"min": 305,
"max": 305,
"values": [
305
]
},
"reloadDuration": {
"median": 349,
"min": 349,
"max": 349,
"values": [
349
]
}
}
}
},
"webkit": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 164,
"min": 164,
"max": 164,
"values": [
164
]
},
"lcp": {
"median": 253,
"min": 253,
"max": 253,
"values": [
253
]
},
"domContentLoaded": {
"median": 241,
"min": 241,
"max": 241,
"values": [
241
]
},
"bootDone": {
"median": 256,
"min": 256,
"max": 256,
"values": [
256
]
},
"reloadDuration": {
"median": 299,
"min": 299,
"max": 299,
"values": [
299
]
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
# Phase 6 investigation
All comparisons use the current instrumented harness with matching frontend and
server from detached worktrees beneath `perf/.tmp`. No production requests.
Command template (three fresh contexts):
```
node perf/baseline.mjs --runs 3 --browser webkit --profile lte --scenario cold --source-root perf/.tmp/phase6-<variant> --out perf/results/phase6-<variant>.json
```
| Source | Commit | LTE FCP median ms | Wire bytes | app.js positive responses/sample |
|---|---|---:|---:|---:|
| Phase 2 | 4950d3e | 1044 | 1191063 | 2 |
| Immediately before lazy layouts | d98d444 | 1032 | 1191686 | 2 |
| First lazy-layout cut | 5d6a154 | 1615 | 1106796 | 2 |
| Phase 3 tip | cd4a25a | 1646 | 1108191 | 2 |
| Phase 5 tip (same product as Phase 4) | 21c1273 | 1736 | 1097007 | 2 |
| Phase 5 + worker force-cache | scratch | 1745 | 1097006 | 2 |
| Phase 5 + parser-owned classic CSS | scratch | 898 | 1097051 | 2 |
| Phase 5 + classic preload | scratch | 909 | 1097032 | 2 |
The adjacent-commit measurements isolate `5d6a154`, not the Phase 4 extraction.
All three FCP samples, complete proxy waterfalls, and page Resource Timing are
stored in each result. The first sample often includes startup compression cost;
it is retained in every median, not discarded.
Phase 3 sample 2: common styles finish around 880 ms. The parser-written classic
stylesheet is discovered at 416 ms but reaches the proxy only at 1434 ms and
finishes at 1607 ms. FCP is 1646 ms. Phase 5 sample 2: classic reaches the proxy
at 1540 ms, finishes at 1711 ms, FCP is 1736 ms. Speculatively discovered body
scripts already occupy the request queue when the selected stylesheet arrives.
Phase 2 discovers its declarative styles together around 231 ms, FCP 1044 ms.
The first service-worker request is at 2458 ms in Phase 3 and 2337 ms in Phase 5,
AFTER paint. Worker install competition does not explain this FCP regression.
Font preload URLs match the font CSS; late layout CSS is the final rendering
barrier. Making classic CSS declarative again removes the delay without changing
CSS bytes or cascade order. This scratch variant unconditionally applies classic
and is diagnostic only; the shipping fix must retain selected-layout behavior.
The modern synchronizer already uses default fetch caching. Changing it to
force-cache has no effect: every WebKit sample still transfers app.js twice.
The page and worker cannot reuse these HTTP-cache responses in this harness.
The next hypothesis was a same-build page handoff reusing the page HTTP cache.
The prototype checked both the X-Asset-Hash header and decoded-body SHA-256, and
stripped compressed transport headers before constructing the cached Response.
Updates and legacy migration must continue through their existing worker path.
The shipping CSS fix is a preload, not an unconditional stylesheet. Its three-run
FCP is 909 ms; the first classic request reaches the proxy at 250 ms and completes
at 423 ms. Lazy still applies only the saved layout, in the original cascade order.
Other selected layouts may speculatively download the small classic stylesheet;
it never applies or blocks their rendering. No inline script or CSP change.
The page-to-worker prototype successfully delivered bodies, but still transferred
app.js twice. `phase6-handoff-mode.json` records the two requests: identical URL,
`Accept-Encoding: gzip, deflate`, no-cors mode and immutable response, but script
versus empty request destinations. Force-cache in the page did not reuse the
script body's cache either. This prototype was discarded, not shipped.
A fetch preload plus a later fetch also failed the one-body assertion. The
shipping fix instead starts one cache-first fetch promise in the head loader.
The final eager app-bootstrap consumes that response, verifies header AND SHA-256,
and caches it before executing unchanged app.js as a classic script. A specific
server-generated CSP hash authorizes only those exact bytes; unsafe-inline remains
absent. DOMContentLoaded boot runs exactly once; the temporary script node and
response promise are released. Native, insecure HTTP and rollback modes retain
external classic execution. Only server asset-manifest rendering changed; no
frontend imports, worker sync algorithm, update flow or playback source changed.
The initial owned-response trial passed with one app.js body, FCP 1040 ms and
981468 wire bytes (one fresh sample; not a final median). Five-run acceptance and
regression results will be recorded separately. Historical diagnostics retained
all samples. Full-harness configurations now reset to the base tree before each
browser/profile, preventing a preceding synthetic update from contaminating the
next cold build. The historical invalid media fixture remains unchanged: home
launch only, not autoplay/audio latency.
Self-review added explicit N-1 app boot after eviction: verify the retained body
and use its exact classic URL through the controlling worker. It never fills the
new key with old bytes. Stale core execution additionally requires every group
contract to match the running shell's expectations; a core or feature contract
change rejects it. Both-engine lazy smoke now changes app bytes, evicts the new
entry, boots N-1 offline, then repairs the current entry before the existing
feature pinning/contract/playback checks. No source in app.js changed.
The all-engine inline prototype's completed five-run Chromium/LTE profile had
cold boot 6748 ms, warm boot 458 ms (Phase 5: 410 ms), offline boot 204 ms, core
update 870 ms. This was a real warm regression, so the full sweep was interrupted
before accepting its results. Raw output: perf/.tmp/phase6-inline-chromium-profile.log.
The native-script controlled variant measured warm 414 ms over five samples.
Shipping capture is therefore restricted to WebKit (including iOS CriOS); other
engines preload and retain their native classic-script/code-cache path. Normal
native cache hits avoid decoding/rehashing; retained core fallback still enforces
all group contracts. The new worker marks stale responses no-store and strips
decoded transport headers, preventing browser resource-cache poisoning under a
new URL. CacheStorage's exact keys and previous entries stay unchanged.
The native-window contract lookup trial regressed Chromium warm boot (~698 ms).
It is rejected. The worker now owns the all-group stale app contract guard
(pure planner and actual worker VM tests first); native boot has no window-side
CacheStorage or hashing work. WebKit's byte-verified owned response path is
unchanged. The native fixture models the actual worker fallback decision instead
of a second page-side decision. All 221 frontend tests and 184 server tests pass;
both-engine lazy/offline/eviction/pinning/contracts/playback smoke passes.
The WebKit warm prototype also paid repeated handoff/cache/hash work (warm
~98 ms versus Phase 5 83 ms). Capture now applies only to uncontrolled WebKit
visits. Controlled warm/offline/update pages use the worker's native exact-URL
script path, including its strengthened contract guard. A failing head-loader
test precedes this change; all 222 frontend and 184 server tests and both-engine
lazy smoke pass. Cold capture and both rollback flags are unchanged.
The final native-path review caught a new Chromium paint regression: five-run
FCP 1616/2328 ms (LTE/lossy), despite faster boot. A contemporary Phase 5 LTE
control measured 1360 ms over three runs. Lowering the new app preload priority
reduced FCP to 1396 ms (three runs). Native parser execution restores app's
original DOMContentLoaded boot boundary. A rejected no-preload controlled-page
trial worsened WebKit warm boot to 98 ms; native cache preloading remains.
The standalone helper adds a CacheStorage script read to native startup. Packing
the loader into the existing final core script, section-rail.js, measured Chrome
warm 425 ms versus contemporary Phase 5 426 ms (five runs); WebKit measured
95 ms versus 88 ms, so zero warm overhead is not yet established. The merged
three-run Chrome LTE cold trial measured FCP 1384 ms and boot 6512 ms. Core's
contract becomes 2 because an old section script lacks this loader and cannot
safely substitute for it. No Phase 5 SHELL file is removed; the experimental
Phase 6 standalone helper is removed. Native-order, exact final-script packing
and contract tests precede implementation. All 223 frontend and 184 server tests
and both-engine lazy/offline/eviction/pinning/contracts/playback smoke pass.
The intermediate native-auto-priority full dataset is retained as a rejected
trial; it is not the final acceptance result. Source directories named in trial
JSONs are dirty diagnostic worktrees of 9408ee0 with exactly the changes above.

11
plans/phase6-plan.md Normal file
View File

@@ -0,0 +1,11 @@
# Phase 6 plan
Start from Phase 5 21c1273. Fix cold first-visit delivery only; preserve playback, banner, migration, exact hashes, contracts/pinning, offline and both rollback flags.
1. Extend perf/baseline.mjs/proxy.mjs with page Resource Timing and complete request waterfalls, and a source-worktree option. Create detached historical worktrees beneath perf/.tmp for Phase 2, 3 and 5 tips. Run isolated WebKit/LTE cold measurements, >=3 runs each. Bisect Phase 3 commits with the same instrumented harness. Test head-loader/CSS discovery, fonts, worker start relative to FCP and immutable HTTP-cache behavior with controlled scratch variants. Record actual evidence in plans/phase6-investigation.md.
2. Add failing tests before each root fix. Likely files: frontend/index.html, lazy.js or a tiny layout bootstrap; frontend/sw.js and asset-sync-core.js only as evidence warrants. Keep the legacy install path and update all-or-nothing behavior intact; cache completed immutable responses safely without any unverifiable body. Preserve selected layout before paint and existing CSS cascade order.
3. Commit each fix separately with frontend and isolated Bun server suites green. Revert individual fixes for rollback; ASSET_HASHING=0/ASSET_SYNC=0 stay supported. Do not deploy or contact production.
4. Run final five-run WebKit cold LTE and lossy medians: FCP <= measured Phase 2 (targets ~1,300/3,800 ms); app.js positive-body responses exactly one; cold wire clearly below baseline. Measure Chromium and warm/offline/core-JS/CSS/feature updates against Phase 5, with repeated medians where regression risk exists. Rerun migration both engines with and without stalls, hidden timers, lazy/seam offline checks, rollback paths and all tests.
5. Self-review master sections 3.2/3.3/6/6b and full diff. Commit <=350-word plans/phase6-report.md with actual before/after table and explicit unmet targets; overwrite DONE06 signal and end.
Risks: CSS cascade/no-flash preservation, duplicate requests crossing browser/worker HTTP-cache partitions, race between page handoff and install, timeout fallbacks delaying cache readiness, CPU contention invalidating timing. Historical worktrees/scratch data stay inside wt-assets; run benchmarks sequentially and preserve the invalid historical media fixture only for comparable home-launch metrics, not playback claims.

46
plans/phase6-report.md Normal file
View File

@@ -0,0 +1,46 @@
# Phase 6 report
Tested `4b1f520`, from Phase 5 `21c1273`. Adjacent-commit measurements isolate
`5d6a154`: WebKit/LTE FCP 1032→1615 ms. Its selected classic CSS arrived late
(1540–1711 ms); worker installation started after paint. Fonts matched their
preloads. Default/force-cache and HTTP-cache handoff trials still transferred
app.js twice.
Shipped early classic style discovery (`e7a7cdc`), a single verified page-owned
WebKit response and specific CSP hash (`a86fa1c`, refined through `9408ee0`),
worker-owned stale-core contract protection (`97eb31e`), and native parser boot
with low-priority app preloading (`4b1f520`). The loader shares the existing final
core script; core contract 2 prevents unsafe old packing fallback. app.js,
playback and sw-update.js are unchanged.
Five-run final medians:
| Engine/profile | Phase 5→6 FCP ms | Phase 5→6 cold bytes | app bodies |
|---|---:|---:|---:|
| WebKit/LTE | 1739→905 | 1097048→983326 | 2→1 |
| WebKit/lossy | 4303→2748 | 1105402→990152 | 2→1 |
| Chromium/LTE | 1356→1388 | 651012→653267 | 1→1 |
| Chromium/lossy | 2052→1976 | 651096→653267 | 1→1 |
WebKit paint, one-body and below-baseline wire targets pass (−16.6%/−16.1%
versus original wire baseline). Phase 2 measured 1044/4045 ms over three runs.
Strict zero regression is **not achieved**: Chrome/LTE FCP +32 ms, cold bytes
+0.35%, long tasks 278→304 ms; warm boots Chrome 410/403→418/442 and WebKit
83/85→88/89 ms. Metadata adds ~0.4 KB per core update. Added loader/security
work has a cost; complete timing attribution remains unproved. Contemporary
Phase 5 controls and all offline/update deltas appear in
[phase6-verification.md](phase6-verification.md), with actual command excerpts.
Chromium cold boot improves 7043/11342→6478/10314 ms.
Migration passes both engines with/without stalls; banner once, explicit legacy
refresh, old URLs, N-1, offline and playback guards. Lazy/layout/feature seams,
hidden timers, both rollback modes, 223 frontend/184 server/3 measurement tests
pass. No fonts/icons re-downloaded. ASSET_HASHING=0 and ASSET_SYNC=0 remain.
Real iPhone install/update, quota pressure and 30-minute locked-screen audio
require owner checks. The historical fixture measures home launch, not autoplay.
Post-crash verification retained in `6f0235f`: both-engine migrations, offline
layouts/features, rollback and cold smoke pass. WebKit smoke FCP 1253/2881 ms
(LTE/lossy), one app response each. Durable five-run results remain authoritative.

View File

@@ -0,0 +1,219 @@
# Phase 6 verification
Final product under test: `4b1f520` (Phase 5 parent `21c1273`). No production requests.
All historical source worktrees contain matching server and frontend revisions.
The complete diagnostic waterfalls and five-run acceptance samples are committed
under `perf/results/phase6-*.json`. KB in these tables means 1,000 bytes.
## Cause and controlled trials
See [phase6-investigation.md](phase6-investigation.md) for the three-run tip
comparisons, adjacent-commit bisect, rejected cache/handoff prototypes and fixes.
The regressing commit is `5d6a154`: LTE FCP 1,032 → 1,615 ms immediately across
that cut. Phase 2/3/5 medians are 1,044/1,646/1,736 ms. Phase 4 extraction did
not introduce the paint regression.
Representative WebKit/LTE sample 2, proxy times relative to tracking start:
phase6-before-phase5.json versus phase6-webkit-cold-2026-10-08.json (9408ee0
owned-response diagnostic, before final loader packing). Final medians follow.
| Request | Phase 5 start/end ms | Phase 6 start/end ms |
|---|---:|---:|
| lazy.js | 250/417 | 250/418 |
| styles.css | 251/570 | 251/570 |
| theme-classic.css | 1540/1711 | 250/421 |
| page app.js | 1524/2273 | 1552/2297 |
| first sw.js | 2337/2533 | 2367/2564 |
| worker app.js | 2903/3651 | absent |
| FCP | 1736 | 905 |
The stylesheet becomes available before the parser applies it. The worker starts
after paint. Default fetch caching was already in the modern synchronizer;
force-cache and a page-to-worker HTTP-cache handoff both still transferred two
app bodies in WebKit. A single page-owned response, verified and cached before
boot registers the worker, removes the redundant app transfer. Controlled pages
and other engines retain native external scripts; no window cache/hash lookup
is added to those paths. Each retained stale app requires all shell contracts
to match, enforced by the worker.
## Final measurements
Exact final command (120 samples: both engines, both profiles, six scenarios,
five fresh samples each):
```sh
node perf/baseline.mjs --runs 5 --browser all --profile all --scenario all --assert-cold --out perf/results/phase6-final-2026-10-08.json
```
Final app.js positive-byte responses are exactly one in all 20 cold samples.
The earlier separate cold/regression datasets used 9408ee0 and are diagnostics,
not the final acceptance build. `phase6-native-auto-priority-2026-10-08.json`
combines that rejected intermediate sweep. Final core contract is 2: the final
section-rail script now owns the loader; a stale pre-packing section script would
not load app.js. No Phase 5 SHELL file was deleted. app.js and sw-update.js remain
byte-for-byte unchanged.
Actual final command output excerpts:
```text
Chromium/LTE: run 4/5... done (653267 bytes, 115 reqs, fcp=1396ms, boot=6478ms)
Chromium/lossy: run 2/5... done (653267 bytes, 115 reqs, fcp=1972ms, boot=10292ms)
WebKit/LTE: run 2/5... done (983326 bytes, 158 reqs, fcp=905ms, boot=2335ms)
WebKit/lossy: run 2/5... done (990152 bytes, 161 reqs, fcp=2748ms, boot=5046ms)
```
WebKit FCP targets (≤1300/3800 ms) pass. Contemporary Phase 2 three-run medians:
1044/4045 ms; its lossy samples are 4045/3218/5297, all retained. Original baseline
WebKit cold wire is 1,179,624 bytes. Final LTE/lossy wire is 983,326/990,152 bytes
(16.6%/16.1% lower). The final cold set transfers app.js once in both engines.
## Complete final comparison
Five-run medians. Each cell is Phase 5 → Phase 6; bytes are exact body wire bytes.
| Browser/profile | Cold requests | Cold bytes | FCP ms | LCP ms | Boot ms | Long tasks ms |
|---|---:|---:|---:|---:|---:|---:|
| chromium/lte | 115 → 115 | 651012 → 653267 | 1356 → 1388 | 1356 → 1388 | 7043 → 6478 | 278 → 304 |
| chromium/lossy | 115 → 115 | 651096 → 653267 | 2052 → 1976 | 2052 → 1976 | 11342 → 10314 | 295 → 297 |
| webkit/lte | 159 → 158 | 1097048 → 983326 | 1739 → 905 | 1810 → 982 | 2315 → 2335 | 0 → 0 |
| webkit/lossy | 162 → 161 | 1105402 → 990152 | 4303 → 2748 | 4371 → 2821 | 5573 → 5046 | 0 → 0 |
WebKit does not expose the Long Tasks API; its zero fields mean unmeasured.
| Browser/profile | Warm boot ms | Offline boot ms | Offline reload ms | Core update ms/bytes | CSS update ms/bytes | Feature update ms/bytes |
|---|---:|---:|---:|---:|---:|---:|
| chromium/lte | 410 → 418 | 185 → 188 | 202 → 206 | 745/125795 → 872/126186 | 1380/35363 → 1388/35760 | 1378/33408 → 1392/33762 |
| chromium/lossy | 403 → 442 | 189 → 194 | 204 → 207 | 747/125795 → 805/126186 | 1377/35363 → 1383/35760 | 1375/33408 → 1386/33762 |
| webkit/lte | 83 → 88 | 86 → 88 | 100 → 102 | 1890/147259 → 2015/147626 | 1455/45659 → 1467/46254 | 1455/43318 → 1380/41860 |
| webkit/lossy | 85 → 89 | 81 → 85 | 95 → 98 | 4192/147259 → 2934/147626 | 3647/43705 → 3687/44211 | 3644/41364 → 3693/41860 |
## Limits and exact unmet parity targets
Strict zero regression versus the recorded Phase 5 run is NOT achieved.
Chromium/LTE FCP is 1388 versus 1356 ms (+32); cold bytes 653267 versus 651012
(+2255, 0.35%); long tasks 304 versus 278 ms. The verified loader, CSP permission,
contract checks and metadata add code/bytes; parser insertion also changes task
boundaries. The whole timing delta has not been causally isolated. Three-run
contemporary Phase 5 LTE medians were FCP 1368, boot 7090 and long tasks 294 ms.
Final Chrome boot improves to 6478/10314 ms (Phase 5 7043/11342).
Warm/offline/update deltas in the complete table remain explicit. Contemporary
five-run Phase 5 warm boots were Chrome 426/446 and WebKit 88/88 ms; final values
418/442 and 88/89 ms. Contemporary Phase 5 core-update times were Chrome 869/799
and WebKit 1955/2776 ms; final 872/805 and 2015/2934 ms. Metadata adds roughly
0.4 KB to core updates. Periodic lossy stalls depend on request order; timing
comparisons cannot establish exact parity. The extra CPU/cache work is small,
but zero cost was not proved, and the positive timing deltas are not dismissed.
Every final update passed assertChangedAssets: only app.js, theme-glass.css or
presenter.js is downloaded as application code, with index/API/worker metadata;
fonts/icons are not re-downloaded. Whole CSS-update wire remains above 30 KB,
a pre-existing Phase 5 gap. Readiness and completion remain separately measured.
Real iPhone Safari/PWA code-cache behavior, OPFS quota pressure, forced cache
eviction and uninterrupted locked-screen/background audio for 30 minutes were
not verified. The historical media fixture is invalid for autoplay: these are
home-launch metrics, not media-start measurements. No playback code changed.
Other selected layouts may prefetch 4079 unused classic CSS bytes; that preload
never applies, and the selected stylesheet retains its original cascade order.
## Compatibility verification on the final product
All commands below ran on 4b1f520, build 31617662d2f0:
```sh
node perf/migration.mjs --browser all --profile unthrottled --out perf/results/phase6-migration-unthrottled-2026-10-08.json
node perf/migration.mjs --browser all --profile lossy --out perf/results/phase6-migration-lossy-2026-10-08.json
node perf/hidden-timers.mjs
node perf/lazy.mjs --browser all --out perf/results/phase6-lazy-2026-10-08.json
node perf/seams.mjs --browser all
ASSET_SYNC=0 node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario offline --out perf/results/phase6-sync-rollback-2026-10-08.json
ASSET_HASHING=0 node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario offline --out perf/results/phase6-hashing-rollback-2026-10-08.json
node --test frontend/*.test.js
node --test perf/measurement.test.mjs
(cd server && bun install && PATH="$PWD/../perf/.tmp/test-venv/bin:$PATH" bun run test)
```
Actual output excerpts:
```text
Migration unthrottled: chromium banners=1 updateReloads=1 cachedFiles=122 userInitiatedOnly=true offline=true playbackGuard=true evictionRepair=true
Migration unthrottled: webkit banners=1 updateReloads=1 cachedFiles=122 userInitiatedOnly=true offline=true playbackGuard=true evictionRepair=true
Migration lossy: chromium durationMs=25233 banners=1 updateReloads=1 legacyURLs=21 userInitiatedOnly=true offline=true
Migration lossy: webkit durationMs=22860 banners=1 updateReloads=1 legacyURLs=21 userInitiatedOnly=true offline=true
Hidden timers: Page errors: none; Result: ALL ASSERTIONS PASSED
Lazy: chromium PASS; webkit PASS
Seams: chromium checked=[shell,presenter,remote,party,share,stats] offlineNetworkRequests=0 errors=[]
Seams: webkit checked=[shell,presenter,remote,party,share,stats] offlineNetworkRequests=0 errors=[]
Frontend: tests 223; pass 223; fail 0; skipped 0
Measurement: tests 3; pass 3; fail 0
Server: sum of per-file output: 184 pass; 0 fail
```
Both rollback modes launched offline with zero network in Chromium and WebKit.
The archived b77938a fixture was rebuilt by the migration script each time.
Legacy reload remains user-initiated; its pre-existing Refresh UI timer exception
is unchanged. New-page playback guards, old-client readiness, one banner, old
plain/?v=oldTag/?__ytpfresh URLs, already-open old tabs and cached offline reload
remain covered. Four expected WebKit disconnected API transport errors per
migration were recorded and matched to failed API requests; no script errors.
The seam harness's historical Phase 4 artifact was restored after saving the
new output as phase6-seams-2026-10-08.json.
Self-review re-read the complete product diff and master §§3.2/3.3/6/6b.
No frontend import was added to server code; no original shell file was removed;
no auto skipWaiting, live stateful re-evaluation, playback edit, push or deploy.
Cache responses are exact-key checked; stale bodies under a new URL are no-store
and carry no decoded transport encoding. Core contract 2 blocks loader-free N-1
packing, while same-contract app fallback and executed feature pinning remain
covered by both-engine smoke. Failed quota writes never delete saved music.
## Post-crash completion — 2026-10-09
Resumed from ff3f934 on codex/phase6. Commit 71608c2 clarifies the diagnostic
waterfall provenance; product code and served build 31617662d2f0 are unchanged.
The durable five-run/120-sample final measurements above were preserved.
Reinstalled Playwright browsers passed fresh compatibility checks:
```sh
node perf/migration.mjs --browser all --profile unthrottled --out perf/results/phase6-migration-unthrottled-2026-10-09.json
node perf/migration.mjs --browser all --profile lossy --out perf/results/phase6-migration-lossy-2026-10-09.json
node perf/hidden-timers.mjs
node perf/lazy.mjs --browser all --out perf/results/phase6-lazy-2026-10-09.json
node perf/seams.mjs --browser all
ASSET_SYNC=0 node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario offline --out perf/results/phase6-sync-rollback-2026-10-09.json
ASSET_HASHING=0 node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario offline --out perf/results/phase6-hashing-rollback-2026-10-09.json
node --test frontend/*.test.js
node --test perf/measurement.test.mjs
(cd server && bun install && PATH="$PWD/../perf/.tmp/test-venv/bin:$PATH" bun run test)
```
Fresh output excerpts (all commands exited successfully):
```text
Migration unthrottled: chromium durationMs=13290; webkit durationMs=11711
Migration lossy: chromium durationMs=27355; webkit durationMs=24426
All four: banners=1 updateReloads=1 cachedFiles=122 legacyURLs=21
All four: userInitiatedOnly=true offline=true playbackGuard=true evictionRepair=true incrementalRefresh=true
Hidden timers: Page errors: none; Result: ALL ASSERTIONS PASSED
Lazy: chromium PASS; webkit PASS
Seams: chromium and webkit checked=[shell,presenter,remote,party,share,stats] offlineNetworkRequests=0 errors=[]
Both rollback flags, both engines: requestCount=0 wireBytes=0
Frontend: tests 223; pass 223; fail 0; skipped 0
Measurement: tests 3; pass 3; fail 0
Server: per-file totals 184 pass; 0 fail
```
The seam output is saved as phase6-seams-2026-10-09.json; the historical Phase 4
result was restored. Offline API transport errors remain distinguished from
script failures. No playback or sw-update.js changes were added. bun.lock is
excluded from commits.
Post-crash cold smoke command:
```sh
node perf/baseline.mjs --runs 1 --browser webkit --profile all --scenario cold --assert-cold --out perf/results/phase6-cold-smoke-2026-10-09.json
```
LTE/lossy FCP was 1253/2881 ms, wire 983326/990152 bytes; both samples passed exactly one positive-byte app.js response. These single-run rechecks do not replace the durable five-run medians.

View File

@@ -8,12 +8,12 @@ const tagHash = bytes => createHash('sha256').update(bytes).digest('hex').slice(
export const injectBuildTag = (source, tag) => source.replace( export const injectBuildTag = (source, tag) => source.replace(
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, JSON.stringify(tag)); /typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, JSON.stringify(tag));
function embedAssets(source, files, groups, buildTag, hashing) { function embedAssets(source, files, groups, buildTag, hashing, assetSync) {
const pageFiles = {}; const pageFiles = {};
for (const group of Object.values(groups)) for (const path of group.files) { for (const group of Object.values(groups)) for (const path of group.files) {
if (path !== '/index.html' && path !== '/sw.js') pageFiles[path] = { ...files[path], h: hashing ? files[path].h : buildTag }; if (path !== '/index.html' && path !== '/sw.js') pageFiles[path] = { ...files[path], h: hashing ? files[path].h : buildTag };
} }
const json = JSON.stringify({ buildTag, groups, files: pageFiles }).replace(/</g, '\\u003c'); const json = JSON.stringify({ buildTag, groups, files: pageFiles, appCache: assetSync }).replace(/</g, '\\u003c');
return source.replace(/(<script type="application\/json" id="ytp-assets">)[\s\S]*?(<\/script>)/, (_, open, close) => open + json + close); return source.replace(/(<script type="application\/json" id="ytp-assets">)[\s\S]*?(<\/script>)/, (_, open, close) => open + json + close);
} }
@@ -70,16 +70,17 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
} }
const files = {}; const files = {};
for (const url of [...bytes.keys()].sort()) files[url] = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' }; for (const url of [...bytes.keys()].sort()) files[url] = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' };
const source = bytes.get('/index.html')?.toString() ?? null; const source = bytes.get('/index.html')?.toString()
.replaceAll('__APP_SCRIPT_HASH__', createHash('sha256').update(bytes.get('/app.js') || '').digest('base64')) ?? null;
const swSource = bytes.get('/sw.js')?.toString() ?? null; const swSource = bytes.get('/sw.js')?.toString() ?? null;
// Derived build metadata cannot be an input to its own hash. Canonicalize // Derived build metadata cannot be an input to its own hash. Canonicalize
// the index meta and SW injected tag, then publish hashes of the final bytes. // the index meta and SW injected tag, then publish hashes of the final bytes.
// All original bytes (including index/SW source) remain inputs via source hashes. // All original bytes (including index/SW source) remain inputs via source hashes.
let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing), files, { hashing }); let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing, assetSync), files, { hashing });
const canonical = { assetSync, files: { ...files }, groups, contracts }; const canonical = { assetSync, files: { ...files }, groups, contracts };
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h }; if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12)); const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
const index = source === null ? null : embedAssets(stampIndex(source, files, { hashing, buildTag }), files, groups, buildTag, hashing); const index = source === null ? null : embedAssets(stampIndex(source, files, { hashing, buildTag }), files, groups, buildTag, hashing, assetSync);
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag) const sw = swSource === null ? null : injectBuildTag(swSource, buildTag)
.replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", JSON.stringify(assetSync)) .replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", JSON.stringify(assetSync))
.replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" + (files['/asset-sync-core.js']?.h || '') + "')"); .replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" + (files['/asset-sync-core.js']?.h || '') + "')");

View File

@@ -88,3 +88,15 @@ test('index embeds build-local group hashes without recursively embedding index/
const legacy=JSON.parse(off.index.match(/id="ytp-assets">([^<]+)<\/script>/)[1]); const legacy=JSON.parse(off.index.match(/id="ytp-assets">([^<]+)<\/script>/)[1]);
expect(legacy.files['/app.js'].h).toBe(off.manifest.buildTag); expect(legacy.files['/app.js'].h).toBe(off.manifest.buildTag);
})); }));
test('app bootstrap CSP hash follows exact app bytes and rollback keeps external execution',()=>fixture(dir=>{
writeFileSync(join(dir,'index.html'),'<meta http-equiv="Content-Security-Policy" content="script-src \'self\' \'sha256-__APP_SCRIPT_HASH__\'"><script type="application/json" id="ytp-assets">{}</script>');
const result=createAssetManifest(dir),digest=createHash('sha256').update('window.app=1').digest('base64');
expect(result.index).toContain("'sha256-"+digest+"'");
expect(JSON.parse(result.index.match(/id="ytp-assets">([^<]+)</)[1]).appCache).toBe(true);
expect(JSON.parse(createAssetManifest(dir,{assetSync:false}).index.match(/id="ytp-assets">([^<]+)</)[1]).appCache).toBe(false);
expect(JSON.parse(createAssetManifest(dir,{hashing:false}).index.match(/id="ytp-assets">([^<]+)</)[1]).appCache).toBe(false);
writeFileSync(join(dir,'app.js'),'window.app=2');
expect(createAssetManifest(dir).index).not.toContain(digest);
}));

View File

@@ -13,7 +13,7 @@ mkdirSync(publicDir);
mkdirSync(dataDir); mkdirSync(dataDir);
mkdirSync(join(publicDir, 'fonts')); mkdirSync(join(publicDir, 'fonts'));
mkdirSync(join(publicDir, 'icons')); mkdirSync(join(publicDir, 'icons'));
writeFileSync(join(publicDir, 'index.html'), '<!doctype html><script type="application/json" id="ytp-assets">{}</script><meta name="ytp-build" content="__BUILD_TAG__"><link rel="stylesheet" href="app.css"><script src="app.js"></script><script src="https://cdn.example/x.js"></script><link rel="stylesheet" href="/root.css">'); writeFileSync(join(publicDir, 'index.html'), '<!doctype html><script type="application/json" id="ytp-assets">{}</script><meta name="ytp-build" content="__BUILD_TAG__"><link rel="preload" href="app.css" as="style"><link rel="stylesheet" href="app.css"><script src="app.js"></script><script src="https://cdn.example/x.js"></script><link rel="stylesheet" href="/root.css">');
writeFileSync(join(publicDir, 'app.css'), 'body{}'); writeFileSync(join(publicDir, 'app.css'), 'body{}');
writeFileSync(join(publicDir, 'app.js'), 'window.app=true;'); writeFileSync(join(publicDir, 'app.js'), 'window.app=true;');
writeFileSync(join(publicDir, 'root.css'), 'body{color:red}'); writeFileSync(join(publicDir, 'root.css'), 'body{color:red}');
@@ -99,7 +99,8 @@ describe('static delivery characterization', () => {
const html = await index.text(); const html = await index.text();
const { buildTag, files } = await (await fetch(`${server.base}/api/manifest`)).json(); const { buildTag, files } = await (await fetch(`${server.base}/api/manifest`)).json();
expect(html).toContain(`<meta name="ytp-build" content="${buildTag}">`); expect(html).toContain(`<meta name="ytp-build" content="${buildTag}">`);
expect(html).toContain(`href="app.css?v=${files['/app.css'].h}"`); expect(html).toContain(`<link rel="preload" href="app.css?v=${files['/app.css'].h}" as="style">`);
expect(html.match(new RegExp('app\\.css\\?v='+files['/app.css'].h,'g'))).toHaveLength(2);
expect(html).toContain(`src="app.js?v=${files['/app.js'].h}"`); expect(html).toContain(`src="app.js?v=${files['/app.js'].h}"`);
expect(html).toContain('https://cdn.example/x.js'); expect(html).toContain('https://cdn.example/x.js');
expect(html).toContain(`href="/root.css?v=${files['/root.css'].h}"`); expect(html).toContain(`href="/root.css?v=${files['/root.css'].h}"`);