diff --git a/cmd/web/main.go b/cmd/web/main.go index 09d70b5..94daf6a 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -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) } diff --git a/web/main.js b/web/main.js index dce1d6c..5d2e82b 100644 --- a/web/main.js +++ b/web/main.js @@ -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), diff --git a/web/tests/smoke.spec.js b/web/tests/smoke.spec.js index 4452933..c5f87ff 100644 --- a/web/tests/smoke.spec.js +++ b/web/tests/smoke.spec.js @@ -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) +})