Verify production-client migration and record Phase 1 measurements

This commit is contained in:
Jonathan Sykes
2026-10-07 23:12:48 +08:00
parent 84546eeb7d
commit 7c45d098b1
6 changed files with 969 additions and 0 deletions

View File

@@ -141,3 +141,43 @@ node perf/hidden-timers.mjs
``` ```
Exits with code 0 on success, or code 1 if any interval fails to pause, fails to resume, or if page errors occur. Exits with code 0 on success, or code 1 if any interval fails to pause, fails to resume, or if page errors occur.
## Phase 1 production-client migration
Build the production fixture locally without contacting the unavailable production
site (COMMON.md takes precedence over the older live-tag instructions above):
```bash
perf/make-shell-fixture.sh b77938a 1175f1a1d2c1
node perf/migration.mjs # Chromium and WebKit
node perf/migration.mjs --browser webkit # one browser
```
The harness archives the actual `b77938a` server and uses the reconstructed old
frontend, isolated database/media/upload paths, and an ephemeral origin behind the
`lossy` throttle/stall proxy. It restarts onto the current server and frontend at
that same origin, then invokes the OLD application's update check and clicks its
actual Refresh UI button. Assertions cover one banner, exactly one reload, cleared
update-attempt state, all new SHELL entries with correct hashes, no script errors,
and offline boot. Plain, stale `?v=<oldTag>` and `?__ytpfresh=` asset responses are
also compared byte-for-byte with the current files. Results are stored in
`perf/results/phase1-migration-2026-10-07.json`; scratch trees are removed on exit.
No service-worker source is patched or simulated. Offline checks stop Bun and
block the proxy; WebKit avoids its broken native setOffline API. Known WebKit
transport errors for disconnected version/recommendation requests are verified
separately from script errors.
The current server snapshots static bytes at boot; restart it after frontend
changes. `ASSET_HASHING=0` restores the previous single-tag stamp/cache behavior.
The manifest's tag hashes sorted canonical metadata: CSS/JS URLs are stamped
before hashing the index, with its derived build-meta placeholder intact. The
published index/SW hashes describe their final served bytes after tag injection;
these derived hashes are not fed recursively into their own build identity.
JS-created workers/importScripts/dynamic imports retain their existing plain URLs
in Phase 1. Adding a separate loader would require changing the intentionally
unchanged SHELL; worker import URL propagation belongs with the client migration.
WASM binaries are embedded in the existing `sha256-wasm.js`/`loudness-wasm.js`,
which receive hashed URLs as HTML scripts. Font preloads retain plain URLs to match
`fonts.css`; the webmanifest and its icon URLs also stay plain and precached.
`piano-engine.mjs` is manifested but is historically absent from SHELL; this
existing offline piano limitation is deferred to the loader phase.

