Files
hanzo.ai/e2e/gates/perf.spec.ts

91 lines
3.9 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { brotliCompressSync } from 'node:zlib'
import { OUT, pages, serveExport } from './export'
import { measure, profiles, viewports } from '../../scripts/perf-measure.mjs'
/**
* A PAGE THAT GETS HEAVIER FAILS THE MERGE THAT MADE IT SO.
*
* `perf.budget.json` holds the limits: `js` is the first-party JavaScript a page
* downloads before it is interactive, in KB after brotli; `lcp` is milliseconds
* to the largest paint on Fast 3G with a 4x slower CPU; `cls` is layout shift.
* `default` covers every page, `pages` raises one, and `vitals` names the pages
* driven in a browser.
*
* The bytes are read off the export, so every shipped page is held to its limit
* for the price of reading files. The paint is measured in Chromium over the
* same export, served with brotli, because a byte count says nothing about
* whether the headline waits for a face that is queued behind the scripts.
* Third-party tags load after `load` and are not in the JS figure.
*/
type Limit = { js: number; lcp: number; cls: number }
const budget: { default: Limit; pages?: Record<string, Partial<Limit>>; vitals: string[] } = JSON.parse(
readFileSync(join(OUT, '..', 'perf.budget.json'), 'utf8'),
)
const limit = (route: string): Limit => ({ ...budget.default, ...budget.pages?.[route] })
const sizes = new Map<string, number>()
const brotli = (src: string): number => {
let size = sizes.get(src)
if (size === undefined) {
size = brotliCompressSync(readFileSync(join(OUT, decodeURIComponent(src.split('?')[0])))).length
sizes.set(src, size)
}
return size
}
/** Script URLs a browser fetches to load `html`: the `nomodule` fallback is not one. */
const scripts = (html: string): string[] => [
...new Set(
[...html.matchAll(/<script\b[^>]*\bsrc="(\/[^"]+\.js[^"]*)"[^>]*>/g)]
.filter((m) => !/\bnoModule\b/i.test(m[0]))
.map((m) => m[1]),
),
]
test('no page ships more first-party JavaScript than its budget', () => {
const over: string[] = []
for (const { route, file } of pages()) {
const kb = scripts(readFileSync(file, 'utf8')).reduce((sum, src) => sum + brotli(src), 0) / 1024
const cap = limit(route).js
if (kb > cap) over.push(`${route}: ${kb.toFixed(0)} KB > ${cap} KB`)
}
expect(over, 'raise perf.budget.json only for a page that earns its weight').toEqual([])
})
test.describe('the first paint', () => {
test.describe.configure({ mode: 'serial' })
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport({ compress: true })
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
for (const route of budget.vitals) {
test(`${route} paints and holds still within budget on Fast 3G`, async ({ browser }) => {
test.setTimeout(300_000)
const cap = limit(route)
// The best of three per viewport: a shared machine only ever adds time, so
// the fastest load says what the page costs. -1 is a load that never
// painted, which is a failure and not a fast page.
const best = async (viewport: (typeof viewports)[keyof typeof viewports]) => {
const runs = []
for (let i = 0; i < 3; i++) runs.push(await measure(browser, base, route, viewport, profiles.fast3g))
const painted = runs.filter((r) => r.lcp > 0)
return { lcp: painted.length ? Math.min(...painted.map((r) => r.lcp)) : -1, cls: Math.min(...runs.map((r) => r.cls)) }
}
for (const [name, viewport] of Object.entries(viewports)) {
const got = await best(viewport)
expect(got.lcp, `${route} ${name} painted`).toBeGreaterThan(0)
expect(got.lcp, `${route} ${name} LCP ms`).toBeLessThanOrEqual(cap.lcp)
expect(got.cls, `${route} ${name} CLS`).toBeLessThanOrEqual(cap.cls)
}
})
}
})