Measure complete CSS updates and fully cached warm reloads

This commit is contained in:
Jonathan Sykes
2026-10-08 05:12:32 +08:00
parent ce5a159d4a
commit a08d363f2d
5 changed files with 88 additions and 28 deletions

View File

@@ -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.

View File

@@ -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)),

View File

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

29
perf/measurement.mjs Normal file
View File

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

16
perf/measurement.test.mjs Normal file
View File

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