Add web host: wasm bridge, page shell, pixel GAME OVER, Playwright smoke tests

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 17:15:37 -04:00
1 parent d3b4adebb0
commit 83bc28c087
13 files changed
+923 -7

No files matched your search

Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Builds web/dist: the gallery as wasm plus the page shell.
set -eu
cd "$(dirname "$0")/.."
build=$(git rev-parse --short HEAD)-$(date +%s)
rm -rf web/dist
mkdir -p web/dist
GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" -o web/dist/app.wasm ./cmd/web
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/dist/
for f in index.html main.js; do
sed "s/__BUILD__/$build/g" "web/$f" > "web/dist/$f"
done
echo "web/dist built: $build, app.wasm $(wc -c < web/dist/app.wasm) bytes"
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>8bitMyGoUI</title>
<meta name="description" content="16-bit pixel-art components for MyGo, drawn dot by dot by the same Go code, compiled to WebAssembly.">
<meta name="theme-color" content="#f4eedc">
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #f4eedc; }
#screen { position: fixed; left: 0; top: 0; image-rendering: pixelated; touch-action: none; outline: none; }
#ime { position: fixed; left: 0; top: 0; width: 2px; height: 16px; padding: 0; border: 0; margin: 0;
opacity: 0; resize: none; overflow: hidden; font-size: 16px; caret-color: transparent; }
#loading { position: fixed; inset: 0; display: grid; place-items: center; }
#bar { display: flex; gap: 8px; }
#bar i { width: 24px; height: 24px; background: #e4dcc6; box-shadow: inset 0 0 0 4px #3a2a3e; }
#bar i.on { background: #b8395c; }
#over { position: fixed; inset: 0; background: #1c1730; color: #f3eeff; display: grid; place-items: center;
font: 16px/1.5 ui-monospace, monospace; padding: 24px; }
#over[hidden] { display: none; }
#over h1 { font-size: 48px; letter-spacing: 8px; color: #f0605f; margin: 0 0 16px; }
#over pre { max-width: 80vw; max-height: 50vh; overflow: auto; white-space: pre-wrap; color: #a79dc4; }
#over button { font: inherit; font-size: 24px; color: #1c1730; background: #f07ea0; border: 4px solid #e8dff7; padding: 8px 24px; cursor: pointer; }
</style>
</head>
<body>
<canvas id="screen" tabindex="-1" role="img" aria-label="8bitMyGoUI gallery, drawn by Go in WebAssembly"></canvas>
<textarea id="ime" aria-label="Keyboard input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"></textarea>
<div id="loading" role="progressbar" aria-label="Loading" aria-valuemin="0" aria-valuemax="10" aria-valuenow="0">
<div id="bar"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</div>
<div id="over" hidden role="alertdialog" aria-labelledby="over-title">
<div><h1 id="over-title">GAME OVER</h1><pre id="why"></pre><button id="continue">CONTINUE?</button></div>
</div>
<noscript>This gallery runs Go compiled to WebAssembly. Turn on JavaScript to play.</noscript>
<script src="wasm_exec.js?v=__BUILD__"></script>
<script src="main.js?v=__BUILD__"></script>
</body>
</html>
+231
View File
@@ -0,0 +1,231 @@
// Feeds browser input to the Go gallery; blits its frames.
'use strict'
const screen = document.getElementById('screen')
const ctx = screen.getContext('2d')
const ime = document.getElementById('ime')
const blocks = [...document.querySelectorAll('#bar i')]
const params = new URLSearchParams(location.search)
const mac = /Mac|iPhone|iPad/.test(navigator.platform)
const cursors = ['default', 'pointer', 'text', 'move', 'ew-resize', 'ns-resize', 'nwse-resize', 'nesw-resize',
'not-allowed', 'crosshair', 'grab', 'grabbing', 'n-resize', 'e-resize', 's-resize', 'w-resize', 'col-resize',
'row-resize', 'vertical-text', 'copy', 'alias', 'context-menu', 'none']
const SHIFT = 1, CTRL = 2, ALT = 4, SUPER = 8
const log = []
const asked = params.get('zoom')
const fixed = /^[1-9]$/.test(asked) ? Number(asked) : 0
let api, zoom, scale, timer, first, active = false, skipText = ''
function note (msg) {
log.push(msg)
console.info('8bit:', msg)
}
window.__8bitCrash = (msg) => {
console.error('8bit: crash', msg)
clearTimeout(timer)
document.getElementById('why').textContent = msg
document.getElementById('over').hidden = false
}
document.getElementById('continue').onclick = () => location.reload()
if (asked !== null && !fixed) note(`ignored zoom=${asked}: use a whole number 1–9`)
// size picks device pixels per dot; returns window in dots.
function size () {
zoom = fixed || Math.max(1, Math.round(devicePixelRatio * (innerWidth >= 1000 ? 2 : 1)))
scale = zoom / devicePixelRatio
return [Math.max(1, Math.floor(innerWidth / scale)), Math.max(1, Math.floor(innerHeight / scale))]
}
function hashPath () { return decodeURIComponent(location.hash.slice(1)) }
// show blits a frame and acts on Go's requests.
function show (r) {
if (!r) return
if (screen.width !== r.w || screen.height !== r.h) {
screen.width = r.w
screen.height = r.h
}
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)
if (r.path && '#' + r.path !== location.hash && !(r.path === '/' && location.hash === '')) {
history.pushState(null, '', '#' + r.path)
}
screen.style.cursor = cursors[r.cursor]
active = r.caret !== undefined
if (active) {
ime.style.left = r.caret[0] * scale + 'px'
ime.style.top = r.caret[1] * scale + 'px'
ime.style.height = r.caret[2] * scale + 'px'
}
if (r.copied !== undefined && navigator.clipboard) navigator.clipboard.writeText(r.copied).catch(e => note('clipboard: ' + e))
for (const url of r.open || []) window.open(url, '_blank', 'noopener')
clearTimeout(timer)
if (r.due !== undefined && !document.hidden) timer = setTimeout(() => show(api.frame()), Math.max(0, r.due))
}
function mods (e) {
const m = (e.shiftKey ? SHIFT : 0) | (e.altKey ? ALT : 0) | (e.ctrlKey ? CTRL : 0) | (e.metaKey ? SUPER : 0)
// wasm's ui.Cmd is Ctrl; ⌘ stays Super for line edits.
if (mac && e.metaKey && !/^(Arrow|Backspace|Delete)/.test(e.code)) return (m & ~SUPER) | CTRL
return m
}
function at (e) {
const r = screen.getBoundingClientRect()
return [(e.clientX - r.left) / scale, (e.clientY - r.top) / scale]
}
// Taps click; held fingers drag; moving fingers scroll.
let touch = null
screen.addEventListener('pointerdown', (e) => {
e.preventDefault()
if (!e.isPrimary) return
ime.focus({ preventScroll: true })
screen.setPointerCapture(e.pointerId)
const [x, y] = at(e)
if (e.pointerType === 'touch') {
const t = touch = { x, y, drag: false, scroll: false }
t.hold = setTimeout(() => { t.drag = true; show(api.pointer('down', x, y)) }, 400)
} else if (e.button === 0) show(api.pointer('down', x, y))
else if (e.button === 2) show(api.pointer('right', x, y))
})
screen.addEventListener('pointermove', (e) => {
if (!e.isPrimary) return
const [x, y] = at(e)
if (e.pointerType !== 'touch' || touch?.drag) return show(api.pointer('move', x, y))
if (!touch || (!touch.scroll && Math.hypot(x - touch.x, y - touch.y) * scale <= 8)) return
clearTimeout(touch.hold)
touch.scroll = true
show(api.wheel(x, y, touch.x - x, touch.y - y))
touch.x = x
touch.y = y
})
screen.addEventListener('pointerup', (e) => {
if (!e.isPrimary) return
const [x, y] = at(e)
if (e.pointerType !== 'touch') {
if (e.button === 0) show(api.pointer('up', x, y))
return
}
if (!touch) return
clearTimeout(touch.hold)
if (touch.drag) show(api.pointer('up', x, y))
else if (!touch.scroll) {
api.pointer('down', x, y)
show(api.pointer('up', x, y))
}
touch = null
})
// A release outside the window cancels the press.
screen.addEventListener('pointercancel', (e) => {
if (!touch || !e.isPrimary) return
clearTimeout(touch.hold)
if (touch.drag) show(api.pointer('up', -1, -1))
touch = null
})
screen.addEventListener('contextmenu', (e) => e.preventDefault())
screen.addEventListener('wheel', (e) => {
e.preventDefault()
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1
const [x, y] = at(e)
show(api.wheel(x, y, e.deltaX * unit / scale, e.deltaY * unit / scale))
}, { passive: false })
const keep = new Set(['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End',
'Backspace', 'Delete', 'Enter', 'NumpadEnter', 'Escape'])
window.addEventListener('keydown', (e) => {
if (e.isComposing || !api) return
const m = mods(e)
const cmd = (m & CTRL) && !(m & ALT)
if (cmd && e.code === 'KeyV') return
// Like native hosts: text only to fields focused at keydown.
const typing = active
show(api.key(e.code, m))
if (keep.has(e.code) || (!typing && e.key.length === 1)) e.preventDefault()
})
ime.addEventListener('input', (e) => {
if (e.isComposing) return
const text = e.data || ''
ime.value = ''
if (!e.inputType.startsWith('insert') || e.inputType === 'insertLineBreak' || e.inputType === 'insertFromPaste') return
if (text === skipText) {
skipText = ''
return
}
if (text) show(api.text(text))
})
ime.addEventListener('compositionupdate', (e) => show(api.compose(e.data, [...e.data].length)))
ime.addEventListener('compositionend', (e) => {
api.compose('', 0)
skipText = e.data
setTimeout(() => { skipText = '' }, 0)
ime.value = ''
show(api.text(e.data))
})
ime.addEventListener('paste', (e) => {
e.preventDefault()
show(api.paste(e.clipboardData.getData('text/plain')))
})
window.addEventListener('focus', () => api && show(api.focus(true)))
window.addEventListener('blur', () => api && show(api.focus(false)))
window.addEventListener('popstate', () => api && show(api.go(hashPath() || '/')))
document.addEventListener('visibilitychange', () => api && !document.hidden && show(api.frame()))
let resizing
window.addEventListener('resize', () => {
clearTimeout(resizing)
resizing = setTimeout(() => {
if (!api) return
const [w, h] = size()
show(api.resize(w, h))
}, 50)
})
// load streams the wasm, filling the bar as bytes arrive.
async function load () {
const res = await fetch('app.wasm?v=__BUILD__')
if (!res.ok) throw new Error('app.wasm: HTTP ' + res.status)
const total = Number(res.headers.get('content-length')) || 0
const reader = res.body.getReader()
const parts = []
let got = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
parts.push(value)
got += value.length
const lit = total ? Math.floor(got / total * blocks.length) : Math.min(blocks.length, parts.length)
blocks.forEach((b, i) => b.classList.toggle('on', i < lit))
document.getElementById('loading').setAttribute('aria-valuenow', lit)
}
const bytes = new Uint8Array(got)
let off = 0
for (const p of parts) {
bytes.set(p, off)
off += p.length
}
const go = new Go()
const { instance } = await WebAssembly.instantiate(bytes, go.importObject)
const t0 = performance.now()
go.run(instance).then(() => window.__8bitCrash('The Go program exited.'))
api = window.__8bitGo
if (!api) throw new Error('Go did not export __8bitGo')
note(`wasm ${(got / 1e6).toFixed(1)} MB loaded`)
const [w, h] = size()
show(api.start(w, h, hashPath()))
first = performance.now() - t0
document.getElementById('loading').remove()
ime.focus({ preventScroll: true })
window.__8bit = {
ready: true,
stats: () => ({ ...api.stats(), zoom, first }),
texts: () => api.texts(),
page: () => api.stats().path,
find: (label) => api.find(label)?.map(v => v * scale),
log
}
note(`ready ${w}x${h} dots, ${zoom} pixels per dot, first frame ${first.toFixed(0)} ms`)
}
load().catch((err) => window.__8bitCrash(String(err && err.stack || err)))
+58
View File
@@ -0,0 +1,58 @@
{
"name": "8bitmygoui-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "8bitmygoui-web",
"devDependencies": {
"@playwright/test": "1.64.0"
}
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "8bitmygoui-web",
"private": true,
"type": "module",
"scripts": {
"build": "./build.sh",
"test": "./build.sh && playwright test"
},
"devDependencies": {
"@playwright/test": "1.64.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test'
// BASE_URL runs the tests against a deployed site.
const url = process.env.BASE_URL
export default defineConfig({
testDir: 'tests',
timeout: 60000,
use: { baseURL: url || 'http://localhost:4173', viewport: { width: 1280, height: 800 } },
webServer: url
? undefined
: { command: 'python3 -m http.server 4173 --directory dist', url: 'http://localhost:4173', reuseExistingServer: true }
})
+252
View File
@@ -0,0 +1,252 @@
import { test, expect } from '@playwright/test'
// open loads the page and collects console errors.
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
}
const texts = page => page.evaluate(() => window.__8bit.texts())
const where = page => page.evaluate(() => window.__8bit.page())
// pixels hashes the canvas, to see that it changed.
const pixels = page => page.evaluate(() => {
const c = document.getElementById('screen')
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data
let h = 0
for (let i = 0; i < d.length; i += 4) h = (h * 31 + d[i] + d[i + 1] * 7 + d[i + 2] * 13) | 0
return h
})
async function box (page, label) {
const r = await page.evaluate(l => window.__8bit.find(l), label)
expect(r, `no element "${label}"`).toBeTruthy()
return r
}
async function click (page, label) {
const r = await box(page, label)
await page.mouse.click(r[0] + r[2] / 2, r[1] + r[3] / 2)
}
// touch drags a finger, holding first when hold > 0.
async function touch (page, from, to, hold = 0) {
const cdp = await page.context().newCDPSession(page)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from[0], y: from[1] }] })
if (hold) await page.waitForTimeout(hold)
for (let i = 1; i <= 4; i++) {
const p = { x: from[0] + (to[0] - from[0]) * i / 4, y: from[1] + (to[1] - from[1]) * i / 4 }
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [p] })
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
test('boots, starts on Enter, shows home', async ({ page }) => {
const errors = await open(page)
expect(await texts(page)).toContain('Press start')
await page.keyboard.press('Enter')
expect(await texts(page)).toContain('Foundations')
expect(errors).toEqual([])
})
test('deep links open pages; unknown paths end the game', async ({ page }) => {
const errors = await open(page, '#/f/icons')
expect(await where(page)).toBe('/f/icons')
expect(await texts(page)).toContain('COPY CODE')
await page.goto('/#/nowhere')
await page.waitForFunction(() => window.__8bit?.page() === '/nowhere')
expect(await texts(page)).toContain('No page at /nowhere')
expect(errors).toEqual([])
})
test('slash focuses search without typing a slash', async ({ page }) => {
await open(page, '#/')
await page.keyboard.press('/')
await page.keyboard.type('dith')
expect(await texts(page)).toContain('1 results for "dith"')
await page.keyboard.press('Enter')
expect(await where(page)).toBe('/f/dither')
expect(page.url()).toContain('#/f/dither')
})
test('Chinese text reaches search', async ({ page }) => {
await open(page, '#/')
await page.keyboard.press('/')
await page.keyboard.insertText('像素')
expect(await texts(page)).toContain('1 results for "像素"')
})
test('clicks navigate, Back returns, P repaints', async ({ page }) => {
await open(page, '#/')
await click(page, 'Foundations')
expect(await where(page)).toBe('/cat/foundations')
await click(page, 'Palettes')
expect(await where(page)).toBe('/f/palettes')
await page.goBack()
await page.waitForFunction(() => window.__8bit.page() === '/cat/foundations')
const before = await pixels(page)
await page.keyboard.press('p')
expect(await pixels(page)).not.toBe(before)
})
test('typing in a field stays in the field', async ({ page }) => {
await open(page, '#/f/editor')
await click(page, 'Name')
await page.keyboard.type('p]')
expect(await where(page)).toBe('/f/editor')
expect((await texts(page)).filter(t => t === '> Name changed')).toHaveLength(2)
})
test('keyboard copy and paste use the system clipboard', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await open(page, '#/f/editor')
await click(page, 'Name')
await page.keyboard.type('pixel')
await page.keyboard.press('ControlOrMeta+A')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('pixel')
await page.evaluate(() => navigator.clipboard.writeText('dith'))
await click(page, 'Search components')
await page.keyboard.press('ControlOrMeta+V')
expect(await texts(page)).toContain('1 results for "dith"')
})
test('an input method composes, then commits', async ({ page }) => {
await open(page, '#/')
await page.keyboard.press('/')
const cdp = await page.context().newCDPSession(page)
await cdp.send('Input.imeSetComposition', { text: 'xiang', selectionStart: 5, selectionEnd: 5 })
expect(await texts(page)).not.toContain('1 results for "xiang"')
await cdp.send('Input.insertText', { text: '像' })
expect(await texts(page)).toContain('1 results for "像"')
})
test('Command or Super with Backspace erases the line', async ({ page }) => {
await open(page, '#/')
await page.keyboard.press('/')
await page.keyboard.type('zz dith')
await page.keyboard.press('Meta+Backspace')
await page.keyboard.type('dith')
expect(await texts(page)).toContain('1 results for "dith"')
})
test('wheel scrolls the sidebar', async ({ page }) => {
await open(page, '#/')
const y = (await box(page, 'Display'))[1]
await page.mouse.move(100, 500)
await page.mouse.wheel(0, 100)
expect((await box(page, 'Display'))[1]).toBeLessThan(y)
})
test('an unmapped key keeps animations running', async ({ page }) => {
await open(page)
await page.keyboard.press('Shift')
const frames = (await page.evaluate(() => window.__8bit.stats())).frames
await page.waitForTimeout(600)
expect((await page.evaluate(() => window.__8bit.stats())).frames).toBeGreaterThan(frames)
})
test('copying the same text again reaches the clipboard', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await open(page, '#/f/icons')
await click(page, 'COPY CODE')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('icon.')
await page.evaluate(() => navigator.clipboard.writeText('external'))
await click(page, 'COPY CODE')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('icon.')
})
test('zoom must be a whole number', async ({ page }) => {
await open(page, '?zoom=1.5#/')
const s = await page.evaluate(() => window.__8bit.stats())
expect(Number.isInteger(s.zoom)).toBe(true)
expect(await page.evaluate(() => window.__8bit.log.some(l => l.includes('zoom=1.5')))).toBe(true)
})
test.describe('touch', () => {
test.use({ hasTouch: true })
test('a tap clicks', async ({ page }) => {
await open(page, '#/')
const r = await box(page, 'Foundations')
await page.touchscreen.tap(r[0] + r[2] / 2, r[1] + r[3] / 2)
expect(await where(page)).toBe('/cat/foundations')
})
test('a swipe scrolls', async ({ page }) => {
await open(page, '#/')
const y = (await box(page, 'Display'))[1]
await touch(page, [100, 600], [100, 400])
expect((await box(page, 'Display'))[1]).toBeLessThan(y)
})
test('a canceled hold does not click', async ({ page }) => {
await open(page, '#/')
const r = await box(page, 'Foundations')
const cdp = await page.context().newCDPSession(page)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: r[0] + 20, y: r[1] + 10 }] })
await page.waitForTimeout(500)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] })
expect(await where(page)).toBe('/')
})
test('hold, then drag, selects text', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await open(page, '#/f/editor')
await click(page, 'Name')
await page.keyboard.type('abcdef')
const r = await box(page, 'Name')
await touch(page, [r[0] + 4, r[1] + r[3] / 2], [r[0] + 300, r[1] + r[3] / 2], 500)
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('abcdef')
})
})
test('COPY CODE fills the clipboard', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await open(page, '#/f/icons')
await click(page, 'COPY CODE')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('icon.')
})
test('resize keeps whole pixels per dot', async ({ page }) => {
await open(page, '#/')
await page.setViewportSize({ width: 800, height: 600 })
await page.waitForFunction(() => window.__8bit.stats().w === Math.floor(innerWidth * devicePixelRatio / window.__8bit.stats().zoom))
const s = await page.evaluate(() => window.__8bit.stats())
expect(Number.isInteger(s.zoom)).toBe(true)
})
test('a Go panic shows GAME OVER in pixels; CONTINUE reloads', async ({ page }) => {
await open(page, '#/f/icons')
await page.evaluate(() => window.__8bitGo.pointer('bogus', 0, 0))
expect(await texts(page)).toContain('web: unknown pointer kind bogus')
await expect(page.locator('#over')).toBeHidden()
await click(page, 'CONTINUE?')
await page.waitForFunction(() => window.__8bit?.ready && window.__8bit.page() === '/f/icons')
expect(await texts(page)).toContain('COPY CODE')
})
test('resizing while wasm loads is safe', async ({ page }) => {
const errors = []
page.on('pageerror', e => errors.push(e.message))
await page.route('**/app.wasm*', async route => {
await new Promise(resolve => setTimeout(resolve, 800))
await route.continue()
})
await page.goto('/')
await page.setViewportSize({ width: 900, height: 700 })
await page.waitForFunction(() => window.__8bit?.ready)
expect(errors).toEqual([])
})
test('a load failure shows the page GAME OVER', async ({ page }) => {
await page.route('**/app.wasm*', route => route.fulfill({ status: 404 }))
await page.goto('/')
await expect(page.locator('#over')).toBeVisible()
await expect(page.locator('#why')).toContainText('app.wasm: HTTP 404')
})