176
perf/migration.mjs Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Real production-client migration through the unchanged Refresh UI flow.
import assert from 'node:assert/strict';
import { spawn, execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createServer } from 'node:net';
import { fileURLToPath } from 'node:url';
import { createThrottleProxy } from './proxy.mjs';
import { chromium, webkit } from 'playwright';
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)));
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
const args = process.argv.slice(2);
const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all';
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
execFileSync('bash', [join(repo, 'perf/make-shell-fixture.sh'), commit, oldTag], { cwd: repo, stdio: 'inherit' });
const scratch = mkdtempSync(join(repo, 'perf/.tmp/migration-'));
const archive = join(scratch, 'old-server.tar');
writeFileSync(archive, execFileSync('git', ['archive', commit, 'server'], { cwd: repo, maxBuffer: 20 * 1024 * 1024 }));
execFileSync('tar', ['-xf', archive, '-C', scratch]);
symlinkSync(join(repo, 'server/node_modules'), join(scratch, 'server/node_modules'));
// Archived server modules resolve their Docker-style public imports relative
// to the module, while static routes resolve ./public relative to cwd.
symlinkSync(join(repo, `perf/fixtures/shell-${oldTag}`), join(scratch, 'server/public'));
const shell = [...readFileSync(join(repo, 'frontend/sw.js'), 'utf8').match(/const SHELL = \[([\s\S]*?)\n\];/)[1].matchAll(/['"](\/[^'"]+)['"]/g)].map(m => m[1]);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function freePort() {
const server = createServer();
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
async function run(name) {
const cwd = join(scratch, name);
mkdirSync(cwd);
const port = await freePort();
let child, output = '', browser, proxy;
async function stop() {
if (!child) return;
const process = child; child = null;
await new Promise(resolve => {
if (process.exitCode !== null) return resolve();
process.once('exit', resolve); process.kill('SIGTERM');
});
}
async function start(old) {
symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : join(repo, 'frontend'), join(cwd, 'public'));
output = '';
child = spawn('bun', [old ? join(scratch, 'server/server.js') : join(repo, 'server/server.js')], {
cwd, env: { ...process.env, BUILD_TAG: '', ASSET_HASHING: old ? '0' : '1', PORT: String(port),
DATA_DIR: join(cwd, 'data'), DB_PATH: join(cwd, 'db.sqlite'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads') },
stdio: ['ignore', 'pipe', 'pipe'],
});
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { output = (output + chunk).slice(-12000); });
for (let i = 0; i < 200; i++) {
if (child.exitCode !== null) throw new Error(`Bun server exited: ${output}`);
try { if ((await fetch(`http://127.0.0.1:${port}/api/version`)).ok) return; } catch {}
await sleep(100);
}
throw new Error(`Bun startup timeout: ${output}`);
}
try {
await start(true);
assert.equal((await (await fetch(`http://127.0.0.1:${port}/api/version`)).json()).buildTag, oldTag);
proxy = createThrottleProxy({ targetPort: port, profile: 'lossy' });
const proxyPort = await proxy.listen();
const origin = `http://127.0.0.1:${proxyPort}`;
browser = await ({ chromium, webkit }[name]).launch({ headless: true });
const context = await browser.newContext();
// Keep this harness entirely local, including any UI third-party requests.
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
let banners = 0, navigations = 0;
const errors = [];
const failedRequests = [];
let disconnected = false;
const page = await context.newPage();
await page.exposeFunction('migrationBanner', () => { banners++; });
await page.addInitScript(() => {
let visible = false;
new MutationObserver(() => {
const modal = document.querySelector('#modal');
const now = !!modal && !modal.classList.contains('hidden') && /Update available/.test(document.querySelector('#modalTitle')?.textContent || '');
if (now && !visible) window.migrationBanner();
visible = now;
}).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
});
page.on('pageerror', error => errors.push({ message: error.message, disconnected }));
page.on('requestfailed', request => failedRequests.push(request.url()));
page.on('framenavigated', frame => { if (frame === page.mainFrame()) navigations++; });
await page.goto(origin, { waitUntil: 'load', timeout: 90000 });
await page.waitForFunction(() => !!navigator.serviceWorker.controller, { timeout: 90000 });
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), oldTag);
assert.equal(banners, 0, 'no first-install banner');
await stop(); unlinkSync(join(cwd, 'public')); await start(false);
const backend = `http://127.0.0.1:${port}`;
const manifest = await (await fetch(`${backend}/api/manifest`)).json();
assert.notEqual(manifest.buildTag, oldTag);
// Old URLs must continue delivering actual NEW bytes, not error/fallback HTML.
for (const path of ['/app.js', '/theme-glass.css', '/opfs-worker.js', '/sha256-wasm.js', '/manifest.webmanifest', '/icons/icon-192.png', '/fonts/HankenGrotesk-latin.woff2']) {
for (const query of ['', '?__ytpfresh=migration', `?v=${oldTag}`]) {
const response = await fetch(`${backend}${path}${query}`);
assert.equal(response.status, 200);
assert.equal(response.headers.get('x-asset-hash'), manifest.files[path].h);
assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(repo, 'frontend', path.slice(1))));
if (query.includes('?v=')) assert.equal(response.headers.get('cache-control'), 'no-store');
}
}
proxy.resetStats(); proxy.startTracking();
const started = Date.now();
// Use the old application's public functions; no simulated update helper.
await page.evaluate(async () => {
await checkBuildTag();
await (await navigator.serviceWorker.getRegistration()).update();
});
await page.waitForFunction(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting, { timeout: 90000 });
await page.waitForFunction(() => /Update available/.test(document.querySelector('#modalTitle')?.textContent || '') && !document.querySelector('#modal').classList.contains('hidden'));
assert.equal(banners, 1, 'exactly one offer before refresh');
const before = navigations;
await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click();
await page.waitForFunction(tag => document.querySelector('meta[name="ytp-build"]')?.content === tag && typeof checkBuildTag === 'function', manifest.buildTag, { timeout: 90000 });
await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt'));
await page.evaluate(async () => { await checkBuildTag(); await checkBuildTag(); await checkBuildTag(); });
await sleep(2000);
assert.equal(navigations - before, 1, 'one reload, no update loop');
assert.equal(banners, 1, 'no banner after applying the current build');
const cacheResult = await page.evaluate(async ({ tag, shell, files }) => {
const cache = await caches.open(`ytplayer-${tag}`);
const missing = [], incorrect = [];
for (const path of shell) {
const response = await cache.match(path);
if (!response) { missing.push(path); continue; }
if (response.headers.get('x-asset-hash') !== files[path === '/' ? '/index.html' : path]?.h) incorrect.push(path);
}
return { missing, incorrect, count: (await cache.keys()).length };
}, { tag: manifest.buildTag, shell, files: manifest.files });
assert.deepEqual(cacheResult.missing, []);
assert.deepEqual(cacheResult.incorrect, [], 'every cached shell asset belongs to the new build');
proxy.stopTracking();
const stats = proxy.getStats();
const durationMs = Date.now() - started;
assert.deepEqual(errors, [], 'no script failures during installation and update');
disconnected = true;
// Match baseline.mjs: Linux WebKit's setOffline breaks even SW navigation.
// Cut the proxy AND stop Bun so offline boot cannot use network bytes.
if (name !== 'webkit') await context.setOffline(true);
proxy.setOffline(true);
await stop();
await page.reload({ waitUntil: 'load', timeout: 60000 });
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), manifest.buildTag);
await page.waitForFunction(() => typeof checkBuildTag === 'function' && !!document.querySelector('#cards')?.dataset.view);
assert.equal(banners, 1);
// WebKit surfaces rejected SW network fetches as pageerrors even when the
// app catches them. Check only the exact, intentional offline API failures;
// syntax/reference/runtime exceptions must still fail this harness.
for (const error of errors) {
assert.equal(name, 'webkit', 'only WebKit has offline transport pageerrors');
assert.equal(error.disconnected, true, 'transport errors only after disconnection');
assert.ok(error.message === 'TypeError: Load failed' || /\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message), `unexpected offline error: ${error.message}`);
}
if (errors.length) assert.ok(failedRequests.some(url => /\/api\/(?:recommendations|version)/.test(url)), 'transport errors correspond to disconnected API requests');
const result = { browser: name, oldTag, newTag: manifest.buildTag, banners, updateReloads: 1, cachedFiles: cacheResult.count, durationMs, wireBytes: stats.totalWireBytes, offline: true, legacyURLs: 21, profile: 'lossy', offlineTransportErrors: errors.length };
console.log(JSON.stringify(result));
return result;
} finally {
await browser?.close();
if (proxy) { proxy.server.closeAllConnections(); await proxy.close(); }
await stop();
}
}
try {
const results = [];
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
writeFileSync(join(repo, 'perf/results/phase1-migration-2026-10-07.json'), JSON.stringify({ commit, results }, null, 2) + '\n');
} finally { rmSync(scratch, { recursive: true, force: true }); }

