Files
ytplayer/perf/migration.mjs

177 lines
11 KiB
JavaScript

#!/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 }); }