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
|
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
|
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.
|
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 assert from 'node:assert/strict';
|
||||||
|
import { 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';
|
||||||
@@ -461,6 +462,8 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitForIdleAssets(page);
|
||||||
|
|
||||||
// Now measure warm re-navigation
|
// Now measure warm re-navigation
|
||||||
proxy.resetStats();
|
proxy.resetStats();
|
||||||
proxy.startTracking();
|
proxy.startTracking();
|
||||||
@@ -470,6 +473,7 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
|
|||||||
|
|
||||||
proxy.stopTracking();
|
proxy.stopTracking();
|
||||||
const stats = proxy.getStats();
|
const stats = proxy.getStats();
|
||||||
|
assertWarmAssets(stats.requests);
|
||||||
|
|
||||||
const pageMetrics = await page.evaluate(() => {
|
const pageMetrics = await page.evaluate(() => {
|
||||||
const nav = performance.getEntriesByType('navigation')[0];
|
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
|
// Phase 3 activation precedes idle feature caching. Measure updates from a
|
||||||
// genuinely warm build, rather than counting unfinished first-install bytes.
|
// genuinely warm build, rather than counting unfinished first-install bytes.
|
||||||
if (await page.evaluate(()=>!!window.Lazy && !!window.Lazy.warm)) {
|
await waitForIdleAssets(page);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
// 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;
|
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);
|
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
|
// Readiness excludes background chunks. Include activation and the changed
|
||||||
// feature's actual idle transfer rather than reporting readiness as its cost.
|
// 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'});});
|
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
|
||||||
const end=Date.now()+90000;
|
const end=Date.now()+90000;
|
||||||
while(true) {
|
while(true) {
|
||||||
const complete=await page.evaluate(async h=>{
|
const complete=await page.evaluate(async ({h,p})=>{
|
||||||
const r=await navigator.serviceWorker.getRegistration();
|
const r=await navigator.serviceWorker.getRegistration();
|
||||||
if(r.waiting || r.installing)return false;
|
if(r.waiting || r.installing)return false;
|
||||||
r.active?.postMessage({type:'WARM_ASSETS',saveData: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;
|
return entry?.headers.get('X-Asset-Hash')===h;
|
||||||
},expected);
|
},{h:expected,p:changedPath});
|
||||||
if(complete)break;
|
if(complete)break;
|
||||||
if(Date.now()>end)throw Error('Changed feature was not cached after activation');
|
if(Date.now()>end)throw Error('Changed feature was not cached after activation');
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
@@ -684,14 +674,13 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
|||||||
const duration = Date.now() - t0;
|
const duration = Date.now() - t0;
|
||||||
proxy.stopTracking();
|
proxy.stopTracking();
|
||||||
const stats = proxy.getStats();
|
const stats = proxy.getStats();
|
||||||
if(updateKind==='feature') {
|
assertChangedAssets(stats.requests, updateKind==='feature'?'/presenter.js':updateKind==='css'?'/theme-glass.css':'/app.js');
|
||||||
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');
|
|
||||||
}
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateKind,
|
updateKind,
|
||||||
|
readyDuration,
|
||||||
|
readyWireBytes,
|
||||||
requests: stats.requests,
|
requests: stats.requests,
|
||||||
duration,
|
duration,
|
||||||
requestCount: stats.requestCount,
|
requestCount: stats.requestCount,
|
||||||
@@ -895,6 +884,8 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
results.browsers[bName][prof].updateCss = {
|
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)),
|
requestCount: summarizeList(updCssRuns.map((r) => r.requestCount)),
|
||||||
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
||||||
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
||||||
|
|||||||
@@ -132,4 +132,4 @@ async function run(name){
|
|||||||
console.log(name,'PASS');
|
console.log(name,'PASS');
|
||||||
} finally {await browser?.close();await proxy?.close();await stop();}
|
} 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