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

+1 -1
View File
@@ -71,7 +71,7 @@ Each component adds one `Entry` in `gallery/demos_<category>.go`: ID, Name, Zh,
- `go vet ./... && go test ./...` before reporting done.
- Behavior tests drive `ui.Tester` and assert values.
- `gallery` checks every entry under every palette: only palette colors, whole dots. `PIXEL_SHOTS=<dir> go test ./gallery -run TestInvariants` writes PNGs for visual review.
- Web: `web/build.sh`, then `npx playwright test` in `web/`.
- Web: `npm install && npm test` in `web/` builds `web/dist` and runs Playwright on a local server; `BASE_URL=<url> npx playwright test` checks a deployed site. Tests read `window.__8bit` (`ready`, `stats()`, `texts()`, `page()`, `find(label)`, `log`).
## Review
+29
View File
@@ -0,0 +1,29 @@
//go:build js && wasm
package main
import "github.com/egoist/mygo/ui"
// keys maps KeyboardEvent.code to MyGo keys.
var keys = func() map[string]ui.Key {
m := map[string]ui.Key{
"Enter": ui.KeyEnter, "NumpadEnter": ui.KeyEnter, "Escape": ui.KeyEscape, "Backspace": ui.KeyBackspace,
"Tab": ui.KeyTab, "Space": ui.KeySpace, "Delete": ui.KeyDelete, "Insert": ui.KeyInsert,
"Home": ui.KeyHome, "End": ui.KeyEnd, "PageUp": ui.KeyPageUp, "PageDown": ui.KeyPageDown,
"ArrowLeft": ui.KeyLeft, "ArrowRight": ui.KeyRight, "ArrowUp": ui.KeyUp, "ArrowDown": ui.KeyDown,
"Minus": ui.KeyMinus, "Equal": ui.KeyEqual, "Comma": ui.KeyComma, "Period": ui.KeyPeriod,
"Slash": ui.KeySlash, "Semicolon": ui.KeySemicolon, "Quote": ui.KeyQuote,
"BracketLeft": ui.KeyBracketLeft, "BracketRight": ui.KeyBracketRight, "Backslash": ui.KeyBackslash,
"Backquote": ui.KeyBackquote, "ContextMenu": ui.KeyContextMenu,
"BrowserBack": ui.KeyBack, "BrowserForward": ui.KeyForward,
"F1": ui.KeyF1, "F2": ui.KeyF2, "F3": ui.KeyF3, "F4": ui.KeyF4, "F5": ui.KeyF5, "F6": ui.KeyF6,
"F7": ui.KeyF7, "F8": ui.KeyF8, "F9": ui.KeyF9, "F10": ui.KeyF10, "F11": ui.KeyF11, "F12": ui.KeyF12,
}
for i := range 10 {
m["Digit"+string(rune('0'+i))] = ui.Key0 + ui.Key(i)
}
for i := range 26 {
m["Key"+string(rune('A'+i))] = ui.KeyA + ui.Key(i)
}
return m
}()
+210
View File
@@ -0,0 +1,210 @@
//go:build js && wasm
// Command web runs the gallery in a browser canvas.
package main
import (
"fmt"
"log/slog"
"runtime/debug"
"slices"
"syscall/js"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/gallery"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// dot is DIPs per dot; each dot is one pixel.
const dot = gallery.Dot
type host struct {
app *gallery.App
tt *ui.Tester
w, h int
frames int
times []float64
opened int
over bool
dead bool
began time.Time
}
func main() {
h := &host{app: gallery.New("/")}
api := js.Global().Get("Object").New()
export := func(name string, fn func(args []js.Value) any) {
api.Set(name, js.FuncOf(func(_ js.Value, args []js.Value) any { return h.guard(name, args, fn) }))
}
export("start", h.start)
export("resize", func(a []js.Value) any { h.size(a[0].Int(), a[1].Int()); return h.out() })
export("pointer", h.pointer)
export("wheel", func(a []js.Value) any {
h.tt.Scroll(dip(a[0]), dip(a[1]), dip(a[2]), dip(a[3]))
return h.out()
})
export("key", h.key)
export("text", func(a []js.Value) any { h.tt.Type(a[0].String()); return h.out() })
export("compose", func(a []js.Value) any { h.tt.Compose(a[0].String(), a[1].Int()); return h.out() })
export("paste", func(a []js.Value) any {
h.tt.SetClipboard(a[0].String())
h.tt.Command("paste")
h.tt.SetClipboard("")
return h.out()
})
export("focus", func(a []js.Value) any { h.tt.SetFocused(a[0].Bool()); return h.out() })
export("go", func(a []js.Value) any { h.app.Go(a[0].String()); h.tt.Frame(); return h.out() })
export("frame", func([]js.Value) any { h.tt.Frame(); return h.out() })
export("stats", h.stats)
export("texts", func([]js.Value) any { return toJS(h.tt.Texts()) })
export("find", func(a []js.Value) any {
r, ok := h.tt.Find(a[0].String())
if !ok {
return nil
}
return []any{float64(r.X / dot), float64(r.Y / dot), float64(r.W / dot), float64(r.H / dot)}
})
js.Global().Set("__8bitGo", api)
slog.Info("8bit: wasm ready")
select {}
}
// guard runs a JS call; a panic shows GAME OVER.
func (h *host) guard(name string, args []js.Value, fn func([]js.Value) any) (res any) {
if h.dead {
return nil
}
defer func() {
if r := recover(); r != nil {
slog.Error("8bit: crash", "call", name, "panic", r, "stack", string(debug.Stack()))
res = h.crash(fmt.Sprint(r))
}
}()
// An empty clipboard after a call means nothing was copied.
if h.tt != nil {
h.tt.SetClipboard("")
}
pixel.ResetDue()
h.began = time.Now()
return fn(args)
}
// crash shows GAME OVER; a second panic ends the page.
func (h *host) crash(why string) (res any) {
if h.over || h.w == 0 {
h.dead = true
js.Global().Call("__8bitCrash", why)
return nil
}
h.over = true
defer func() {
if r := recover(); r != nil {
res = h.crash(fmt.Sprintf("%s\n\nGAME OVER failed: %v", why, r))
}
}()
h.tt = ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.WinterNight, Dot: dot})
ui.Column(c).Fill().Center().Children(func() {
if gallery.GameOver(c, why) {
js.Global().Get("location").Call("reload")
}
})
}, h.w*dot, h.h*dot)
h.tt.SetScale(1.0 / dot)
return h.out()
}
// start sizes the window and opens any deep link.
func (h *host) start(a []js.Value) any {
h.w, h.h = a[0].Int(), a[1].Int()
if path := a[2].String(); path != "" {
h.app.Go(path)
}
h.tt = ui.NewTester(h.app.View, h.w*dot, h.h*dot)
h.tt.SetScale(1.0 / dot)
slog.Info("8bit: start", "dots", fmt.Sprintf("%dx%d", h.w, h.h), "path", h.app.Path())
return h.out()
}
func (h *host) size(w, ht int) {
h.w, h.h = w, ht
h.tt.SetSize(w*dot, ht*dot)
slog.Info("8bit: resize", "dots", fmt.Sprintf("%dx%d", w, ht))
}
func (h *host) pointer(a []js.Value) any {
x, y := dip(a[1]), dip(a[2])
switch kind := a[0].String(); kind {
case "move":
h.tt.Move(x, y)
case "down":
h.tt.Press(x, y)
case "up":
h.tt.Release(x, y)
case "right":
h.tt.RightClickAt(x, y)
default:
panic("web: unknown pointer kind " + kind)
}
return h.out()
}
func (h *host) key(a []js.Value) any {
k, ok := keys[a[0].String()]
if !ok {
return nil
}
h.tt.Key(ui.Modifiers(a[1].Int()), k)
return h.out()
}
// out returns the frame and requests for the page.
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())}
if due := pixel.Due(); !due.IsZero() {
res["due"] = float64(time.Until(due).Milliseconds())
}
if r, ok := h.tt.TextCaret(); ok {
res["caret"] = []any{float64(r.X / dot), float64(r.Y / dot), float64(r.H / dot)}
}
if c := h.tt.Clipboard(); c != "" {
res["copied"] = c
}
if urls := h.tt.OpenedURLs(); len(urls) > h.opened {
res["open"] = toJS(urls[h.opened:])
h.opened = len(urls)
}
return res
}
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()}
}
func dip(v js.Value) float32 { return float32(v.Float() * dot) }
func toJS(s []string) any {
out := make([]any, len(s))
for i, v := range s {
out[i] = v
}
return out
}
+4 -1
View File
@@ -9,6 +9,9 @@ import (
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
// Dot is the gallery's DIPs per dot.
const Dot = 2
// App is the gallery that native and web share.
type App struct {
router *ui.Router
@@ -40,7 +43,7 @@ func (a *App) Go(path string) {
}
func (a *App) View(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.Palettes[a.palette], Locale: a.locale})
pixel.Use(c, pixel.Settings{Palette: pixel.Palettes[a.palette], Dot: Dot, Locale: a.locale})
if !a.started {
a.boot(c)
return
+11 -4
View File
@@ -191,15 +191,22 @@ func (a *App) results(c *ui.Context) {
// lost answers unknown paths with GAME OVER and CONTINUE.
func (a *App) lost(c *ui.Context, path string) {
if GameOver(c, "No page at "+path) {
a.Go("/")
}
}
// GameOver shows why the game ended; true on CONTINUE.
func GameOver(c *ui.Context, why string) bool {
k := pixel.K(c)
cont := false
ui.Column(c).Gap(k.D(8)).AlignItems(ui.Center).Padding(k.D(24)).Children(func() {
ui.Box(c).Size(k.D(pixel.Measure(k.Msg("game_over"))*3), k.D(48)).Draw(func(p *ui.Painter, r ui.Rect) {
shadowText(k.Canvas(p, r), 0, 0, k.Msg("game_over"), pixel.Bad, 3)
})
label(c, "No page at "+path, pixel.Faint)
pixel.Text(c, why, pixel.TextOptions{Slot: pixel.Faint, Wrap: true, MaxLines: 6}).Width(k.D(min(pixel.Measure(why), 280)))
pixel.Icon(c, icon.Skull, pixel.IconOptions{Scale: 3})
if button(c, k.Msg("continue")) {
a.Go("/")
}
cont = button(c, k.Msg("continue"))
})
return cont
}
+50 -1
View File
@@ -114,7 +114,8 @@ func TestImportsPointDown(t *testing.T) {
func TestNoBannedCalls(t *testing.T) {
for _, f := range files(t) {
if strings.HasSuffix(f.path, "_test.go") || f.pkg == "pixel" || strings.HasPrefix(f.pkg, "tools/") || strings.HasPrefix(f.pkg, "internal/") {
// cmd/web drives a Tester and builds no views.
if strings.HasSuffix(f.path, "_test.go") || f.pkg == "pixel" || f.pkg == "cmd/web" || strings.HasPrefix(f.pkg, "tools/") || strings.HasPrefix(f.pkg, "internal/") {
continue
}
imported := map[string]string{}
@@ -220,3 +221,51 @@ func TestCommentsAndFileLength(t *testing.T) {
}
}
}
// TestWebScripts applies the comment and length rules to scripts.
func TestWebScripts(t *testing.T) {
marks := map[string]*regexp.Regexp{".js": regexp.MustCompile(`^\s*//\s?(.*)$`), ".sh": regexp.MustCompile(`^\s*#\s(.*)$`)}
seen := 0
err := filepath.WalkDir("../../web", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && slices.Contains([]string{"node_modules", "dist", "test-results", "playwright-report"}, d.Name()) {
return filepath.SkipDir
}
mark := marks[filepath.Ext(p)]
if d.IsDir() || mark == nil {
return nil
}
seen++
src, err := os.ReadFile(p)
if err != nil {
return err
}
lines := strings.Split(string(src), "\n")
if len(lines) > 500 {
t.Errorf("%s: %d lines, keep files under 500", p, len(lines))
}
run := 0
for i, l := range lines {
m := mark.FindStringSubmatch(l)
if m == nil {
run = 0
continue
}
if run++; run > 1 {
t.Errorf("%s:%d: comment of %d lines", p, i+1, run)
}
if words := len(strings.Fields(m[1])); words > 10 {
t.Errorf("%s:%d: comment of %d words: %s", p, i+1, words, strings.TrimSpace(l))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if seen < 4 {
t.Fatalf("found %d web scripts; the walk is broken", seen)
}
}
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')
})