Measure the budget end to end, and fail on any console error
The Playwright suite now checks PRD section 7 and the budget on the local build and on each preview and live URL. Frame times count the whole frame: Go reports its share after copying the pixels, and the page adds the canvas blit and keeps p50 and p95 over the last 240 frames. A test slows putImageData and expects p95 to show it. The budget test runs on a moving component page and logs what it measures: first frame, p50, p95 and the wasm's brotli bytes, as Cloudflare sent them or compressed locally. Today: first frame about 60 ms, p95 about 10 ms, wasm under 5 MB, against 300 ms, 16 ms and 8 MB. Every page now ends free of console and page errors, whether a test used open() or not; the two tests that fail on purpose say so. Clicks and typing reach components on their own pages.
This commit is contained in:
3 files changed
+89
-22
No files matched your search
+4
-16
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +25,6 @@ type host struct {
|
||||
tt *ui.Tester
|
||||
w, h int
|
||||
frames int
|
||||
times []float64
|
||||
opened int
|
||||
over bool
|
||||
dead bool
|
||||
@@ -165,13 +163,11 @@ func (h *host) key(a []js.Value) any {
|
||||
func (h *host) out() any {
|
||||
img := h.tt.Image()
|
||||
h.frames++
|
||||
h.times = append(h.times, float64(time.Since(h.began).Microseconds())/1000)
|
||||
if len(h.times) > 240 {
|
||||
h.times = h.times[1:]
|
||||
}
|
||||
pix := js.Global().Get("Uint8ClampedArray").New(len(img.Pix))
|
||||
js.CopyBytesToJS(pix, img.Pix)
|
||||
res := map[string]any{"w": img.Rect.Dx(), "h": img.Rect.Dy(), "pix": pix, "path": h.app.Path(), "cursor": int(h.tt.Cursor())}
|
||||
// took is Go's share; the page adds its blit.
|
||||
took := float64(time.Since(h.began).Microseconds()) / 1000
|
||||
res := map[string]any{"w": img.Rect.Dx(), "h": img.Rect.Dy(), "pix": pix, "path": h.app.Path(), "cursor": int(h.tt.Cursor()), "took": took}
|
||||
if due := pixel.Due(); !due.IsZero() {
|
||||
res["due"] = float64(time.Until(due).Milliseconds())
|
||||
}
|
||||
@@ -189,15 +185,7 @@ func (h *host) out() any {
|
||||
}
|
||||
|
||||
func (h *host) stats([]js.Value) any {
|
||||
s := slices.Clone(h.times)
|
||||
slices.Sort(s)
|
||||
pct := func(p float64) float64 {
|
||||
if len(s) == 0 {
|
||||
return 0
|
||||
}
|
||||
return s[int(p*float64(len(s)-1))]
|
||||
}
|
||||
return map[string]any{"frames": h.frames, "p50": pct(0.5), "p95": pct(0.95), "w": h.w, "h": h.h, "path": h.app.Path()}
|
||||
return map[string]any{"frames": h.frames, "w": h.w, "h": h.h, "path": h.app.Path()}
|
||||
}
|
||||
|
||||
func dip(v js.Value) float32 { return float32(v.Float() * dot) }
|
||||
|
||||
+13
-1
@@ -12,6 +12,8 @@ const cursors = ['default', 'pointer', 'text', 'move', 'ew-resize', 'ns-resize',
|
||||
'row-resize', 'vertical-text', 'copy', 'alias', 'context-menu', 'none']
|
||||
const SHIFT = 1, CTRL = 2, ALT = 4, SUPER = 8
|
||||
const log = []
|
||||
// times are recent frames' ms: Go's share plus the blit.
|
||||
const times = []
|
||||
const asked = params.get('zoom')
|
||||
const fixed = /^[1-9]$/.test(asked) ? Number(asked) : 0
|
||||
let api, zoom, scale, timer, first, active = false, skipText = ''
|
||||
@@ -37,11 +39,19 @@ function size () {
|
||||
return [Math.max(1, Math.floor(innerWidth / scale)), Math.max(1, Math.floor(innerHeight / scale))]
|
||||
}
|
||||
|
||||
// percentiles are the median and 95th of the frame times.
|
||||
function percentiles (ms) {
|
||||
const s = [...ms].sort((a, b) => a - b)
|
||||
const at = p => s.length ? s[Math.floor(p * (s.length - 1))] : 0
|
||||
return { p50: at(0.5), p95: at(0.95) }
|
||||
}
|
||||
|
||||
function hashPath () { return decodeURIComponent(location.hash.slice(1)) }
|
||||
|
||||
// show blits a frame and acts on Go's requests.
|
||||
function show (r) {
|
||||
if (!r) return
|
||||
const t = performance.now()
|
||||
if (screen.width !== r.w || screen.height !== r.h) {
|
||||
screen.width = r.w
|
||||
screen.height = r.h
|
||||
@@ -49,6 +59,8 @@ function show (r) {
|
||||
screen.style.width = r.w * scale + 'px'
|
||||
screen.style.height = r.h * scale + 'px'
|
||||
ctx.putImageData(new ImageData(r.pix, r.w, r.h), 0, 0)
|
||||
times.push(r.took + performance.now() - t)
|
||||
if (times.length > 240) times.shift()
|
||||
if (r.path && '#' + r.path !== location.hash && !(r.path === '/' && location.hash === '')) {
|
||||
history.pushState(null, '', '#' + r.path)
|
||||
}
|
||||
@@ -219,7 +231,7 @@ async function load () {
|
||||
ime.focus({ preventScroll: true })
|
||||
window.__8bit = {
|
||||
ready: true,
|
||||
stats: () => ({ ...api.stats(), zoom, first }),
|
||||
stats: () => ({ ...api.stats(), ...percentiles(times), zoom, first }),
|
||||
texts: () => api.texts(),
|
||||
page: () => api.stats().path,
|
||||
find: (label) => api.find(label)?.map(v => v * scale),
|
||||
|
||||
+72
-5
@@ -1,13 +1,27 @@
|
||||
import { brotliCompressSync, constants } from 'node:zlib'
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// open loads the page and collects console errors.
|
||||
// pageErrors keeps each page's console and page errors.
|
||||
const pageErrors = new WeakMap()
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const seen = []
|
||||
pageErrors.set(page, seen)
|
||||
page.on('console', m => { if (m.type() === 'error') seen.push(m.text()) })
|
||||
page.on('pageerror', e => seen.push(e.message))
|
||||
})
|
||||
|
||||
// Every page must end free of console errors.
|
||||
test.afterEach(async ({ page }) => {
|
||||
if (test.info().annotations.some(a => a.type === 'console errors')) return
|
||||
expect(pageErrors.get(page)).toEqual([])
|
||||
})
|
||||
|
||||
// open loads the page and returns errors seen so far.
|
||||
async function open (page, hash = '') {
|
||||
const errors = []
|
||||
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
|
||||
page.on('pageerror', e => errors.push(e.message))
|
||||
await page.goto('/' + hash)
|
||||
await page.waitForFunction(() => window.__8bit?.ready)
|
||||
return errors
|
||||
return pageErrors.get(page)
|
||||
}
|
||||
|
||||
const texts = page => page.evaluate(() => window.__8bit.texts())
|
||||
@@ -225,6 +239,7 @@ test('resize keeps whole pixels per dot', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('a Go panic shows GAME OVER in pixels; RETRY reloads', async ({ page }) => {
|
||||
test.info().annotations.push({ type: 'console errors', description: 'the panic is on purpose' })
|
||||
await open(page, '#/f/icons')
|
||||
await page.evaluate(() => window.__8bitGo.pointer('bogus', 0, 0))
|
||||
expect(await texts(page)).toContain('web: unknown pointer kind bogus')
|
||||
@@ -262,8 +277,60 @@ test('scripts and wasm have content-addressed, immutable URLs', async ({ page, r
|
||||
})
|
||||
|
||||
test('a load failure shows the page GAME OVER', async ({ page }) => {
|
||||
test.info().annotations.push({ type: 'console errors', description: 'the 404 is on purpose' })
|
||||
await page.route('**/a/app.*.wasm', route => route.fulfill({ status: 404 }))
|
||||
await page.goto('/')
|
||||
await expect(page.locator('#over')).toBeVisible()
|
||||
await expect(page.locator('#why')).toContainText(/a\/app\.[0-9a-f]{12}\.wasm: HTTP 404/)
|
||||
})
|
||||
|
||||
test('components take clicks and typing on their pages', async ({ page }) => {
|
||||
const errors = await open(page, '#/c/066-button')
|
||||
// The demo's log panel shows each event as "> event".
|
||||
const logged = s => page.waitForFunction(l => window.__8bit.texts().includes(l), '> ' + s)
|
||||
await click(page, 'PRIMARY')
|
||||
await logged('PRIMARY')
|
||||
await page.goto('/#/c/082-textinput')
|
||||
await page.waitForFunction(() => window.__8bit.page() === '/c/082-textinput')
|
||||
await click(page, 'Hero name')
|
||||
await page.keyboard.type('Zed')
|
||||
await logged('name Zed')
|
||||
await page.keyboard.press('Enter')
|
||||
await logged('submitted Zed')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('the budget: first frame, p95 frame, wasm with brotli', async ({ page, request }) => {
|
||||
const errors = await open(page, '#/c/295-starfield')
|
||||
// Stars rush at 12 fps; sixty frames fill the window.
|
||||
await page.waitForFunction(() => window.__8bit.stats().frames >= 60)
|
||||
const s = await page.evaluate(() => window.__8bit.stats())
|
||||
const [wasm] = await page.evaluate(() => performance.getEntriesByType('resource')
|
||||
.filter(e => e.name.endsWith('.wasm')).map(e => ({ url: e.name, sent: e.encodedBodySize, size: e.decodedBodySize })))
|
||||
let br = wasm.sent
|
||||
if (br >= wasm.size) {
|
||||
// Served plain: compress as Cloudflare's brotli does.
|
||||
const body = await (await request.get(wasm.url)).body()
|
||||
br = brotliCompressSync(body, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } }).length
|
||||
}
|
||||
console.log(`budget: first frame ${s.first.toFixed(0)} ms, p50 ${s.p50.toFixed(1)} ms, p95 ${s.p95.toFixed(1)} ms, wasm ${(br / 1e6).toFixed(2)} MB brotli`)
|
||||
expect(s.first).toBeLessThanOrEqual(300)
|
||||
expect(s.p95).toBeLessThanOrEqual(16)
|
||||
expect(br).toBeLessThanOrEqual(8e6)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('frame times count the drawing', async ({ page }) => {
|
||||
// A slow canvas must show in p95, not hide.
|
||||
await page.addInitScript(() => {
|
||||
const put = CanvasRenderingContext2D.prototype.putImageData
|
||||
CanvasRenderingContext2D.prototype.putImageData = function (...a) {
|
||||
const until = performance.now() + 25
|
||||
while (performance.now() < until) {}
|
||||
return put.apply(this, a)
|
||||
}
|
||||
})
|
||||
await open(page, '#/c/295-starfield')
|
||||
await page.waitForFunction(() => window.__8bit.stats().frames >= 20)
|
||||
expect((await page.evaluate(() => window.__8bit.stats())).p95).toBeGreaterThanOrEqual(25)
|
||||
})
|
||||
Reference in new issue
Block a user