BLIT386BLIT386
DocumentationReference

Testing

Testing tiers (unit, integration, visual) plus CPU benchmarks and WebGPU mocks.

BLIT386 uses three primary testing tiers (unit, integration, visual) plus a benchmark tier for CPU performance regression tracking, compared locally rather than in CI.

Architecture

Tier 1: Unit tests (Vitest, Node.js)

Pure logic with no GPU dependencies. Tests run in Node.js for maximum speed. The list below is illustrative - see src/**/*.test.ts for the full set (~110 files).

  • Vector2i - all arithmetic, distance, comparison, factory methods
  • Rect2i - intersection, containment, computed properties
  • Color32 - color math, conversions, blending
  • Palette - construction, set/get, named aliases, serialization, GPU float layout, preset factories
  • PaletteEffect - manager lifecycle, CycleEffect rotation, FadeEffect/FadeRangeEffect lerp, FlashEffect, paletteSwap
  • Easing - boundary values, monotonicity, midpoint checks for all easing curves
  • GameLoop - constructor validation, tick counter, step() and pending-frame cancellation
  • IBTDemo - defaultConfig(), optional configure(), and the testState() hook contract
  • Random, hash1i/2i/3i, and the noise classes - seeded determinism, clone() / fork(), seedValue
  • SpriteOrientation, TileGrid, NineSlice, Rect2i.fromTile - flip/turn/scale math, tile-grid validation, and the nine-slice strip layout behind BT.drawSprite params, BT.drawTile, and BT.drawNineSlice

Tier 2: Integration tests (Vitest, Node + GPU mocks)

