
Frontend Testing
- 28 installs
- 38 repo stars
- Updated January 16, 2026
- chongdashu/phaserjs-tinyswords
Plan, implement, and debug frontend tests across unit, integration, E2E, visual, and a11y, including canvas/WebGL games.
About
Guides picking the cheapest test layer and eliminating nondeterminism for reliable frontend and Phaser game tests. Used when a developer sets up or stabilizes a frontend test suite.
- Cheapest-layer test decision tree
- Determinism for canvas/WebGL game testing
Frontend Testing by the numbers
- 28 all-time installs (skills.sh)
- Ranked #1,361 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/chongdashu/phaserjs-tinyswords --skill frontend-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 28 |
|---|---|
| repo stars | ★ 38 |
| Last updated | January 16, 2026 |
| Repository | chongdashu/phaserjs-tinyswords ↗ |
What it does
Plan, implement, and debug frontend tests across unit, integration, E2E, visual, and a11y, including canvas/WebGL games.
Files
Frontend Testing
Unlock reliable confidence fast: enable safe refactors by choosing the right test layer, making the app observable, and eliminating nondeterminism so failures are actionable.
Philosophy: Confidence Per Minute
Frontend tests fail for two reasons: the product is broken, or the test is lying. Your job is to maximize signal and minimize “test is lying”.
Before writing a test, ask:
- What user risk am I covering (money, progression, auth, data loss, “can’t start” crashes)?
- What’s the narrowest layer that catches this bug class (pure logic vs UI vs full browser)?
- What nondeterminism exists (time, RNG, async loading, network, animations, fonts, GPU)?
- What “ready” signal can I wait on besides
setTimeout? - What should a failure print/screenshot so it’s diagnosable in CI?
Core principles: 1. Test the contract, not the implementation: assert stable user-meaningful outcomes and public seams. 2. Prefer determinism over retries: make time/RNG/network controllable; remove flake at the source. 3. Observe like a debugger: console errors, network failures, screenshots, and state dumps on failure. 4. One critical flow first: a reliable smoke test beats 50 flaky tests.
Workflow Decision Tree
Pick the test type by the cheapest layer that provides the needed confidence:
- Unit tests (fastest): pure functions, reducers, validators, math, pathfinding, deterministic simulation steps.
- Component/integration tests (medium): UI behavior with mocked IO (React Testing Library / Vue Testing Library / Testing Library DOM).
- E2E tests (slowest, highest confidence): critical user flows across routing, storage, real bundling/runtime.
- Visual regression (specialized): layout/pixel regressions; for canvas/WebGL, only after locking determinism.
- A11y checks: great for DOM UIs; limited value for pure canvas unless you expose accessible DOM overlays.
Quick Start (Any Project)
1. Define 1 smoke flow: “page loads → user can start → one key action works”. 2. Choose runner:
- Prefer Playwright for browser E2E + screenshots.
- Prefer Testing Library for DOM component behavior.
- Prefer unit tests for logic you can run without a browser.
3. Add a “ready” signal in the app (DOM marker, window flag, or game event) and wait on that. 4. Fail loudly: treat console errors and failed requests as test failures. 5. Stabilize: seed RNG, freeze time, fix viewport/DPR, disable animations, and remove network variability.
Playwright Patterns (Especially Useful For Games)
Use Playwright when you need “real browser” confidence:
- Drive input via mouse/keyboard/touch; treat the canvas like the user does.
- Add a test seam: expose a small, stable test API on
window(read-only state + a few commands). - Prefer
waitForFunction-style readiness over sleep; gate on “scene ready” / “assets loaded” / “first frame rendered”. - For screenshots: lock viewport, device scale factor, fonts, and animation timing.
- For 9-slice / canvas UI regressions: add a dedicated UI harness scene/page and assert via targeted screenshots (see
references/phaser-canvas-testing.md).
If using the Playwright MCP tools (browser automation inside Codex), follow the same mindset:
- Use
browser_console_messagesandbrowser_network_requeststo catch silent failures. - Use
browser_evaluateto assertwindow.__TEST__state and to set up deterministic mode. - Use
browser_take_screenshotfor visual assertions after determinism is enforced.
Reconnaissance-Then-Action (Borrowed From Real Debugging)
When a UI is dynamic, don’t guess selectors—recon first, then act:
Quick decision guide:
Task → Is it static HTML (no JS runtime needed)?
├─ Yes → read the HTML to find stable selectors/content, then automate
└─ No → treat as dynamic: run the app, wait for readiness, then inspect rendered state1. Navigate and wait for readiness:
- For many webapps: wait for a meaningful “loaded” element (preferred).
networkidlecan help for SPAs, but avoid it if the app uses websockets/polling.
2. Capture evidence (what the user actually sees):
- screenshot (full page for DOM; targeted for canvas)
- console errors + failed requests
3. Discover selectors from the rendered state:
- prefer role/text/label selectors over brittle CSS
4. Execute actions using discovered selectors and re-check state.
Common pitfall: ❌ Inspect/interact before the app is ready. ✅ Wait on an explicit ready signal (DOM marker or window.__TEST__.ready), not a sleep.
Server Lifecycle Helper (Playwright E2E)
When the dev server isn’t already running, use the bundled helper as a black box:
- Run
python scripts/with_server.py --helpfirst. - Start one (or multiple) servers, wait for their ports, then run your test command.
Example:
python scripts/with_server.py --server "npm run dev" --port 5173 -- npm testFlake Reduction Checklist
- Replace sleeps with explicit readiness conditions.
- Control time (
Date.now, timers), RNG, and animation loops. - Make network deterministic (mock, record/replay, or run against a seeded local backend).
- Eliminate “first-run” differences (asset caches, fonts) or warm them explicitly.
- Lock environment: viewport, DPR, locale/timezone, and rendering settings.
Anti-Patterns to Avoid
❌ Testing the wrong layer: E2E tests for pure logic. Better: unit tests for logic; reserve E2E for integration contracts.
❌ Testing implementation details: asserting DOM structure/classnames or internal engine objects. Better: assert user-meaningful outputs (text, navigation, score/HP changes) or a small stable test seam.
❌ Sleep-driven tests: wait 2s then click. Better: wait on explicit readiness (DOM marker, event, window flag).
❌ Uncontrolled randomness: RNG/time-based behaviors in assertions. Better: seed RNG, freeze time, and assert stable invariants.
❌ Pixel snapshots without determinism (especially canvas/WebGL). Better: add deterministic mode first; then screenshot selectively.
❌ Snapshot explosion: hundreds of snapshots that no one can interpret. Better: keep snapshots targeted (critical screens); prefer specific assertions for behavior.
❌ Retries as a strategy: “just bump retries in CI”. Better: fix readiness and determinism; use retries only as temporary guardrails.
Variation Guidance (Prevent One-Size-Fits-All)
Vary the approach based on:
- UI type: DOM app vs canvas/WebGL game vs hybrid.
- Risk: core revenue/progression flows get E2E first; edge UI polish gets component tests.
- CI constraints: headless-only, limited GPU, slow CPUs, no audio devices.
- Test seam availability: if you can add a stable
window.__TEST__API, assert state; if not, stick to black-box input/output.
Remember
You can make almost any frontend (including canvas/WebGL games) testable by adding a tiny, stable seam for readiness + state. This skill is meant to empower creative, high-signal testing rather than cargo-cult checklists. Aim for tests that are boring to maintain: deterministic, explicit about readiness, and rich in failure evidence. One reliable smoke test is the foundation; everything else compounds from there.
Bundled Resources
Read these only when needed:
references/playwright-mcp-cheatsheet.md: patterns for using Playwright MCP tools for assertions, waiting, and diagnostics.references/phaser-canvas-testing.md: deterministic mode + hooks for Phaser/canvas/WebGL games.references/flake-reduction.md: deeper flake triage and stabilization tactics.
Use these scripts as black boxes (run --help first; don’t read source unless you must):
scripts/with_server.py: start/wait/stop one or more dev servers around a test command.scripts/imgdiff.py: lightweight screenshot diff helper (requirespip install pillow).
Flake Reduction (Frontend)
First classify the flake
1. Readiness flake: app not ready when test interacts (most common). 2. Timing flake: animation/transition/physics changes outcome by milliseconds. 3. Environment flake: viewport/DPR/fonts/GPU differences change rendering. 4. Data flake: network/backend state changes. 5. Concurrency flake: tests interfere via shared storage, ports, global state.
Triage workflow
- Re-run locally with the same flags as CI (headless, same viewport, same env vars).
- Capture evidence on failure:
- console messages
- network requests with status codes
- screenshot at failure moment
- a “state dump” from a stable test seam (e.g.,
window.__TEST__.state())
Fix patterns (in order of leverage)
Readiness
- Add explicit “ready” signals; wait on them.
- Avoid broad
waitForTimeout; preferwaitForFunctionor DOM conditions.
Determinism
- Seed RNG, control time, and remove animation variability.
- For canvas/WebGL: fixed timestep mode for tests.
Isolation
- Reset storage between tests.
- Use unique test data; avoid shared accounts/state.
- Avoid depending on test execution order.
Environment
- Lock viewport/DPR/locale/timezone.
- Ensure fonts are installed/loaded deterministically (or use default system fonts).
Temporary guardrails
- Retries are acceptable only as a short-term measure with a tracked follow-up.
Phaser / Canvas / WebGL Testing Notes
Why canvas/WebGL tests get flaky
Common nondeterminism sources:
- variable frame times (CPU load, headless rendering)
- time-based movement/physics without fixed timestep
- RNG for loot/spawns/AI
- async asset loading and “first frame” races
- font loading differences affecting layout (if mixed DOM+canvas)
The fix is not “more retries”; it’s deterministic mode + explicit readiness.
Deterministic mode (recommended pattern)
When ?test=1 (or a build-time flag) is enabled:
- seed RNG from a known value (and expose it)
- freeze or control time (fixed timestep) for simulation-sensitive assertions
- disable camera shake, screen flash, particles, and audio if they introduce nondeterminism
- ensure assets are preloaded before the first interactive frame
Add a minimal test seam
Expose a stable API (avoid leaking internal Phaser objects unless wrapped):
window.__TEST__ = { ready: false, seed, sceneKey, state: () => ({...}) }- set
ready = trueonly after: - preload completed
- first scene created
- first render tick occurred (optional but helpful for screenshot timing)
If you need to expose entities, expose IDs + essential fields (x/y/hp/state), not sprite instances.
What to assert (avoid brittle assertions)
Prefer invariants that match player-visible behavior:
- “player can start” (scene key, UI state)
- “pressing attack spawns hitbox and reduces enemy HP”
- “collecting coin increments score”
Avoid:
- raw pixel-perfect sprite positions unless you fixed dt and RNG
- asserting on ordering of internally iterated arrays/maps
Screenshot testing: make it reliable
Before comparing screenshots:
- fix viewport size + device scale factor
- fix RNG seed + fixed dt
- wait on
__TEST__.readyand optionally a__TEST__.frameCount >= N - keep snapshots targeted (menus/first scene), not every frame
---
UI slicing regressions (nine-slice / ribbons / bars)
Canvas UI bugs (9-slice seams, padded-frame “side bars”, segmented ribbons, transparent HUD bases) are easiest to catch with a purpose-built UI harness scene rather than trying to reproduce in the full game flow.
Recommended harness pattern
Create a dedicated test page/scene (e.g., test.html) that:
- loads only UI assets
- renders each element on multiple backdrops (dark UI background + “world green” + paper/wood) to expose transparency problems
- renders raw frames and assembled output side-by-side
- supports keyboard toggles (
1..N) and a programmatic seam viawindow.__TEST__.commands.showTest(n)
This is especially high-signal for 9-slice work because the failure mode is visual (gaps/bands), and the correct output is hard to assert via internal state alone.
What to render (minimum set)
- Raw frames: all 9 frames of a 3×3 sheet (catches loader
frameWidth/spacingerrors) - Assembled panel: several target sizes (catches trim/overlap math issues)
- Ribbons/banners: show “raw crop+scale” and “stitched multi-slice” (catches internal transparent gutters)
- Bars: base + track + fill (cropped) over a “world” backdrop (catches transparent-window issues)
Playwright screenshot workflow (practical)
1. Start a static server for public/ 2. page.goto('/test.html') 3. await page.waitForFunction(() => window.__TEST__?.ready) 4. Switch modes: await page.evaluate(() => window.__TEST__.commands.showTest(5)) 5. Screenshot: page.screenshot({ fullPage: true })
For CI: store the screenshots and diff them with scripts/imgdiff.py (after making viewport/DPR deterministic).
Playwright MCP Cheatsheet (Codex)
This reference is for using the Playwright MCP tools during frontend testing tasks (especially canvas/WebGL games).
Mental Model
- Use MCP to reproduce a user flow and collect evidence: console, network, screenshots, and state.
- Prefer explicit readiness over time-based waits.
- Treat any console error (or failed asset request) as a product failure unless explicitly allowed.
High-signal tool patterns
Navigate + wait for app readiness
mcp__playwright__browser_navigateto the URL.mcp__playwright__browser_wait_forwhen a specific text appears (DOM UIs).- For canvas apps, prefer
mcp__playwright__browser_evaluateto poll awindowreadiness flag: - Example readiness seam (recommended in app code):
window.__TEST__ = { ready: true } - For DOM inspection,
mcp__playwright__browser_snapshotis often higher-signal than raw HTML, and is safer than “guessing selectors”.
Assert state (white-box)
mcp__playwright__browser_evaluateto readwindow.__TEST__:window.__TEST__.sceneKey,window.__TEST__.score,window.__TEST__.entities, etc.
Drive input
mcp__playwright__browser_click/mcp__playwright__browser_dragfor pointer flows.mcp__playwright__browser_press_keyfor keyboard-driven movement/combat.mcp__playwright__browser_typefor text fields (menus, names, chats).
Fail fast on “silent” errors
mcp__playwright__browser_console_messagesand fail on anyerror-level messages.mcp__playwright__browser_network_requestsand fail on non-2xx/3xx for required assets.
Visual evidence
mcp__playwright__browser_take_screenshotfor a deterministic frame (lock viewport/DPR first).- If you need pixel diffs in-repo, use
scripts/imgdiff.py.
Recommended “test seams” to add to the app
Minimal, stable, and read-only is best:
window.__TEST__.ready(boolean)window.__TEST__.version(string)window.__TEST__.state()(returns JSON-serializable state snapshot)window.__TEST__.commands(optional small set of commands likereset(),seed(n),skipIntro())
Avoid exposing raw engine objects unless you also provide stable wrappers.
#!/usr/bin/env python3
"""
Minimal image diff utility for visual-regression workflows.
Usage:
python scripts/imgdiff.py baseline.png current.png --out diff.png
Exit codes:
0 = identical
1 = different
2 = error (missing deps, unreadable images, etc.)
"""
from __future__ import annotations
import argparse
import sys
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("baseline")
parser.add_argument("current")
parser.add_argument("--out", default="diff.png")
parser.add_argument("--max-rms", type=float, default=0.0)
args = parser.parse_args()
try:
from PIL import Image, ImageChops # type: ignore
from PIL.ImageStat import Stat # type: ignore
except Exception:
print("Pillow is required. Install with: pip install pillow", file=sys.stderr)
return 2
try:
baseline = Image.open(args.baseline).convert("RGBA")
current = Image.open(args.current).convert("RGBA")
except Exception as exc:
print(f"Failed to read images: {exc}", file=sys.stderr)
return 2
if baseline.size != current.size:
print(f"Different sizes: {baseline.size} vs {current.size}", file=sys.stderr)
return 1
diff = ImageChops.difference(baseline, current)
stat = Stat(diff)
# RMS per channel, then overall RMS
rms_channels = stat.rms
rms = (sum(v * v for v in rms_channels) / len(rms_channels)) ** 0.5
if rms > 0:
diff.save(args.out)
if rms <= args.max_rms:
return 0
print(f"Images differ (RMS={rms:.4f}, threshold={args.max_rms:.4f}). Wrote {args.out}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
Start one or more servers, wait for them to be ready, run a command, then clean up.
Usage:
# Single server
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_test.py
# Multiple servers (repeat --server/--port pairs)
python scripts/with_server.py \
--server "cd backend && python server.py" --port 3000 \
--server "cd frontend && npm run dev" --port 5173 \
-- python your_test.py
"""
from __future__ import annotations
import argparse
import os
import signal
import socket
import subprocess
import sys
import time
from typing import List
def wait_for_port(host: str, port: int, timeout_s: int) -> None:
"""Block until TCP port accepts connections or raise RuntimeError."""
start = time.time()
while time.time() - start < timeout_s:
try:
with socket.create_connection((host, port), timeout=1):
return
except OSError:
time.sleep(0.25)
raise RuntimeError(f"Server not ready on {host}:{port} within {timeout_s}s")
def terminate_process_tree(proc: subprocess.Popen[bytes], grace_s: int) -> None:
"""Terminate a subprocess and (best-effort) its children."""
if proc.poll() is not None:
return
try:
if os.name != "nt":
os.killpg(proc.pid, signal.SIGTERM)
else:
proc.terminate()
except Exception:
proc.terminate()
try:
proc.wait(timeout=grace_s)
return
except subprocess.TimeoutExpired:
pass
try:
if os.name != "nt":
os.killpg(proc.pid, signal.SIGKILL)
else:
proc.kill()
except Exception:
proc.kill()
proc.wait()
def main(argv: List[str]) -> int:
parser = argparse.ArgumentParser(description="Run a command with one or more servers")
parser.add_argument(
"--server",
action="append",
dest="servers",
required=True,
help="Server command (repeatable)",
)
parser.add_argument(
"--port",
action="append",
dest="ports",
type=int,
required=True,
help="Port for each server (must match --server count)",
)
parser.add_argument("--host", default="127.0.0.1", help="Host to poll (default: 127.0.0.1)")
parser.add_argument("--timeout", type=int, default=30, help="Timeout seconds per server (default: 30)")
parser.add_argument("--grace", type=int, default=5, help="Grace seconds before kill (default: 5)")
parser.add_argument(
"command",
nargs=argparse.REMAINDER,
help="Command to run after servers are ready (prefix with -- to separate)",
)
args = parser.parse_args(argv)
command = args.command
if command and command[0] == "--":
command = command[1:]
if not command:
print("Error: no command specified (use `--` before the command).", file=sys.stderr)
return 2
if len(args.servers) != len(args.ports):
print("Error: number of --server and --port arguments must match.", file=sys.stderr)
return 2
procs: List[subprocess.Popen[bytes]] = []
try:
for idx, (cmd, port) in enumerate(zip(args.servers, args.ports), start=1):
print(f"Starting server {idx}/{len(args.servers)}: {cmd}")
proc = subprocess.Popen(
cmd,
shell=True,
start_new_session=(os.name != "nt"),
)
procs.append(proc)
print(f"Waiting for {args.host}:{port} ...")
wait_for_port(args.host, port, timeout_s=args.timeout)
print(f"Ready: {args.host}:{port}")
print(f"\nAll {len(procs)} server(s) ready")
print(f"Running: {' '.join(command)}\n")
result = subprocess.run(command)
return result.returncode
finally:
if procs:
print(f"\nStopping {len(procs)} server(s)...")
for idx, proc in enumerate(procs, start=1):
try:
terminate_process_tree(proc, grace_s=args.grace)
print(f"Server {idx} stopped")
except Exception as exc:
print(f"Failed to stop server {idx}: {exc}", file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))