Add web host: wasm bridge, page shell, pixel GAME OVER, Playwright smoke tests
This commit is contained in:
13 files changed
+923
-7
No files matched your search
Executable
+13
@@ -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"
|
||||
@@ -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
@@ -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)))
|
||||
Generated
+58
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user