Code that requires WebGPU objects or DOM APIs. Most tests run in Node with src/__test__/webgpu-mock.ts for GPU stubs and vi for browser API stubs. Tests that need a full DOM (Bootstrap, BootstrapHelpers) opt into happy-dom via the // @vitest-environment happy-dom directive. Inventory is illustrative - not exhaustive.

  • AssetLoader - image caching and deduplication, evict(), and loadingCount (Node + vi stubs for Image)
  • SpriteSheet - UV calculation, lazy texture creation, indexization, loadIndexed() convenience flow, getIndexedPixels() defensive-copy semantics, and status/progress load tracking (Node + GPU mocks)
  • AudioClip - decode cache, deduplication, fallback URL lists, loadingCount, and hot-replace wiring (Node + Web Audio mocks)
  • BitmapFont - glyph lookup, text measurement (Node + vi stubs)
  • BootstrapHelpers - WebGPU support detection, canvas lookup (happy-dom)
  • Bootstrap - full bootstrap lifecycle, including ?backend=software WebGPU-skip path, double-bootstrap() guard (Promise<boolean>), and hot-swap delegation (happy-dom)
  • HotRuntime / HotSwap - Vite HMR context registration, tier selection, asset hot-replace routing (Node)
  • WakeLock / Orientation - acquire/release lifecycle, silent no-op fallbacks (Node + vi stubs)
  • PointerInput - touch-action gating for scroll capture and palette-band override, a press carried across a frame that ran no update(), and an empty pointerType counting as the mouse (happy-dom)
  • KeyboardInput and keyboardRuntimeMaps - isAnyKey* edges and repeat, the 'versus' / 'single' layouts, and BT.inputMapReset() (happy-dom; the runtime map tables in Node)
  • GamepadInput - digital triggers (BTN_L2 / BTN_R2 at the 50% threshold) and isAnyButton* (Node + vi stubs)
  • clientToDisplayPos - the one conversion shared by pointer input and BT.nativeScreenToDisplayPos (Node)
  • AudioManager - unlock state machine and the park/unpark pair a renderAt seek uses (happy-dom + Web Audio mocks)
  • WebGPURenderer - frame lifecycle, camera, pipeline delegation (Node + GPU mocks)
  • SoftwareRenderer - frame lifecycle, palette enforcement, camera offsets, indexed sprite blits, bitmap text, captureFrame() semantics, and unsupported-effects assertions (Node + 2D canvas mocks)
  • PrimitivePipeline - vertex buffer math, line algorithm (Node + GPU mocks)
  • SpritePipeline - texture batching, UV coordinates (Node + GPU mocks)
  • WebGPUContext - initialization with mock adapter/device (Node + GPU mocks)
  • BTAPI - singleton coordinator; lazy-loads WebGPURenderer after WebGPU init succeeds; includes software-mode init, ?backend=software URL override, BT.requestedBackend vs. BT.activeBackend after WebGPU fallback, captureFrame() in software mode, BT.loadingAssetsCount, overlay render path, BT.isReady across init and re-init, BT.testState(), and BT.renderAt / BT.resume (from-start replay on a fresh instance, failed replay keeps the previous instance, per-update palette effect timing, audio parked during a seek) (Node + GPU mocks + 2D canvas mocks)
  • Overlay - colocated tests under src/overlay/*.test.ts: label parsing, layout helpers, layoutPlan golden Y positions for 320×240 (including custom rows, palette grid variable bottom band, and timing chart scaffold cases), PaletteView.computeGrid width/size matrix, PaletteInteraction hit-test/scroll/tooltip helpers, RenderPaletteUsage mask helpers (markIndexUsed, collectUsedIndices), toggle hit-testing, and updateAndRender integration (Node)
  • FrameCapture - GPU readback, PNG conversion (Node + GPU mocks + browser stubs)
  • consumer-doc-imports - src/docs/consumer-doc-imports.test.ts guards README/docs import paths against BLIT386.ts exports

Tier 3: Visual regression (Playwright, Chromium)

Actual GPU rendering verified via screenshot comparison. Requires Chrome with WebGPU flags enabled.

Most specs cover both the default WebGPU backend and the ?backend=software software backend, producing separate baseline screenshots for each. post-process.spec.ts is WebGPU-only (software backend throws on effects). Every fixture publishes window.__ACTIVE_BACKEND__ and the specs open it through tests/visual/open-fixture.ts: a WebGPU test skips when the engine silently fell back to software, and a software test asserts 'software', so a fallback can no longer pass a WebGPU baseline by accident.

  • Primitives - pixel, line, and rectangle rendering (WebGPU + software)
  • Sprites - palette-indexed sprite rendering, palette offsets, batching, plus the sprites-oriented, sprites-stretched, and sprites-nineslice fixtures for flips and quarter turns, scale and Rect2i stretch, and BT.drawNineSlice
  • Render-at - no snapshots: under a manual requestAnimationFrame clock, the page that played to a tick and a fresh page that seeks to it with BT.renderAt must match byte for byte, on both backends
  • Camera - camera offset transforms applied to all geometry
  • Fonts - placeholder text rendering at known positions
  • Mixed - primitives and sprites combined with correct layering
  • Post-process - baseline scene, pixel/display-tier effects, CRT preset stacks, upscale filters

Tier 4: CPU benchmarks (Vitest bench)

Hot-path performance checks for methods and allocation patterns. Benchmarks run locally only - compare a feature branch against a main baseline with pnpm run bench:compare; see Performance Testing. Extra flags go after -- (pnpm run bench:compare -- --threshold 10); the separator itself is ignored.

Declaration tooling checks

Public types are rolled up during pnpm run build via vite-plugin-dts and API Extractor. The workspace pins TypeScript to the same version API Extractor bundles (see Developer Experience).

  • pnpm run test:declarations - Node test runner for scripts/check-declaration-tooling.mjs (drift patterns, required BT getters in rolled-up .d.ts, and alignment log parsing). Included in pnpm run preflight.
  • CI - after pnpm run build, node scripts/check-declaration-tooling.mjs build.log runs in the .github/workflows/ci.yml build-library and bundle-size jobs to fail on drift warnings and version mismatch.
  • Manual - pnpm run build 2>&1 | tee build.log && node scripts/check-declaration-tooling.mjs build.log

Commands

pnpm run test                   # Run all unit tests (alias for test:unit)
pnpm run test:unit              # Run all unit tests
pnpm run test:unit:watch        # Watch mode for development
pnpm run test:unit:coverage     # Coverage report (80% minimum threshold)
pnpm run test:declarations      # Declaration tooling log checker (Node test)
pnpm run test:visual            # Playwright visual regression (requires Chrome)
pnpm run test:visual:update     # Update visual test baselines
pnpm run test:visual:coverage   # Visual tests with Istanbul coverage report
pnpm run bench                  # Run CPU benchmarks (Vitest bench)
pnpm run bench:json             # Run CPU benchmarks and write benchmark-results.json
pnpm run bench:baseline         # Run CPU benchmarks and write benchmark-baseline.json
pnpm run bench:compare          # Compare benchmark-results.json against benchmark-baseline.json

Test file location

Tests are colocated next to their source files:

src/utils/Vector2i.ts       - Source
src/utils/Vector2i.test.ts  - Test

Visual regression tests live in a separate directory:

tests/visual/
  fixtures/             - HTML pages and Vite config
  __snapshots__/        - Git-tracked reference screenshots
  coverage-fixture.ts   - Playwright fixture for Istanbul coverage
  open-fixture.ts       - Opens a fixture and reports the backend that actually rendered
  primitives.spec.ts    - Primitive rendering visual test
  sprites.spec.ts       - Sprite rendering visual test
  camera.spec.ts        - Camera transform visual test
  fonts.spec.ts         - Text rendering visual test
  mixed.spec.ts         - Combined rendering visual test
  post-process.spec.ts  - Post-process effect chain visual test
  render-at.spec.ts     - Live loop vs. BT.renderAt seek, byte-for-byte

Writing a new test

import { , ,  } from 'vitest';

import {  } from './MyClass';

('MyClass', () => {
  ('myMethod', () => {
    ('should do something', () => {
      const  = new ();
      (.()).();
    });
  });
});

Conventions:

  • Use describe/it (not test)
  • Follow source code style (4-space indent, single quotes, semicolons)
  • No emoji in test descriptions
  • No JSDoc required in test files

Palette testing patterns

Use these patterns when adding or reviewing palette-related features:

  • Slot-zero invariants: assert slot 0 remains transparent and draw paths treat it as discarded/clear.
  • Preset correctness: verify preset factories (Palette.vga, cga, c64, gameboy, pico8, nes) and palette.applyHUD() produce expected hex values at known slots.
  • Effect determinism: for paletteCycle, paletteFade, paletteFadeRange, paletteFlash, and paletteSwap, assert both mid-effect and completion states under fixed timestep progression.
  • Dirty flag contract: assert set() / copyFrom() / effect updates mark palettes dirty and renderer upload paths clear the flag after upload.
  • Offset rendering behavior: add visual assertions that BT.drawSprite(..., paletteOffset) remaps the same indexed sprite data to different slot ranges without duplicating textures.
  • Layout-swap safety: after swapping to a different index layout, assert BT.spritesRefresh() re-indexes tracked sheets and catches missing-color cases.

WebGPU mock usage

For tests that need GPU objects, import from the mock factory:

import { ,  } from '../__test__/webgpu-mock';

const  = ();
const  = (256, 256);

Available mocks:

  • createMockGPUDevice() - returns a stub GPUDevice
  • createMockGPUTexture(width, height) - returns a stub GPUTexture
  • createMockGPUCanvasContext() - returns a stub GPUCanvasContext
  • createMockRenderPassEncoder() - returns a stub GPURenderPassEncoder
  • createMockPaletteBuffer() - returns a 4096-byte stub GPUBuffer for palette uniform tests
  • installMockNavigatorGPU() / uninstallMockNavigatorGPU() - install/remove global navigator.gpu

For software-renderer tests that need a Canvas 2D context, use the makeMock2DCanvas() helper defined in src/core/BTAPI.test.ts. It returns an HTMLCanvasElement stub whose getContext('2d') yields a minimal CanvasRenderingContext2D mock (with createImageData, putImageData, drawImage, and toBlob stubs).

src/__test__/setup.ts also installs a global OffscreenCanvas stub in Node.js (returns zero-filled pixel data from getImageData). This is needed by SpriteSheet.indexize() tests that run outside a browser environment.

Coverage

Minimum thresholds enforced in vitest.config.ts:

  • Statements: 80%
  • Branches: 80%
  • Functions: 80%
  • Lines: 80%

Run pnpm run test:unit:coverage to check. Coverage reports are generated in coverage/.

Visual Test Coverage

Visual test coverage uses Istanbul instrumentation via vite-plugin-istanbul. It is separate from unit test coverage (different provider: Istanbul vs. V8) and generates reports in coverage-visual/.

pnpm run test:visual:coverage  # Runs visual tests with instrumented code, then generates lcov report

Coverage is collected via a custom Playwright fixture (tests/visual/coverage-fixture.ts) that captures window.__coverage__ from each page after tests complete. The VISUAL_COVERAGE=1 env var activates instrumentation.

Visual test workflow

  1. Create a fixture HTML page in tests/visual/fixtures/
  2. Write a Playwright spec in tests/visual/
  3. Run pnpm run test:visual to generate baseline screenshots
  4. Screenshots are committed to tests/visual/__snapshots__/
  5. After intentional rendering changes, run pnpm run test:visual:update

IDE setup

Zed uses the Tasks system, and the repo root commits .zed/tasks.json with one task per package script worth running from the editor (blit386: test:unit, blit386: test:unit:watch, blit386: test:visual, blit386: bench, and the preflight / typecheck / build tasks for every package). Clone the repo and they appear in the task picker; add a one-off entry for a single file with "command": "pnpm", "args": ["exec", "vitest", "run", "$ZED_FILE"] and "cwd": "$ZED_WORKTREE_ROOT/packages/blit386".

CI integration

GitHub Actions runs unit tests and quality gates in CI. Visual regression is local-only today (see below).

ci.yml (CI workflow)

Triggers on push to main and on pull requests targeting main (opened, synchronize, reopened - labeling a PR does not retrigger the workflow).

The pipeline is path-filtered. A changes job runs dorny/paths-filter first and emits one boolean per package plus a shared flag for root configs, scripts/, and .github/; every package job is gated on its own filter or on shared. So an engine-only pull request does not rebuild the demos, and a website-only one touches neither. Two filters deliberately span packages: demos includes packages/blit386/** because demos consumes the engine as workspace:*, and website includes packages/blit386/docs/** because that is what sync:docs:check mirrors.

JobGated onWhat it runs
changesalwaysPath filters; emits the per-package booleans every job below reads
quality-rootalwaysformat:check, docs:links, agents:check, sync:cursor-commands:check, sync:maintainer-agents:check, bump:check, and the root test:* Node suites for those scripts - repo-wide, so they run once rather than per package
security-auditalwayspnpm run security:audit, pnpm run security:audit:prod (dependency policy gate), workspace-wide
quality-engineenginelint, typecheck, spellcheck, knip, sync:doc-banners:check, api:since:check, api:history:check, api:deprecations:check
build-engineenginepnpm run build, declaration tooling check, uploads dist/ artifact
bundle-sizeengineDownloads the build-engine artifact, gzipped ESM size gate (90 KB)
test-engineenginetest:unit:coverage with Codecov upload, test:api-history, test:security-preflight
quality-demosdemoslint, spellcheck, knip, check:demo-registry, check:demo-comment-links, test
build-demosdemosBuilds the engine first (workspace dependency), then the demos bundle
quality-websitewebsitetypecheck, spellcheck, test, knip, check:well-known-schemas, and sync:docs:check (no lint: the package's lint is biome check ., which quality-root's repo-wide format:check already covers)
build-websitewebsitepnpm run build (sets CLOUDFLARE=1, so Twoslash runs), then check:deploy-size and check:well-known-urls
quality-kit / build-test-kitkittypecheck, knip; then build and node --test
quality-scaffolder / build-test-scaffolderscaffoldertypecheck, knip; then build kit, build scaffolder, run its tests and test:bump-lockstep

Jobs needing full git history ask the shared setup action for it: quality-engine and test-engine take fetch-depth: 0 plus fetch-tags: true (release dates come from tags, and the API-history generator walks history with git log --follow), and quality-website takes fetch-depth: 0 for the same --follow reason in the docs mirror.

sync:docs:check runs here and, since BT-438, as the final gate of packages/website's own preflight, so a local run is no longer laxer than CI. It runs last in both places for the same reason: it regenerates content/docs in the working tree, so anything reading that tree afterwards would be reading regenerated rather than committed content. It could not run at all before the monorepo merge, because the docs-site repository had no engine checkout to sync against - so a stale published mirror was only ever caught by hand.

The six workflow files are ci.yml, pr-checks.yml, dco.yml, deploy.yml, mcp-governance-audit.yml (the monthly MCP governance scan from the security runbook), and kit-docs-drift.yml (advisory drift between _api-history.json and the kit docs, reported to Linear, never a status check); CodeQL runs from GitHub's default setup rather than a checked-in workflow. On pull requests, pr-checks.yml complements ci.yml with Conventional Commits validation only; every other gate - knip, bundle size, doc links, agent-config drift - runs inside ci.yml.

pr-checks.yml (PR Checks workflow)

Runs only on pull requests to main. Its single job validates commit messages; the quality, knip, bundle-size, and doc-link gates all live in ci.yml. It does not run unit or visual tests.

JobWhat it runs
commitlintConventional Commits validation for PR commits

Visual regression (not in CI)

Visual tests run locally only

pnpm run test:visual requires Chrome with WebGPU and is not executed in GitHub Actions. That is intentional: Actions minutes are a real cost for a solo project, so visual regression stays a manual gate (the decision recorded on BT-319). Run it locally before merging renderer, palette, or post-process changes; use pnpm run test:visual:update when baselines change intentionally. pnpm run preflight does not include visual tests.

Unit-level coverage of src/render/effects (enforced by a per-directory Vitest threshold at 80%) is the primary automated protection for the post-process path. Visual snapshots remain the pixel-correctness check, but they are not what CI gates on.

Known quirks

  • // @vitest-environment happy-dom - required at the top of test files that touch DOM APIs without GPU mocks.
  • AssetLoader - tests stub Image with vi; do not rely on happy-dom data-URI onload behavior.
  • Vector2i -0 vs. 0 - use result.x + 0 in assertions when sign is meaningless.
  • docs:links scope - scripts/check-markdown-links.mjs enumerates git-tracked *.md / *.mdx via git ls-files (honors .gitignore), not only docs/ and README.

See also

Last updated on October 18, 2026

On this page