View File

@@ -0,0 +1,128 @@
{
"date": "2026-10-07T14:58:19.058Z",
"commit": "6cb00d5073931325b15ec5b773bf15dc2b53816e",
"runsConfigured": 3,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168,
168,
168
]
},
"wireBytes": {
"median": 1042903,
"min": 1042903,
"max": 1042903,
"values": [
1042903,
1042903,
1042903
]
},
"fcp": {
"median": 1576,
"min": 1536,
"max": 1604,
"values": [
1604,
1536,
1576
]
},
"lcp": {
"median": 1924,
"min": 1892,
"max": 1960,
"values": [
1960,
1892,
1924
]
},
"domContentLoaded": {
"median": 10331,
"min": 10304,
"max": 11004,
"values": [
11004,
10304,
10331
]
},
"bootDone": {
"median": 10466,
"min": 10425,
"max": 11184,
"values": [
11184,
10425,
10466
]
},
"longTaskTotal": {
"median": 450,
"min": 379,
"max": 481,
"values": [
481,
379,
450
]
},
"swInstallDuration": {
"median": 40643,
"min": 40439,
"max": 41259,
"values": [
41259,
40439,
40643
]
},
"swInstallBytes": {
"median": 1042903,
"min": 1042903,
"max": 1042903,
"values": [
1042903,
1042903,
1042903
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null,
null,
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28587,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "ee1f3de99a02",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 87,
"durationMs": 19381,
"wireBytes": 1191657,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "ee1f3de99a02",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 87,
"durationMs": 15257,
"wireBytes": 668490,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"offlineTransportErrors": 4
}
]
}

