dev-docs/RFCs/proposals/vitest-migration-rfc.md
This RFC proposes migrating deck.gl's test infrastructure from tape (assertion framework) + ocular-test (test runner from @vis.gl/dev-tools) to vitest (which serves as both runner and assertion library).
The migration aims to:
ocular-test (runner from @vis.gl/dev-tools)
├── Vite (dev server for browser tests)
├── BrowserTestDriver (@probe.gl/test-utils)
├── c8 (coverage)
└── tape (assertions via tape-promise/tape)
Entry points:
test/node.ts - Minimal smoke test (only imports-spec + core-layers.spec)test/browser.ts - Comprehensive - runs ALL tests (./modules + ./render + ./interaction)Important architectural note: The previous design intentionally ran all tests in the browser (source of truth for a WebGL library), with Node serving only as a smoke test.
Current test commands:
yarn test - runs ocular-testyarn test-fast - runs ocular-lint && ocular-test nodeyarn cover - runs ocular-test coverwindow.browserTestDriver_finish)Replace ocular-test and tape with vitest:
vitest (runner + assertions)
├── Vite (built-in - same foundation as ocular-test)
├── Playwright (browser mode - replaces BrowserTestDriver)
├── v8 coverage (built-in)
└── expect() assertions (replaces tape)
We adopt a hybrid approach using vitest workspaces:
This preserves the previous design philosophy where browser tests are comprehensive, while adding fast local iteration via Node.
File naming convention:
| Pattern | Description |
|---|---|
*.node.spec.ts | Node-only smoke tests (fast, no WebGL) |
*.spec.ts | Browser tests (WebGL, real DOM, etc.) |
Vitest workspace configuration:
// vitest.workspace.ts
export default defineWorkspace([
// Node project - simple smoke tests (*.node.spec.ts only)
{
test: {
name: 'node',
environment: 'node',
include: ['test/modules/**/*.node.spec.ts'],
setupFiles: ['./test/setup/vitest-node-setup.ts']
}
},
// Headless project - unit tests in headless browser (CI)
{
test: {
name: 'headless',
include: ['test/modules/**/*.spec.ts'],
exclude: ['test/modules/**/*.node.spec.ts'],
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
headless: true
}
}
},
// Browser project - full suite in headed browser (local dev)
{
test: {
name: 'browser',
include: ['test/modules/**/*.spec.ts'],
exclude: ['test/modules/**/*.node.spec.ts'],
browser: {
enabled: true,
name: 'chromium',
provider: 'playwright',
headless: false
}
}
}
]);
Vitest browser mode only supports Playwright or WebdriverIO as providers - Puppeteer is not an option. This is actually beneficial:
The existing Puppeteer usage (via @probe.gl/test-utils BrowserTestDriver) will be replaced with Playwright's native APIs.
Test file changes:
// Before (tape)
import test from 'tape-promise/tape';
test('color#parseColor', t => {
const result = parseColor([127, 128, 129]);
t.deepEqual(result, [127, 128, 129, 255], 'expected result');
t.end();
});
// After (vitest)
import {test, expect} from 'vitest';
test('color#parseColor', () => {
const result = parseColor([127, 128, 129]);
expect(result).toEqual([127, 128, 129, 255]);
});
Assertion mapping:
| tape | vitest |
|---|---|
t.ok(value) | expect(value).toBeTruthy() |
t.notOk(value) | expect(value).toBeFalsy() |
t.equal(a, b) / t.is(a, b) | expect(a).toBe(b) |
t.deepEqual(a, b) | expect(a).toEqual(b) |
t.throws(fn) | expect(fn).toThrow() |
t.end() | (not needed) |
| Old Command | New Command | Notes |
|---|---|---|
yarn test | yarn test | Now runs node + headless + render |
yarn test-fast | yarn test-fast | Same: lint + node smoke test |
yarn cover | yarn test-headless --coverage | Redundant script removed |
yarn test ci | yarn test-ci | Explicit CI command |
| (none) | yarn test-headless | New: browser unit tests only |
| (none) | yarn test-render | New: render/interaction tests only |
| (none) | yarn test-browser | New: headed browser + render |
| Command | Lint | Node | Headless | Coverage | Render |
|---|---|---|---|---|---|
test | ✓ | ✓ | ✓ | ||
test-fast | ✓ | ✓ | |||
test-headless | ✓ | ||||
test-render | ✓ | ||||
test-ci | ✓ | ✓ | ✓ | ✓ | |
test-browser | ✓ (headed) | ✓ |
Note: Coverage can be added to any vitest command with --coverage.
{
"scripts": {
"test": "vitest run --project node --project headless && npm run test-render",
"test-fast": "ocular-lint && vitest run --project node",
"test-headless": "vitest run --project headless",
"test-render": "ocular-test browser-headless",
"test-ci": "vitest run --project node --project headless --coverage && npm run test-render",
"test-browser": "vitest run --project browser && npm run test-render"
}
}
| Pattern | Environment | Use Case |
|---|---|---|
*.node.spec.ts | Node only | Fast smoke tests (imports, basic logic) |
*.spec.ts | Browser (headless/headed) | Full test suite with WebGL, DOM |
The @deck.gl/test-utils module uses spies for testing layer lifecycle methods. To make test-utils framework-agnostic and avoid module loading issues (vitest imports hanging in Node), we use an Injectable Spy API.
Users pass their own createSpy function instead of test-utils importing test frameworks directly:
// vitest user
import {testLayer} from '@deck.gl/test-utils';
import {vi} from 'vitest';
await testLayer({
createSpy: (obj, method) => vi.spyOn(obj, method),
Layer: ScatterplotLayer,
testCases: [...]
});
// tape/probe.gl user
import {testLayer} from '@deck.gl/test-utils';
import {makeSpy} from '@probe.gl/test-utils';
await testLayer({
createSpy: makeSpy,
Layer: ScatterplotLayer,
testCases: [...]
});
Importing vitest outside of a vitest test context causes "Vitest failed to access its internal state" errors and hangs. By making createSpy injectable, test-utils has zero test framework dependencies and works in any environment.
For existing tape users who don't pass createSpy, test-utils lazily imports @probe.gl/test-utils with a deprecation warning:
// Internal fallback (only used if createSpy not provided)
async function getDefaultSpyFactory() {
try {
const {makeSpy} = await import('@probe.gl/test-utils');
console.warn(
'[@deck.gl/test-utils] Implicit @probe.gl/test-utils usage is deprecated. ' +
'Pass createSpy option explicitly.'
);
return makeSpy;
} catch {
throw new Error('createSpy option required.');
}
}
@probe.gl/test-utils is a required peer dependency because the default entry point (@deck.gl/test-utils) has a static import from tape.ts. Only vitest is optional (for the ./vitest sub-entry):
"peerDependencies": {
"@probe.gl/test-utils": "^4.1.1",
"vitest": "^4.0.18"
},
"peerDependenciesMeta": {
"vitest": { "optional": true }
}
Note: Users who only use @deck.gl/test-utils/vitest don't technically need @probe.gl/test-utils, but it remains required for backward compatibility with existing 9.x users.
| User | createSpy provided? | Result |
|---|---|---|
| Vitest user | Yes (vi.spyOn) | Works, no warnings |
| Tape user (new) | Yes (makeSpy) | Works, no warnings |
| Tape user (existing) | No | Works with deprecation warning |
| Neither installed | No | Clear error message |
| Version | Behavior |
|---|---|
| 9.3.x | createSpy optional, defaults to probe.gl with warning |
| 10.0.0 | createSpy required, remove probe.gl fallback |
A smoke test verifies the probe.gl fallback path in CI:
DECK_TEST_UTILS_USE_PROBE_GL=1 yarn test-tape-compat
1.1 Install dependencies:
yarn add -D @vitest/browser @vitest/browser-playwright playwright
Node 18 Compatibility: Confirmed - Vitest 2.1.9 requires ^18.0.0 || >=20.0.0, Playwright requires >=18.
1.2 Update vitest.config.ts with workspace projects (see Multi-Environment Architecture above)
1.3 Create setup files:
test/setup/vitest-node-setup.ts - JSDOM polyfills (from current test/node.ts)test/setup/vitest-browser-setup.ts - Minimal (browser provides DOM)1.4 Add npm scripts for each environment
test/smoke/tape-compat.spec.ts)DECK_TEST_UTILS_USE_PROBE_GL environment variable for testing fallback patht.end() callsonError: t.notOk → onError: (err) => expect(err).toBeFalsy())The hybrid approach serves as a discovery mechanism:
yarn test-node and observe failures@luma.gl/*)typeof windowDecision point after discovery:
.browser.spec.tsOutcome: Nearly all tests (~95%+) require browser environment due to WebGL/luma.gl dependencies. We adopted a simplified approach:
*.node.spec.ts files (smoke tests)*.spec.ts filesNode smoke tests (2 files):
imports.node.spec.ts - Verifies module exportscore-layers.node.spec.ts - Basic layer instantiationExcluded tests (need fixes before inclusion):
path-tesselator.spec.ts - Was commented out in original suitepolygon-tesselation.spec.ts - Was commented out in original suitegeocoders.spec.ts - Never imported in original suiteCurrent state:
test/render/ with 150 golden imagestest/interaction/BrowserTestDriver (Puppeteer)SnapshotTestRunner uses window.browserTestDriver_captureAndDiffScreenInteractionTestRunner uses window.browserTestDriver_emulateInput5.1 Convert to vitest syntax:
import test from 'tape' with import {test, expect} from 'vitest'5.2 Update SnapshotTestRunner for Playwright:
browserTestDriver_captureAndDiffScreen with Playwright's page.screenshot()@vitest/browser's page context5.3 Update InteractionTestRunner for Playwright:
browserTestDriver_emulateInput with Playwright APIs:
page.mouse.move(), page.mouse.click(), page.keyboard.press()5.4 Add to browser project:
{
name: 'browser',
include: [
'test/modules/**/*.spec.ts',
'test/render/**/*.spec.ts', // Add render tests
'test/interaction/**/*.spec.ts' // Add interaction tests
]
}
Files to modify:
modules/test-utils/src/snapshot-test-runner.tsmodules/test-utils/src/interaction-test-runner.tstest/render/index.js → test/render/index.spec.tstest/interaction/index.js → test/interaction/index.spec.tsOutcome:
Custom vitest browser commands created in test/setup/browser-commands.ts:
captureAndDiffScreen - Takes screenshots via Playwright, compares with golden images using sharp + pixelmatchemulateInput - Emulates mouse/keyboard events via Playwright's Frame APIisHeadless - Returns browser headless mode statusTest runners migrated:
SnapshotTestRunner now uses commands.captureAndDiffScreen() instead of window.browserTestDriver_captureAndDiffScreen()InteractionTestRunner now uses commands.emulateInput() instead of window.browserTestDriver_emulateInput()TestRunner base class updated to remove probe.gl dependenciesNew test entry points created:
test/render/index.spec.ts - Vitest version of render teststest/interaction/index.spec.ts - Vitest version of interaction teststest/interaction/map-controller.spec.ts - MapController tests using expect()test/interaction/picking.spec.ts - Picking tests using expect()Interaction tests passing - All 10 MapController tests and 1 Picking test pass.
Render tests need additional work:
Type declarations added:
test/setup/vitest-browser-commands.d.ts - Extends @vitest/browser/context BrowserCommands interfaceDependencies added:
pixelmatch - Image comparisonpngjs - PNG parsing (not used directly, but required by some deps)sharp - Robust image processing (handles various PNG color types)tap-spec, tape-catch dependenciestest and test-browser entry points from .ocularrc.js (now using vitest)tape-compat, bench, bench-browser, and size entries in .ocularrc.jsyarn test-tape-compat to GitHub CI workflowtest/browser.ts, .nycrctest/node.ts (used by vitest node setup)Remaining ocular-test entry points that still need migration:
// .ocularrc.js
entry: {
'tape-compat': 'test/smoke/tape-compat.spec.ts', // Keep for backward compat testing
bench: 'test/bench/index.js', // TODO: Migrate to vitest bench
'bench-browser': 'test/bench/browser.html', // TODO: Migrate to vitest bench
size: 'test/size/import-nothing.js' // TODO: Migrate to vitest
}
7.1 Benchmark Migration:
Vitest has built-in benchmark support via vitest bench:
// Before (probe.gl/bench)
import {Bench} from '@probe.gl/bench';
const bench = new Bench()
.add('parseColor', () => parseColor([127, 128, 129]));
bench.run();
// After (vitest bench)
import {bench, describe} from 'vitest';
describe('color', () => {
bench('parseColor', () => {
parseColor([127, 128, 129]);
});
});
Commands:
{
"bench": "vitest bench",
"bench-browser": "vitest bench --browser"
}
7.2 Size Test Migration:
Size tests verify bundle sizes don't regress. Options:
vitest with custom reporter that measures bundle sizeesbuild --analyzesize-limit packageNote: This phase is lower priority as benchmarks and size tests are run manually, not in CI.
test/modules/@deck.gl/test-utils)yarn test-node - runs unit tests in Node (fast feedback)yarn test-browser - runs ALL tests in Chromium (unit + render + interaction)yarn test-headless - runs ALL tests headlessly (CI)| Risk | Mitigation |
|---|---|
| Browser tests may behave differently | Vitest browser mode uses Playwright, similar to current Puppeteer-based setup |
| Coverage format changes | Vitest v8 provider outputs lcov format, same as current setup |
| Breaking changes for external consumers of test-utils | Add vitest as peer dependency, document migration |
| Many tests fail in Node environment | Discovery phase allows fallback to browser-only approach (Option A) |
| Puppeteer → Playwright migration breaks snapshot comparison | Vitest requires Playwright; will need to regenerate golden images if pixel differences occur |
| CI takes longer (running tests twice) | Node tests are fast; browser failures are the blocking check |
Problem: Vitest caught 18 unhandled promise rejections during test runs:
TypeError: Failed to execute 'getProgramInfoLog' on 'WebGL2RenderingContext':
parameter 1 is not of type 'WebGLProgram'.
Root Cause: luma.gl compiles shaders asynchronously using KHR_parallel_shader_compile. When shader compilation has errors, the error reporting code runs asynchronously:
// In @luma.gl/webgl webgl-render-pipeline.js
const linkErrorLog = this.device.gl.getProgramInfoLog(this.handle);
this.device.reportError(new Error(`${errorType}: ${linkErrorLog}`), this)();
When test cleanup (layerManager.finalize()) destroys WebGL resources before this async error reporting completes, this.handle becomes invalid, causing getProgramInfoLog to throw.
Why Tape Didn't Report This: The tape/probe.gl test runner didn't catch unhandled promise rejections as aggressively as vitest does. The underlying race condition existed but was silently ignored.
Current Fix: Added cleanupAfterLayerTestsAsync() which yields to the event loop before destroying resources:
async function cleanupAfterLayerTestsAsync(resources: TestResources): Promise<Error | null> {
layerManager.setLayers([]);
// Wait for pending async operations (luma.gl shader error handling)
await new Promise(resolve => setTimeout(resolve, 0));
layerManager.finalize();
deckRenderer.finalize();
// ...
}
This is used by testLayerAsync. With vitest's isolate: false configuration, async tests sprinkled throughout the suite provide "sync points" that flush pending promises from preceding synchronous tests.
Limitation: This fix is somewhat fragile because it relies on:
isolate: false - All tests run sequentially in the same browser contextIf someone enables isolate: true or changes test ordering significantly, the unhandled rejections may reappear.
Follow-up Task: Make testLayer async to properly await cleanup:
| Approach | Effort | Impact |
|---|---|---|
Current (async cleanup in testLayerAsync only) | Done | Works but relies on test ordering |
Make testLayer return Promise<void> | ~39 files | Robust, explicit cleanup |
The robust fix requires:
testLayer to use cleanupAfterLayerTestsAsyncasync test functions and await testLayer()Example migration:
// Before
test('PolygonLayer', () => {
testLayer({Layer: PolygonLayer, testCases, onError: err => expect(err).toBeFalsy()});
});
// After
test('PolygonLayer', async () => {
await testLayer({Layer: PolygonLayer, testCases, onError: err => expect(err).toBeFalsy()});
});
This is a mechanical change suitable for a follow-up PR.
The vitest migration required excluding some tests that were working with tape. This section documents each test and the path to re-enabling it.
These tests were already commented out or never imported in the original tape suite:
| Test File | Reason |
|---|---|
carto/index.spec.ts | Never imported - tests global CartoLayerLibrary |
layers/path-tesselator.spec.ts | Commented out on master |
layers/polygon-tesselation.spec.ts | Commented out on master |
widgets/geocoders.spec.ts | No index.ts, never imported |
extensions/mask/mask.spec.ts | Commented out - luma.gl v9 uniforms API change |
extensions/mask/mask-pass.spec.ts | Commented out - luma.gl v9 uniforms API change |
layers/path-layer/path-layer-vertex.spec.ts | Commented out - Transform not exported from @luma.gl/engine |
extensions/collision-filter/collision-filter.spec.ts | Commented out on master |
These tests ran successfully with tape but fail with vitest:
| Test File | Category | Issue | Fix Effort |
|---|---|---|---|
core/lib/attribute/attribute.spec.ts | PRE_EXISTING | Source code bug: data-column.ts overwrites user stride/offset | Fix in source |
geo-layers/tile-3d-layer/tile-3d-layer.spec.ts | MEDIUM | Async loading race conditions, spy count mismatch | Mock network requests |
core/lib/layer-extension.spec.ts | MEDIUM | updateState called twice when swapping extensions (expected: 1) | Investigate lifecycle |
core/lib/pick-layers.spec.ts | MEDIUM | Picking spy assertions fire at unexpected times | Add render cycle waits |
geo-layers/terrain-layer.spec.ts | MEDIUM | Network requests + GPU operations timeout | Mock terrain loader |
carto/layers/h3-tile-layer.spec.ts | HARD | autoHighlight test times out (>30s) - H3 generation expensive | Profile, simplify data |
aggregation-layers/hexbin.spec.ts | HARD | GPU/WebGL state leakage with isolate: false | Isolate GPU contexts |
core/lib/deck-picker.spec.ts | HARD | Picking FBO persists across tests | Cleanup GPU resources |
core/controllers/controllers.spec.ts | MEDIUM | Timeline animation state leakage | Reset controller state |
interaction/map-controller.spec.ts | MEDIUM | Timing-sensitive in headless mode, synthetic events | Increase waits, skip some |
extensions/terrain/terrain-effect.spec.ts | HARD | Timeout >30s, heavy GPU state | Profile, simplify data |
core/passes/layers-pass.spec.ts | MEDIUM | glParameters.viewport state leakage | Reset GL state per test |
The following tests were fixed and re-enabled:
| Test File | Issue | Fix |
|---|---|---|
carto/layers/schema/carto-raster-tile.spec.ts | TileReader.compression global state leakage | Reset state at start/end of test |
carto/layers/schema/carto-raster-tile-loader.spec.ts | Dependency on above test | Fixed by above |
The browser project (headed mode for local development) currently excludes test/render/**/*.spec.ts. This was necessary because:
Current workaround: Render tests only run in the dedicated render project which has:
headless: true{width: 1024, height: 768} in instancesFuture fix: Add viewport configuration to the browser project to enable local headed render test debugging:
// In browser project config
browser: {
instances: [{
browser: 'chromium',
viewport: {width: 1024, height: 768} // Match render project
}]
}
This would allow developers to run render tests in headed mode for visual debugging.
GPU/WebGL State Leakage (5 tests): Tests using GPU compute (hexbin, deck-picker, terrain-effect) suffer from WebGL context state persisting across tests when isolate: false. This is a trade-off for performance.
Global State Mutations (2 tests): Tests that modify global/static state (like TileReader.compression) without cleanup cause flakiness. Fix: Reset state at start and end of each test.
Async Timing (4 tests): Tests with network loading or complex render cycles have timing issues. Fix: Mock network requests, add explicit waits, or increase timeouts.
Source Code Bugs (1 test): The attribute test reveals a real bug in data-column.ts that should be fixed independently.
describe/it blocks, or keep flat test() calls?@deck.gl/test-utils?Goal: Allow external consumers time to migrate while moving the ecosystem forward.
Note: @deck.gl/test-utils is published on npm with ~10k monthly downloads (~1% of core). While primarily intended for internal use, external consumers exist and deserve a migration path.
| Phase | Version | Timeline | Changes |
|---|---|---|---|
| Compatibility | 9.3.x | Next minor release | Add vitest as peer dependency alongside @probe.gl/test-utils. Both tape and vitest patterns work. |
| Deprecation Warning | 9.4.x | +1 minor release | Console warnings for tape-based patterns (makeSpy, assert: t.ok). Documentation updated with vitest examples. |
| Removal | 10.0.0 | Next major release | Remove tape/probe.gl support. vi.spyOn replaces makeSpy. Callbacks use vitest expect(). |
Migration guide for external consumers:
// Before (tape)
import {testLayer} from '@deck.gl/test-utils';
import test from 'tape';
testLayer({assert: test.ok, onError: test.fail});
// After (vitest)
import {testLayer} from '@deck.gl/test-utils';
import {expect} from 'vitest';
testLayer({
assert: (condition, message) => expect(condition, message).toBeTruthy(),
onError: (error) => { throw error; }
});