mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 04:20:39 +00:00
98 lines
5.7 KiB
JavaScript
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
|
|
}
|
|
|