View File

@@ -0,0 +1,296 @@
{
"date": "2026-10-07T15:08:29.812Z",
"commit": "d18d17cf29778f0f3afaccb9df308de0f3a1e46c",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168
]
},
"wireBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"fcp": {
"median": 1628,
"min": 1628,
"max": 1628,
"values": [
1628
]
},
"lcp": {
"median": 1976,
"min": 1976,
"max": 1976,
"values": [
1976
]
},
"domContentLoaded": {
"median": 11020,
"min": 11020,
"max": 11020,
"values": [
11020
]
},
"bootDone": {
"median": 11149,
"min": 11149,
"max": 11149,
"values": [
11149
]
},
"longTaskTotal": {
"median": 460,
"min": 460,
"max": 460,
"values": [
460
]
},
"swInstallDuration": {
"median": 41250,
"min": 41250,
"max": 41250,
"values": [
41250
]
},
"swInstallBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28542,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
},
"warm": {
"requestCount": {
"median": 5,
"min": 5,
"max": 5,
"values": [
5
]
},
"wireBytes": {
"median": 10581,
"min": 10581,
"max": 10581,
"values": [
10581
]
},
"fcp": {
"median": 280,
"min": 280,
"max": 280,
"values": [
280
]
},
"lcp": {
"median": 992,
"min": 992,
"max": 992,
"values": [
992
]
},
"domContentLoaded": {
"median": 787,
"min": 787,
"max": 787,
"values": [
787
]
},
"bootDone": {
"median": 974,
"min": 974,
"max": 974,
"values": [
974
]
},
"longTaskTotal": {
"median": 287,
"min": 287,
"max": 287,
"values": [
287
]
}
},
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 100,
"min": 100,
"max": 100,
"values": [
100
]
},
"lcp": {
"median": 100,
"min": 100,
"max": 100,
"values": [
100
]
},
"domContentLoaded": {
"median": 296,
"min": 296,
"max": 296,
"values": [
296
]
},
"bootDone": {
"median": 327,
"min": 327,
"max": 327,
"values": [
327
]
},
"reloadDuration": {
"median": 349,
"min": 349,
"max": 349,
"values": [
349
]
}
},
"updateJs": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613029,
"min": 613029,
"max": 613029,
"values": [
613029
]
},
"duration": {
"median": 2088,
"min": 2088,
"max": 2088,
"values": [
2088
]
},
"wireBytesByType": {
"html": 18996,
"js": 267655,
"css": 65359,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
},
"updateCss": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613046,
"min": 613046,
"max": 613046,
"values": [
613046
]
},
"duration": {
"median": 1997,
"min": 1997,
"max": 1997,
"values": [
1997
]
},
"wireBytesByType": {
"html": 18996,
"js": 267644,
"css": 65387,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
}
}
}
}
}

