#!/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 profile = args.includes('--profile') ? args[args.indexOf('--profile')+1] : 'lossy'; 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 waitAsync(page,predicate,arg,timeout=90000) { const deadline=Date.now()+timeout; while(Date.now() 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 }); 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 waitAsync(page,async () => !!(await navigator.serviceWorker.getRegistration())?.waiting); 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 sleep(4500); assert.equal(navigations,before,'legacy installation does not reload without Refresh UI'); // A legacy startup update can still be in flight through the stall proxy. // Require the target candidate, not a transient earlier waiting worker. await waitAsync(page,async tag => { const reg=await navigator.serviceWorker.getRegistration(); const cache=await caches.open('ytplayer-assets'); return !reg.installing && reg.waiting?.state==='installed' && !!await cache.match('/__ytp_candidate/'+tag); }, manifest.buildTag); const status = await page.evaluate(async()=>SwUpdate.askCacheStatus((await navigator.serviceWorker.getRegistration()).waiting,{timeoutMs:4000})); assert.equal(status?.ready,true,'new worker confirms persistent cache to old helper'); assert.equal(status.version,manifest.buildTag,'status belongs to the target build'); const oldTab = await context.newPage(); await oldTab.goto(origin,{waitUntil:'load'}); assert.equal(await oldTab.locator('meta[name="ytp-build"]').getAttribute('content'),oldTag); const oldBytes = await oldTab.evaluate(async()=>await (await fetch('/app.js')).text()); 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'); assert.equal(await oldTab.evaluate(async()=>await (await fetch('/app.js')).text()),oldBytes,'open legacy tab keeps N-1 plain assets'); await oldTab.close(); // Phase 3 commits core + layout first; idle warming must fill the retained full shell before offline feature checks. await page.evaluate(()=>Lazy.warm()); await waitAsync(page,async files=>{const cache=await caches.open('ytplayer-assets');return (await Promise.all(Object.keys(files).map(async path=>{const response=await cache.match(path+'?v='+files[path].h);return !!response&&response.headers.get('X-Asset-Hash')===files[path].h;}))).every(Boolean);},Object.fromEntries(shell.map(path=>[path==='/'?'/index.html':path,manifest.files[path==='/'?'/index.html':path]]))); const cacheResult = await page.evaluate(async ({ tag, shell, files }) => { const cache = await caches.open('ytplayer-assets'); const missing = [], incorrect = []; for (const path of shell) { const p = path === '/' ? '/index.html' : path; const response = await cache.match(p+'?v='+files[p].h); 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(); assert.equal(stats.requests.filter(r=>r.url.includes('__ytpfresh')).length,0,'legacy helper skips its full download after ready:true'); const durationMs = Date.now() - started; assert.deepEqual(errors, [], 'no script failures during installation and update'); // Exercise the new helper's actual lexical Player guard, without starting // media or changing the production update/outcome code. let guardText=''; page.once('dialog',async dialog=>{guardText=dialog.message();await dialog.dismiss();}); const guard=await page.evaluate(async()=>{ const saved=Player._wantsPlaying;Player._wantsPlaying=true;let reloads=0; try { await SwUpdate.applyUpdate({reg:null,container:navigator.serviceWorker,reload:()=>reloads++});return {reloads,blocked:false}; } catch(e){return {reloads,blocked:/Playing/.test(e.message)};} finally{Player._wantsPlaying=saved;} }); assert.match(guardText,/Playing - update after this song \/ when paused/); assert.deepEqual(guard,{reloads:0,blocked:true}); const refresh=await page.evaluate(async files=>{ const cache=await caches.open('ytplayer-assets'); const key='/async-guard.js?v='+files['/async-guard.js'].h; await cache.delete(key); const requests=[]; const result=await SwUpdate.refreshShellInPlace({cachesApi:caches, fetchFn:(url,init)=>{requests.push(url);return fetch(url,init)},isShellCache:()=>true}); if(!await cache.match(key)) throw Error('Refresh did not repair missing asset'); // Independently test the worker's plain-URL eviction repair. const other='/visible-timer.js?v='+files['/visible-timer.js'].h; await cache.delete(other); const r=await fetch('/visible-timer.js'); if(r.headers.get('X-Asset-Hash')!==files['/visible-timer.js'].h || !await cache.match(other)) throw Error('Evicted asset did not self-heal'); return {result,requests}; },manifest.files); assert.equal(refresh.result.refreshed,1,'thin refresh downloads only its missing asset'); assert.deepEqual(refresh.requests,['/api/manifest','/async-guard.js?v='+manifest.files['/async-guard.js'].h]); 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, userInitiatedOnly: true, playbackGuard: true, evictionRepair: true, incrementalRefresh: true, 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(args.includes('--out') ? resolve(args[args.indexOf('--out')+1]) : join(repo, `perf/results/phase3-migration-${profile}-2026-10-08.json`), JSON.stringify({ commit, results }, null, 2) + '\n'); } finally { rmSync(scratch, { recursive: true, force: true }); }