Files

98 lines
5.7 KiB
JavaScript

/**
* One page load, measured: cold cache, real Chromium, optionally on Fast 3G with a
* 4x slower CPU. Shared by the CLI (scripts/perf.mjs, any URL) and the gate
* (e2e/gates/perf.spec.ts, the export), so both read the same numbers.
*/
export const viewports = { desktop: { width: 1440, height: 900 }, mobile: { width: 390, height: 844, isMobile: true, hasTouch: true } }
export const profiles = {
cold: null,
fast3g: { latency: 150, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8, cpu: 4 },
}
const observers = () => {
window.__m = { cls: 0, lcp: null, fcp: null, tbt: 0, inp: 0 }
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (e.name === 'first-contentful-paint') window.__m.fcp = e.startTime
}).observe({ type: 'paint', buffered: true })
new PerformanceObserver((l) => {
const e = l.getEntries().at(-1)
window.__m.lcp = { t: e.startTime, el: e.element ? e.element.tagName + (e.element.id ? '#' + e.element.id : '') + ' ' + (e.url || (e.element.textContent || '').slice(0, 40)) : null, size: e.size }
}).observe({ type: 'largest-contentful-paint', buffered: true })
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (!e.hadRecentInput) window.__m.cls += e.value
}).observe({ type: 'layout-shift', buffered: true })
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (e.startTime > (window.__m.fcp ?? 0)) window.__m.tbt += Math.max(0, e.duration - 50)
}).observe({ type: 'longtask', buffered: true })
new PerformanceObserver((l) => {
for (const e of l.getEntries()) window.__m.inp = Math.max(window.__m.inp, e.duration)
}).observe({ type: 'event', durationThreshold: 16, buffered: true })
}
const kind = (u, type) => (/\.woff2?(\?|$)/.test(u) || type === 'Font' ? 'font' : type === 'Script' ? 'js' : type === 'Stylesheet' ? 'css' : type === 'Document' ? 'html' : type === 'Image' ? 'img' : 'other')
export async function measure(browser, origin, path, vp, prof) {
const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, isMobile: vp.isMobile, hasTouch: vp.hasTouch, deviceScaleFactor: vp.isMobile ? 3 : 1, serviceWorkers: 'block' })
const page = await ctx.newPage()
const cdp = await ctx.newCDPSession(page)
await cdp.send('Network.enable')
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true })
if (prof) {
await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: prof.latency, downloadThroughput: prof.downloadThroughput, uploadThroughput: prof.uploadThroughput })
await cdp.send('Emulation.setCPUThrottlingRate', { rate: prof.cpu })
}
const reqs = new Map()
cdp.on('Network.requestWillBeSent', (e) => {
if (e.redirectResponse) reqs.set(e.requestId + '-r' + reqs.size, { url: e.redirectResponse.url, status: e.redirectResponse.status, type: 'Redirect', bytes: 0 })
reqs.set(e.requestId, { url: e.request.url, type: e.type, bytes: 0, blocking: e.request.renderBlockingBehavior })
})
cdp.on('Network.responseReceived', (e) => {
const r = reqs.get(e.requestId)
if (!r) return
r.status = e.response.status
r.proto = e.response.protocol
const h = e.response.headers
r.headers = { 'cache-control': h['cache-control'], 'content-encoding': h['content-encoding'], 'cf-cache-status': h['cf-cache-status'], age: h['age'], 'content-type': h['content-type'] }
})
cdp.on('Network.loadingFinished', (e) => {
const r = reqs.get(e.requestId)
if (r) r.bytes = e.encodedDataLength
})
await page.addInitScript(observers)
const t0 = Date.now()
await page.goto(origin + path, { waitUntil: 'commit', timeout: 60000 })
// A page whose `load` never comes is a finding, not a crash: -1 says so.
const loaded = await page.waitForLoadState('load', { timeout: 45000 }).then(() => true, () => false)
await page.waitForTimeout(prof ? 4000 : 2500)
// LCP finalises on input; a tap is the cheapest way to fix the value.
await page.mouse.click(5, 5).catch(() => {})
await page.waitForTimeout(300)
const m = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0]
return { ...window.__m, ttfb: nav.responseStart, dcl: nav.domContentLoadedEventEnd, load: nav.loadEventEnd, finalUrl: location.pathname }
})
const list = [...reqs.values()]
const own = (r) => r.url.startsWith(origin)
const sum = (k) => list.filter((r) => own(r) && kind(r.url, r.type) === k).reduce((a, r) => a + r.bytes, 0)
const blocking = await page.evaluate(() =>
performance.getEntriesByType('resource').filter((r) => r.renderBlockingStatus === 'blocking').map((r) => r.name.replace(location.origin, '')),
)
const sample = (k) => list.find((r) => kind(r.url, r.type) === k && r.headers)
const res = {
ttfb: Math.round(m.ttfb), fcp: Math.round(m.fcp ?? -1), lcp: Math.round(m.lcp?.t ?? -1), lcpEl: m.lcp?.el, cls: +m.cls.toFixed(4), tbt: Math.round(m.tbt), inp: Math.round(m.inp), load: loaded ? Math.round(m.load) : -1,
requests: list.filter((r) => r.type !== 'Redirect').length, redirects: list.filter((r) => r.type === 'Redirect').length,
jsBytes: sum('js'), cssBytes: sum('css'), fontBytes: sum('font'), imgBytes: sum('img'), htmlBytes: sum('html'),
totalBytes: list.reduce((a, r) => a + r.bytes, 0),
jsCount: list.filter((r) => own(r) && kind(r.url, r.type) === 'js').length,
thirdPartyBytes: list.filter((r) => !own(r)).reduce((a, r) => a + r.bytes, 0),
renderBlocking: blocking,
headers: { html: sample('html')?.headers, js: sample('js')?.headers, css: sample('css')?.headers, font: sample('font')?.headers },
apiCalls: list.filter((r) => /api\.hanzo\.ai|\/v1\//.test(r.url)).map((r) => r.url.replace(origin, '')),
wall: Date.now() - t0,
}
await ctx.close()
return res
}