View File

@@ -0,0 +1,296 @@
{
"date": "2026-10-07T15:10:34.781Z",
"commit": "84546eeb7da50fdfcfadb5368cbdf9054b014b9e",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"cold": {
"requestCount": {
"median": 168,
"min": 168,
"max": 168,
"values": [
168
]
},
"wireBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"fcp": {
"median": 1564,
"min": 1564,
"max": 1564,
"values": [
1564
]
},
"lcp": {
"median": 1920,
"min": 1920,
"max": 1920,
"values": [
1920
]
},
"domContentLoaded": {
"median": 11001,
"min": 11001,
"max": 11001,
"values": [
11001
]
},
"bootDone": {
"median": 11137,
"min": 11137,
"max": 11137,
"values": [
11137
]
},
"longTaskTotal": {
"median": 430,
"min": 430,
"max": 430,
"values": [
430
]
},
"swInstallDuration": {
"median": 41164,
"min": 41164,
"max": 41164,
"values": [
41164
]
},
"swInstallBytes": {
"median": 1042858,
"min": 1042858,
"max": 1042858,
"values": [
1042858
]
},
"storageEstimate": {
"usage": 3490858,
"quota": 5372199978
},
"mediaReady": {
"median": null,
"min": null,
"max": null,
"values": [
null
]
},
"autoplayBlocked": true,
"wireBytesByType": {
"html": 28542,
"js": 479281,
"css": 130718,
"fonts": 397072,
"images": 6278,
"media": 0,
"api": 336,
"other": 631
}
},
"warm": {
"requestCount": {
"median": 5,
"min": 5,
"max": 5,
"values": [
5
]
},
"wireBytes": {
"median": 10581,
"min": 10581,
"max": 10581,
"values": [
10581
]
},
"fcp": {
"median": 172,
"min": 172,
"max": 172,
"values": [
172
]
},
"lcp": {
"median": 648,
"min": 648,
"max": 648,
"values": [
648
]
},
"domContentLoaded": {
"median": 531,
"min": 531,
"max": 531,
"values": [
531
]
},
"bootDone": {
"median": 641,
"min": 641,
"max": 641,
"values": [
641
]
},
"longTaskTotal": {
"median": 148,
"min": 148,
"max": 148,
"values": [
148
]
}
},
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 80,
"min": 80,
"max": 80,
"values": [
80
]
},
"lcp": {
"median": 240,
"min": 240,
"max": 240,
"values": [
240
]
},
"domContentLoaded": {
"median": 211,
"min": 211,
"max": 211,
"values": [
211
]
},
"bootDone": {
"median": 232,
"min": 232,
"max": 232,
"values": [
232
]
},
"reloadDuration": {
"median": 254,
"min": 254,
"max": 254,
"values": [
254
]
}
},
"updateJs": {
"requestCount": {
"median": 90,
"min": 90,
"max": 90,
"values": [
90
]
},
"wireBytes": {
"median": 613029,
"min": 613029,
"max": 613029,
"values": [
613029
]
},
"duration": {
"median": 1891,
"min": 1891,
"max": 1891,
"values": [
1891
]
},
"wireBytesByType": {
"html": 18996,
"js": 267655,
"css": 65359,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 62,
"other": 631
}
},
"updateCss": {
"requestCount": {
"median": 88,
"min": 88,
"max": 88,
"values": [
88
]
},
"wireBytes": {
"median": 607743,
"min": 607743,
"max": 607743,
"values": [
607743
]
},
"duration": {
"median": 1890,
"min": 1890,
"max": 1890,
"values": [
1890
]
},
"wireBytesByType": {
"html": 18996,
"js": 262403,
"css": 65387,
"fonts": 254048,
"images": 6278,
"media": 0,
"api": 0,
"other": 631
}
}
}
}
}
}