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(), optionalconfigure(), and thetestState()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 behindBT.drawSpriteparams,BT.drawTile, andBT.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(), andloadingCount(Node + vi stubs forImage) - SpriteSheet - UV calculation, lazy texture creation, indexization,
loadIndexed()convenience flow,getIndexedPixels()defensive-copy semantics, andstatus/progressload 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=softwareWebGPU-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-actiongating for scroll capture and palette-band override, a press carried across a frame that ran noupdate(), and an emptypointerTypecounting as the mouse (happy-dom) - KeyboardInput and keyboardRuntimeMaps -
isAnyKey*edges and repeat, the'versus'/'single'layouts, andBT.inputMapReset()(happy-dom; the runtime map tables in Node) - GamepadInput - digital triggers (
BTN_L2/BTN_R2at the 50% threshold) andisAnyButton*(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
renderAtseek 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
WebGPURendererafter WebGPU init succeeds; includes software-mode init,?backend=softwareURL override,BT.requestedBackendvs.BT.activeBackendafter WebGPU fallback,captureFrame()in software mode,BT.loadingAssetsCount, overlay render path,BT.isReadyacross init and re-init,BT.testState(), andBT.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,layoutPlangolden Y positions for 320×240 (including custom rows, palette grid variable bottom band, and timing chart scaffold cases),PaletteView.computeGridwidth/size matrix,PaletteInteractionhit-test/scroll/tooltip helpers,RenderPaletteUsagemask helpers (markIndexUsed,collectUsedIndices), toggle hit-testing, andupdateAndRenderintegration (Node) - FrameCapture - GPU readback, PNG conversion (Node + GPU mocks + browser stubs)
- consumer-doc-imports -
src/docs/consumer-doc-imports.test.tsguards README/docs import paths againstBLIT386.tsexports
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, andsprites-nineslicefixtures for flips and quarter turns, scale andRect2istretch, andBT.drawNineSlice - Render-at - no snapshots: under a manual
requestAnimationFrameclock, the page that played to a tick and a fresh page that seeks to it withBT.renderAtmust 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 forscripts/check-declaration-tooling.mjs(drift patterns, requiredBTgetters in rolled-up.d.ts, and alignment log parsing). Included inpnpm run preflight.- CI - after
pnpm run build,node scripts/check-declaration-tooling.mjs build.logruns in the.github/workflows/ci.ymlbuild-libraryandbundle-sizejobs 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.jsonTest file location
Tests are colocated next to their source files:
src/utils/Vector2i.ts - Source
src/utils/Vector2i.test.ts - TestVisual 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-byteWriting a new test
import { , , } from 'vitest';
import { } from './MyClass';
('MyClass', () => {
('myMethod', () => {
('should do something', () => {
const = new ();
(.()).();
});
});
});Conventions:
- Use
describe/it(nottest) - 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
0remains transparent and draw paths treat it as discarded/clear. - Preset correctness: verify preset factories (
Palette.vga,cga,c64,gameboy,pico8,nes) andpalette.applyHUD()produce expected hex values at known slots. - Effect determinism: for
paletteCycle,paletteFade,paletteFadeRange,paletteFlash, andpaletteSwap, 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 GPUDevicecreateMockGPUTexture(width, height)- returns a stub GPUTexturecreateMockGPUCanvasContext()- returns a stub GPUCanvasContextcreateMockRenderPassEncoder()- returns a stub GPURenderPassEncodercreateMockPaletteBuffer()- returns a 4096-byte stub GPUBuffer for palette uniform testsinstallMockNavigatorGPU()/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 reportCoverage 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
- Create a fixture HTML page in
tests/visual/fixtures/ - Write a Playwright spec in
tests/visual/ - Run
pnpm run test:visualto generate baseline screenshots - Screenshots are committed to
tests/visual/__snapshots__/ - 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.
| Job | Gated on | What it runs |
|---|---|---|
changes | always | Path filters; emits the per-package booleans every job below reads |
quality-root | always | format: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-audit | always | pnpm run security:audit, pnpm run security:audit:prod (dependency policy gate), workspace-wide |
quality-engine | engine | lint, typecheck, spellcheck, knip, sync:doc-banners:check, api:since:check, api:history:check, api:deprecations:check |
build-engine | engine | pnpm run build, declaration tooling check, uploads dist/ artifact |
bundle-size | engine | Downloads the build-engine artifact, gzipped ESM size gate (90 KB) |
test-engine | engine | test:unit:coverage with Codecov upload, test:api-history, test:security-preflight |
quality-demos | demos | lint, spellcheck, knip, check:demo-registry, check:demo-comment-links, test |
build-demos | demos | Builds the engine first (workspace dependency), then the demos bundle |
quality-website | website | typecheck, 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-website | website | pnpm run build (sets CLOUDFLARE=1, so Twoslash runs), then check:deploy-size and check:well-known-urls |
quality-kit / build-test-kit | kit | typecheck, knip; then build and node --test |
quality-scaffolder / build-test-scaffolder | scaffolder | typecheck, 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.
| Job | What it runs |
|---|---|
commitlint | Conventional 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
Imagewithvi; do not rely on happy-dom data-URIonloadbehavior. Vector2i -0vs.0- useresult.x + 0in assertions when sign is meaningless.docs:linksscope -scripts/check-markdown-links.mjsenumerates git-tracked*.md/*.mdxviagit ls-files(honors.gitignore), not onlydocs/and README.
See also
API: Palette
Palette Guide
Palette Presets
API: Assets
API: Core
Hot Reload Guide
Overlay Guide
Post-Process Effects
Tooling
Performance Testing
Software Fallback Smoke Matrix
Last updated on October 18, 2026