From 83bc28c087922a0681f6bc5806bf46307b31e3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 17:15:37 -0400 Subject: [PATCH] Add web host: wasm bridge, page shell, pixel GAME OVER, Playwright smoke tests --- AGENTS.md | 2 +- cmd/web/keys.go | 29 ++++ cmd/web/main.go | 210 ++++++++++++++++++++++++ gallery/app.go | 5 +- gallery/pages.go | 15 +- internal/archtest/archtest_test.go | 51 +++++- web/build.sh | 13 ++ web/index.html | 39 +++++ web/main.js | 231 ++++++++++++++++++++++++++ web/package-lock.json | 58 +++++++ web/package.json | 12 ++ web/playwright.config.js | 13 ++ web/tests/smoke.spec.js | 252 +++++++++++++++++++++++++++++ 13 files changed, 923 insertions(+), 7 deletions(-) create mode 100644 cmd/web/keys.go create mode 100644 cmd/web/main.go create mode 100755 web/build.sh create mode 100644 web/index.html create mode 100644 web/main.js create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/playwright.config.js create mode 100644 web/tests/smoke.spec.js diff --git a/AGENTS.md b/AGENTS.md index ef04865..cbe029c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ Each component adds one `Entry` in `gallery/demos_.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= 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= npx playwright test` checks a deployed site. Tests read `window.__8bit` (`ready`, `stats()`, `texts()`, `page()`, `find(label)`, `log`). ## Review diff --git a/cmd/web/keys.go b/cmd/web/keys.go new file mode 100644 index 0000000..2f33642 --- /dev/null +++ b/cmd/web/keys.go @@ -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 +}() diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..8cc3fe6 --- /dev/null +++ b/cmd/web/main.go @@ -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 +} diff --git a/gallery/app.go b/gallery/app.go index 1ab80ae..1eabd57 100644 --- a/gallery/app.go +++ b/gallery/app.go @@ -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 diff --git a/gallery/pages.go b/gallery/pages.go index b357da8..1c390ad 100644 --- a/gallery/pages.go +++ b/gallery/pages.go @@ -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 } diff --git a/internal/archtest/archtest_test.go b/internal/archtest/archtest_test.go index 0c614ab..49619c7 100644 --- a/internal/archtest/archtest_test.go +++ b/internal/archtest/archtest_test.go @@ -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) + } +} diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 0000000..e611fa2 --- /dev/null +++ b/web/build.sh @@ -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" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..564487d --- /dev/null +++ b/web/index.html @@ -0,0 +1,39 @@ + + + + + +8bitMyGoUI + + + + + + + +
+
+
+ + + + + + diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..b8d619d --- /dev/null +++ b/web/main.js @@ -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))) diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..0b67d1f --- /dev/null +++ b/web/package-lock.json @@ -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" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..0718c01 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/playwright.config.js b/web/playwright.config.js new file mode 100644 index 0000000..8899d14 --- /dev/null +++ b/web/playwright.config.js @@ -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 } +}) diff --git a/web/tests/smoke.spec.js b/web/tests/smoke.spec.js new file mode 100644 index 0000000..efe2009 --- /dev/null +++ b/web/tests/smoke.spec.js @@ -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') +})