diff --git a/perf/README.md b/perf/README.md index 3fa5b38..0b64796 100644 --- a/perf/README.md +++ b/perf/README.md @@ -311,3 +311,27 @@ plus the source frontend build tag. WebKit does not expose the long-task API. Migration supports `--out` to preserve earlier phase evidence. Existing lazy harness results are copied to `phase4-lazy-2026-10-08.json` after each Phase 4 run and the Phase 3 result is restored. No production requests or deploys are needed. + +## Phase 5 complete measurements + +```bash +node --test perf/measurement.test.mjs +node perf/baseline.mjs --runs 5 --browser all --profile all --scenario all \ + --out perf/results/final-2026-10-08.json \ + --compare perf/results/baseline-2026-10-07.json +node perf/lazy.mjs --browser all --out perf/results/phase5-lazy-2026-10-08.json +``` + +Warm reloads now wait for verified idle asset caching before counters reset and +assert that no application asset body transfers. CSS update totals now include +activation and the changed inactive stylesheet's verified background cache fill, +matching the feature-update boundary. `readyWireBytes`/`readyDuration` retain the +blocking readiness snapshot separately. Core updates stop at blocking readiness +because app.js is blocking. Every update asserts that the only application asset +payload is the edited file; worker script checks/imports and index/manifest count +as overhead in the total. Historical Phase 3/4 CSS numbers were readiness only. +No product behavior changes are involved in these measurement corrections. + +Keep browser/build/test processes idle during full timing measurements. WebKit's +zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready +null results do not establish playback performance or iPhone audio continuity. diff --git a/perf/baseline.mjs b/perf/baseline.mjs index 142febd..ffc7704 100755 --- a/perf/baseline.mjs +++ b/perf/baseline.mjs @@ -26,6 +26,7 @@ */ import assert from 'node:assert/strict'; +import { assertChangedAssets, assertWarmAssets, waitForIdleAssets } from './measurement.mjs'; import { createHash } from 'node:crypto'; import { spawn, spawnSync, execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs'; @@ -461,6 +462,8 @@ async function runWarmScenario({ browserType, server, proxy, profile }) { }); }); + await waitForIdleAssets(page); + // Now measure warm re-navigation proxy.resetStats(); proxy.startTracking(); @@ -470,6 +473,7 @@ async function runWarmScenario({ browserType, server, proxy, profile }) { proxy.stopTracking(); const stats = proxy.getStats(); + assertWarmAssets(stats.requests); const pageMetrics = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0]; @@ -622,24 +626,7 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi // Phase 3 activation precedes idle feature caching. Measure updates from a // genuinely warm build, rather than counting unfinished first-install bytes. - if (await page.evaluate(()=>!!window.Lazy && !!window.Lazy.warm)) { - await page.evaluate(()=>Lazy.warm()); - const end=Date.now()+90000; - while (true) { - const ready=await page.evaluate(async()=>{ - const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state'); - if(!r)return false; - const m=(await r.json()).current; - return (await Promise.all(Object.values(m.groups).flatMap(g=>g.files).map(async p=>{ - const response=await c.match(p+'?v='+m.files[p].h); - return response?.headers.get('X-Asset-Hash')===m.files[p].h; - }))).every(Boolean); - }); - if(ready)break; - if(Date.now()>end)throw Error('Idle warm did not complete before update benchmark'); - await sleep(100); - } - } + await waitForIdleAssets(page); // Switch server to Tree N+1 (app.js or theme-glass.css modification) const targetTree = updateKind === 'feature' ? trees.treeUpdateFeature : updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss; @@ -662,20 +649,23 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi await sleep(50); } - if (updateKind === 'feature') { + const readyDuration = Date.now() - t0; + const readyWireBytes = proxy.getStats().totalWireBytes; + if (updateKind === 'feature' || updateKind === 'css') { + const changedPath=updateKind==='feature'?'/presenter.js':'/theme-glass.css'; // Readiness excludes background chunks. Include activation and the changed // feature's actual idle transfer rather than reporting readiness as its cost. - const expected = createHash('sha256').update(readFileSync(path.join(targetTree,'presenter.js'))).digest('hex').slice(0,10); + const expected = createHash('sha256').update(readFileSync(path.join(targetTree,changedPath.slice(1)))).digest('hex').slice(0,10); await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});}); const end=Date.now()+90000; while(true) { - const complete=await page.evaluate(async h=>{ + const complete=await page.evaluate(async ({h,p})=>{ const r=await navigator.serviceWorker.getRegistration(); if(r.waiting || r.installing)return false; r.active?.postMessage({type:'WARM_ASSETS',saveData:false}); - const c=await caches.open('ytplayer-assets'),entry=await c.match('/presenter.js?v='+h); + const c=await caches.open('ytplayer-assets'),entry=await c.match(p+'?v='+h); return entry?.headers.get('X-Asset-Hash')===h; - },expected); + },{h:expected,p:changedPath}); if(complete)break; if(Date.now()>end)throw Error('Changed feature was not cached after activation'); await sleep(100); @@ -684,14 +674,13 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi const duration = Date.now() - t0; proxy.stopTracking(); const stats = proxy.getStats(); - if(updateKind==='feature') { - const assetDownloads=stats.requests.filter(r=>r.bytes>0).map(r=>new URL(r.url,navUrl).pathname).filter(p=>/\.(?:js|css|woff2?|png|svg)$/.test(p)&&!['/sw.js','/asset-sync-core.js'].includes(p)); - assert.deepEqual([...new Set(assetDownloads)],['/presenter.js'],'feature edit transfers only its chunk; no app/core/font/icon payload'); - } + assertChangedAssets(stats.requests, updateKind==='feature'?'/presenter.js':updateKind==='css'?'/theme-glass.css':'/app.js'); await browser.close(); return { updateKind, + readyDuration, + readyWireBytes, requests: stats.requests, duration, requestCount: stats.requestCount, @@ -895,6 +884,8 @@ async function main() { } results.browsers[bName][prof].updateCss = { + readyDuration: summarizeList(updCssRuns.map(r=>r.readyDuration)), + readyWireBytes: summarizeList(updCssRuns.map(r=>r.readyWireBytes)), requestCount: summarizeList(updCssRuns.map((r) => r.requestCount)), wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)), duration: summarizeList(updCssRuns.map((r) => r.duration)), diff --git a/perf/lazy.mjs b/perf/lazy.mjs index 6b6092a..b1afee7 100644 --- a/perf/lazy.mjs +++ b/perf/lazy.mjs @@ -132,4 +132,4 @@ async function run(name){ console.log(name,'PASS'); } finally {await browser?.close();await proxy?.close();await stop();} } -try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});} +try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(args.includes('--out')?resolve(args[args.indexOf('--out')+1]):join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});} diff --git a/perf/measurement.mjs b/perf/measurement.mjs new file mode 100644 index 0000000..66035aa --- /dev/null +++ b/perf/measurement.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +export function assertChangedAssets(requests, expected) { + const paths=requests.filter(r=>r.bytes>0).map(r=>new URL(r.url,'http://localhost').pathname).filter(p=>/\.(?:js|css|woff2?|png|svg)$/.test(p)&&!['/sw.js','/asset-sync-core.js'].includes(p)); + assert.deepEqual([...new Set(paths)],[expected],'only the changed application asset transfers; worker checks and derived index/manifest are separate overhead'); +} +export async function waitForIdleAssets(page) { + if(!await page.evaluate(()=>!!window.Lazy?.warm))return; + await page.evaluate(()=>Lazy.warm()); + const end=Date.now()+120000; + while(true) { + const ready=await page.evaluate(async()=>{ + const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state'); + // Legacy rollback has no manifest state and installs its complete shell instead. + if(!r)return (await caches.keys()).some(k=>/^ytplayer-(?!assets|thumbs|fonts)/.test(k)); + const m=(await r.json()).current; + return (await Promise.all(Object.values(m.groups).flatMap(g=>g.files).map(async p=>{ + const response=await c.match(p+'?v='+m.files[p].h); + return response?.headers.get('X-Asset-Hash')===m.files[p].h; + }))).every(Boolean); + }); + if(ready)return; + if(Date.now()>end)throw Error('Idle warm did not complete before benchmark'); + await new Promise(r=>setTimeout(r,100)); + } +} +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')); + assert.deepEqual(assets,[],'warm reload does not transfer unfinished shell assets'); +} diff --git a/perf/measurement.test.mjs b/perf/measurement.test.mjs new file mode 100644 index 0000000..6fd0109 --- /dev/null +++ b/perf/measurement.test.mjs @@ -0,0 +1,16 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { assertChangedAssets, assertWarmAssets } from './measurement.mjs'; +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}]; + assertChangedAssets(requests,'/theme-glass.css'); + assert.throws(()=>assertChangedAssets([...requests,{url:'/app.js?v=new',bytes:10000}],'/theme-glass.css')); + assert.throws(()=>assertChangedAssets(requests.filter(r=>!r.url.includes('theme-glass')),'/theme-glass.css')); + assertChangedAssets([{url:'/presenter.js?v=new',bytes:3200}],'/presenter.js'); + assert.throws(()=>assertChangedAssets([{url:'/presenter.js?v=new',bytes:3200},{url:'/icons/192.png',bytes:1000}],'/presenter.js')); +}); + +test('warm measurements reject unfinished shell downloads but allow API checks',()=>{ + assertWarmAssets([{url:'/api/version',bytes:62},{url:'/app.js?v=current',bytes:0}]); + assert.throws(()=>assertWarmAssets([{url:'/theme-glass.css?v=current',bytes:5000}])); +});