← The Store

Playtest Harness

Autonomous mobile QA for the WebGL/Canvas2D game library.

PipelinesLivePlaywrightChromiumClaude API
30games

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.

playtest-gate.ymlyaml64 lines

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
playwright.config.tstypescript49 lines

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.

Part of these stacks

Related systems

Enrichment OrchestratorFive-agent pipeline that writes SEO metadata for every asset.CRSS Exam-Prep PipelineCorpus checks, snapshots, edit digests and SME queues for an exam bank.HF Open-Model Research CrewA CrewAI crew that scouts, benchmarks and writes up open Hub encoders.

Explore the full catalog →

Want a system like this built for you?Work with me →