Measure complete CSS updates and fully cached warm reloads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
29
perf/measurement.mjs
Normal 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
16
perf/measurement.test.mjs
Normal 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}]));
|
||||
});
|
||||
Reference in New Issue
Block a user