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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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