Playtest Harness
Autonomous mobile QA for the WebGL/Canvas2D game library.
What it is
Two rungs of game QA: a nightly LLM smoke pass that screenshots every game at a mobile viewport with software WebGL, and a deterministic per-PR Playwright gate that blocks merges on layout, obstruction or render regressions.
Take it with you
The real, committed source behind this system — copy it or download the file. Plus a portable spec of everything on this page.
The per-PR GitHub Actions gate that builds the app and runs the mobile Playwright specs, blocking merges on regressions.
# .github/workflows/playtest-gate.yml
#
# Deterministic mobile gate. Runs on every PR, blocks merge if a scripted
# playthrough or an invariant assertion fails. No API key needed: there is no
# LLM in this loop. The agentic smoke sweep lives in playtest.yml.
#
# playwright.config.ts builds and boots the production app via its webServer
# block, so this workflow stays short.
name: Playtest gate
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 20
cache: npm
# The webServer block rebuilds the whole site before the first spec runs,
# so restoring the Next.js incremental cache is the biggest win here.
- name: Restore Next.js build cache
uses: actions/cache@v5
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*.js', 'src/**/*.jsx', 'src/**/*.mjs', 'next.config.mjs', 'tailwind.config.js') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
- run: npm ci
# Cache the Chromium download; on a hit only the OS deps still install.
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- run: npx playwright test
# On failure, keep the HTML report and traces so you can replay the run.
- if: failure()
uses: actions/upload-artifact@v6
with:
name: playwright-report
path: playwright-report/
retention-days: 7
The Playwright config that builds + boots the prod app and runs an iPhone-class mobile-chromium project with software WebGL.
import { defineConfig } from "@playwright/test";
// Deterministic mobile gate for the games library. Runs the scripted
// playthroughs under .claude/skills/playtest-games/paths and fails the build on
// any violated invariant. No LLM in this loop: this is the fast, deterministic
// rung that gates PRs. The agentic smoke sweep is a separate workflow.
const SWIFTSHADER_ARGS = [
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--ignore-gpu-blocklist",
];
export default defineConfig({
testDir: "./.claude/skills/playtest-games/paths",
testMatch: /.*\.spec\.ts/,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
// Software WebGL (SwiftShader, no GPU) compiles shaders on the CPU and the
// production build boots cold, so give each test more headroom than the 30s
// default to avoid load-dependent flakiness on a runner.
timeout: 60_000,
reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "list",
use: {
baseURL: "http://localhost:3000",
// iPhone-class viewport with touch on. Matches the smoke harness.
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
trace: "retain-on-failure",
screenshot: "only-on-failure",
launchOptions: { args: SWIFTSHADER_ARGS },
},
projects: [{ name: "mobile-chromium", use: { browserName: "chromium" } }],
// Builds and boots the production server, waits for it, tears it down after.
// Confirm these scripts and the port match CLAUDE.md.
webServer: {
command: "npm run build && npm start",
url: "http://localhost:3000",
// A cold production build (prebuild sync + full static generation of the
// whole site) plus boot runs well past the 120s default on a CI runner, so
// give "build && start" generous headroom before the gate gives up waiting.
timeout: 300000,
reuseExistingServer: !process.env.CI,
},
});
Where it lives
- .claude/skills/playtest-games/
- playwright.config.ts
- .github/workflows/playtest-gate.yml
See it in action
Games →FAQ
How does it render WebGL without a GPU?
It boots headless Chromium with SwiftShader software WebGL, so the 3D board renders in CI with no GPU.