diff --git a/perf/README.md b/perf/README.md index e9fecd5..55b3ed0 100644 --- a/perf/README.md +++ b/perf/README.md @@ -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. +## 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=` 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. diff --git a/perf/migration.mjs b/perf/migration.mjs new file mode 100644 index 0000000..bec737e --- /dev/null +++ b/perf/migration.mjs @@ -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 }); } diff --git a/perf/results/phase1-cold-2026-10-07.json b/perf/results/phase1-cold-2026-10-07.json new file mode 100644 index 0000000..5ea4664 --- /dev/null +++ b/perf/results/phase1-cold-2026-10-07.json @@ -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 + } + } + } + } + } +} \ No newline at end of file diff --git a/perf/results/phase1-migration-2026-10-07.json b/perf/results/phase1-migration-2026-10-07.json new file mode 100644 index 0000000..f5336d8 --- /dev/null +++ b/perf/results/phase1-migration-2026-10-07.json @@ -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 + } + ] +} diff --git a/perf/results/phase1-overlapped-2026-10-07.json b/perf/results/phase1-overlapped-2026-10-07.json new file mode 100644 index 0000000..c0a0720 --- /dev/null +++ b/perf/results/phase1-overlapped-2026-10-07.json @@ -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 + } + } + } + } + } +} \ No newline at end of file diff --git a/perf/results/phase1-reduced-2026-10-07.json b/perf/results/phase1-reduced-2026-10-07.json new file mode 100644 index 0000000..f848276 --- /dev/null +++ b/perf/results/phase1-reduced-2026-10-07.json @@ -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 + } + } + } + } + } +} \ No newline at end of file