20 KiB
test-vite — Spec Delta
Package: @suaveplan/test-vite
Status: PROPOSED
ADDED Requirements
Requirement: createViteLibraryConfig produces ESM-first library config with dts and externalised workspace deps
createViteLibraryConfig(opts) MUST return a Vite UserConfig with build.lib.formats = ['es'], build.rollupOptions.output.preserveModules = true, preserveModulesRoot = 'src', and build.rollupOptions.external extended to include every regex / string supplied by the caller plus the built-in baseline (/^@suaveplan\//, /^node:/, 'tslib'). The vite-plugin-dts plugin MUST be added with caller-supplied entryRoot / tsconfigPath. Invalid options MUST raise TestViteError(TEST_VITE_CODES.INVALID_OPTIONS).
Scenario: factory returns ESM-first config with preserveModules
- WHEN
createViteLibraryConfig({ root, entries: { index: 'src/index.ts' }, tsconfigPath: './tsconfig.lib.json' })is called - THEN the returned config has
build.lib.formats === ['es']andoutput.preserveModules === trueand the dts plugin appears inplugins
Scenario: external baseline is merged with caller-supplied entries
- WHEN the caller passes
external: ['zod', /^@opentelemetry\//] - THEN the returned
build.rollupOptions.externalcontains both'zod'and/^@opentelemetry\//AND the baseline/^@suaveplan\//AND/^node:/AND'tslib'
Scenario: invalid options raise INVALID_OPTIONS
- WHEN
createViteLibraryConfig({ entries: {} })is called (empty entries object) - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.INVALID_OPTIONS
Verified by: src/vite-library-config/vite-library-config.test.ts::"returns ESM-first config with preserveModules" AND src/vite-library-config/vite-library-config.test.ts::"merges caller external with baseline" AND src/vite-library-config/vite-library-config.test.ts::"throws INVALID_OPTIONS on empty entries"
Requirement: createViteAppConfig produces app-mode config with hashed assets
createViteAppConfig(opts) MUST return a Vite UserConfig configured for a consumer app: hashed asset filenames (assets/[name]-[hash][extname]), build.target defaulting to 'es2022', envPrefix accepted from opts and defaulted to ['VITE_'], plugin list defaulting to caller-supplied. Invalid options MUST raise TestViteError(TEST_VITE_CODES.INVALID_OPTIONS).
Scenario: returns app config with hashed assets
- WHEN
createViteAppConfig({ root: '.', envPrefix: ['APP_'] })is called - THEN the result includes
build.rollupOptions.output.assetFileNamesmatchingassets/[name]-[hash][extname]ANDenvPrefix === ['APP_']
Scenario: defaults VITE_ envPrefix and es2022 target
- WHEN
createViteAppConfig({ root: '.' })is called with no envPrefix - THEN
envPrefix === ['VITE_']ANDbuild.target === 'es2022'
Scenario: invalid options raise INVALID_OPTIONS
- WHEN
createViteAppConfig({ root: 123 as unknown as string })is called - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.INVALID_OPTIONS
Verified by: src/vite-app-config/vite-app-config.test.ts::"returns app config with hashed asset filenames" AND src/vite-app-config/vite-app-config.test.ts::"defaults envPrefix to VITE_ and target to es2022" AND src/vite-app-config/vite-app-config.test.ts::"throws INVALID_OPTIONS on non-string root"
Requirement: createVitestConfig defaults to WSL2-safe fork pool with Istanbul coverage
createVitestConfig(opts) MUST return a Vitest UserConfig whose test section has pool: 'forks', coverage.provider: 'istanbul', coverage.thresholds = { statements: 100, branches: 100, functions: 100, lines: 100 }, setupFiles accepted from opts (default []), environment accepted from opts (default 'node'), and fileParallelism overridable (default true). Invalid options MUST raise TestViteError(TEST_VITE_CODES.INVALID_OPTIONS).
Scenario: defaults forks pool and istanbul
- WHEN
createVitestConfig({})is called - THEN
test.pool === 'forks'ANDtest.coverage.provider === 'istanbul'AND all fourtest.coverage.thresholdsequal 100
Scenario: accepts environment + setupFiles overrides
- WHEN
createVitestConfig({ environment: 'happy-dom', setupFiles: ['./setup.ts'] })is called - THEN
test.environment === 'happy-dom'ANDtest.setupFilescontains'./setup.ts'
Scenario: invalid options raise INVALID_OPTIONS
- WHEN
createVitestConfig({ environment: 'invalid' as unknown as 'node' })is called - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.INVALID_OPTIONS
Verified by: src/vitest-config/vitest-config.test.ts::"defaults to forks pool and istanbul provider" AND src/vitest-config/vitest-config.test.ts::"accepts environment and setupFiles overrides" AND src/vitest-config/vitest-config.test.ts::"throws INVALID_OPTIONS on bad environment"
Requirement: createVitestBrowserConfig opts into Playwright provider with fileParallelism off
createVitestBrowserConfig(opts) MUST return a Vitest UserConfig whose test.browser enables Playwright with caller-supplied browsers (defaulting to ['chromium']), headless: true by default, enabled: true, AND test.fileParallelism = false. Invalid options MUST raise TestViteError(TEST_VITE_CODES.INVALID_OPTIONS).
Scenario: defaults to chromium + headless + fileParallelism off
- WHEN
createVitestBrowserConfig({})is called - THEN
test.browser.provider.name === 'playwright'ANDtest.browser.instances?.[0]?.browser === 'chromium'ANDtest.browser.headless === trueANDtest.fileParallelism === false
Scenario: accepts multi-browser matrix
- WHEN
createVitestBrowserConfig({ browsers: ['chromium', 'firefox', 'webkit'] })is called - THEN
test.browser.instanceslists all three browsers in the supplied order
Scenario: invalid options raise INVALID_OPTIONS
- WHEN
createVitestBrowserConfig({ browsers: [] })is called - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.INVALID_OPTIONS
Verified by: src/vitest-browser-config/vitest-browser-config.test.ts::"defaults to chromium headless with fileParallelism off" AND src/vitest-browser-config/vitest-browser-config.test.ts::"accepts multi-browser matrix" AND src/vitest-browser-config/vitest-browser-config.test.ts::"throws INVALID_OPTIONS on empty browsers"
Requirement: setupBrowserEnvironment installs DOM shims idempotently
setupBrowserEnvironment(opts) MUST install (only when absent on the target globalThis) shims for localStorage, matchMedia, ResizeObserver, IntersectionObserver, and crypto. The function MUST be idempotent — a second call MUST NOT replace already-installed real implementations. It MUST accept an injectable target (default: globalThis) for testability. Disabling individual shims ({ mockLocalStorage: false } etc.) MUST be honoured.
Scenario: installs missing globals
- WHEN
setupBrowserEnvironment({ target })is called on a fresh target object - THEN
target.localStorage,target.matchMedia,target.ResizeObserver,target.IntersectionObserver, ANDtarget.cryptoare defined
Scenario: respects existing real globals
- WHEN
target.matchMediais already set to a sentinel function - AND
setupBrowserEnvironment({ target })is called - THEN
target.matchMediais still the sentinel function (not replaced)
Scenario: opt-out flags suppress individual shims
- WHEN
setupBrowserEnvironment({ target, mockLocalStorage: false })is called on a fresh target - THEN
target.localStorageis undefined while the other shims are installed
Verified by: src/browser-environment/browser-environment.test.ts::"installs missing DOM globals on a fresh target" AND src/browser-environment/browser-environment.test.ts::"respects pre-existing real implementations" AND src/browser-environment/browser-environment.test.ts::"opt-out flag suppresses individual shim"
Requirement: verifyTreeShake detects export drift in a built bundle
verifyTreeShake({ entry, expectedSymbols, readFile }) MUST asynchronously read the bundle text via the injectable readFile, parse its export { … } / export const|function|class statements, and return { ok: true, actual } when the actual export set equals expectedSymbols. Drift MUST yield { ok: false, missing, unexpected }. When enforce: true is passed and drift is detected, the function MUST throw TestViteError(TEST_VITE_CODES.TREE_SHAKE_VIOLATION) carrying the drift on details. A missing entry file MUST raise TestViteError(TEST_VITE_CODES.MISSING_ENTRY).
Scenario: reports ok when exports match
- WHEN
verifyTreeShake({ entry: 'dist/index.js', expectedSymbols: ['foo', 'bar'], readFile })reads a bundle exporting exactlyfooandbar - THEN the result is
{ ok: true, actual: ['bar', 'foo'] }(sorted)
Scenario: reports drift when exports diverge
- WHEN the bundle exports
fooandbazbut the caller expectsfooandbar - THEN the result is
{ ok: false, missing: ['bar'], unexpected: ['baz'] }
Scenario: enforce mode throws TREE_SHAKE_VIOLATION
- WHEN drift is detected AND
enforce: true - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.TREE_SHAKE_VIOLATIONANDdetailscarrying{ missing, unexpected }
Scenario: missing entry raises MISSING_ENTRY
- WHEN
readFilerejects with ENOENT - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.MISSING_ENTRY
Verified by: src/tree-shake-validator/tree-shake-validator.test.ts::"reports ok when exports match" AND src/tree-shake-validator/tree-shake-validator.test.ts::"reports drift when exports diverge" AND src/tree-shake-validator/tree-shake-validator.test.ts::"enforce mode throws TREE_SHAKE_VIOLATION" AND src/tree-shake-validator/tree-shake-validator.test.ts::"missing entry raises MISSING_ENTRY"
Requirement: snapshotBundleSize compares bundle bytes against a snapshot with tolerance
snapshotBundleSize({ entry, snapshot, tolerance, update, fs }) MUST read the bundle byte length via injectable fs.readFile, compare against the snapshot byte count, and:
- when
update: true, write the current size into the snapshot file and return{ updated: true, size }, - when the snapshot is missing and
update: false, throwTestViteError(TEST_VITE_CODES.SNAPSHOT_MISSING), - when
(actual - snapshot) / snapshot > tolerance, throwTestViteError(TEST_VITE_CODES.SIZE_BUDGET_EXCEEDED), - otherwise return
{ updated: false, size, delta }.
Default tolerance = 0.05 (5 %).
Scenario: returns delta within tolerance
- WHEN the bundle is 1050 bytes and the snapshot stores 1000 bytes with default tolerance
- THEN the call returns
{ updated: false, size: 1050, delta: 0.05 }
Scenario: throws SIZE_BUDGET_EXCEEDED past tolerance
- WHEN the bundle is 1100 bytes against a 1000-byte snapshot with
tolerance: 0.05 - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.SIZE_BUDGET_EXCEEDED
Scenario: writes snapshot under update mode
- WHEN
update: trueAND the snapshot file is absent - THEN the call returns
{ updated: true, size }ANDfs.writeFilewas invoked with the new size
Scenario: throws SNAPSHOT_MISSING without update
- WHEN
update: falseANDfs.readFilefor the snapshot rejects with ENOENT - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.SNAPSHOT_MISSING
Verified by: src/bundle-size-snapshot/bundle-size-snapshot.test.ts::"returns delta within tolerance" AND src/bundle-size-snapshot/bundle-size-snapshot.test.ts::"throws SIZE_BUDGET_EXCEEDED past tolerance" AND src/bundle-size-snapshot/bundle-size-snapshot.test.ts::"writes snapshot under update mode" AND src/bundle-size-snapshot/bundle-size-snapshot.test.ts::"throws SNAPSHOT_MISSING without update"
Requirement: PortAllocator hands out and recycles ports backed by injectable store
createPortAllocator({ range, store }) MUST return { allocate, release, snapshot }. allocate() MUST return the lowest unused port in range, mark it held in store, and throw TestViteError(TEST_VITE_CODES.PORT_EXHAUSTED) when all ports are held. release(port) MUST throw TestViteError(TEST_VITE_CODES.PORT_NOT_HELD) when releasing a port that isn't held; otherwise free the port for re-allocation. snapshot() MUST return the current set of held ports.
Scenario: allocates the lowest free port
- WHEN range is
[5000, 5002]and the store starts empty - THEN
allocate()returns5000, then5001, then5002
Scenario: throws PORT_EXHAUSTED when range is full
- WHEN all three ports in the range are held AND
allocate()is called again - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.PORT_EXHAUSTED
Scenario: release frees the port for reallocation
- WHEN port 5001 has been allocated and is released
- THEN the next
allocate()returns 5001
Scenario: releasing an unheld port throws PORT_NOT_HELD
- WHEN
release(5999)is called on a port that was never allocated - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.PORT_NOT_HELD
Verified by: src/port-allocator/port-allocator.test.ts::"allocates the lowest free port" AND src/port-allocator/port-allocator.test.ts::"throws PORT_EXHAUSTED when range is full" AND src/port-allocator/port-allocator.test.ts::"release frees the port for reallocation" AND src/port-allocator/port-allocator.test.ts::"releasing an unheld port throws PORT_NOT_HELD"
Requirement: withHMR round-trips an edit through an injectable HMR server
withHMR({ server, edit, expectEvents, timeoutMs }) MUST call server.send(edit), await events from server.onEvent until every entry of expectEvents has been observed (in order), and resolve with the captured events. When timeoutMs elapses before all events arrive, the call MUST throw TestViteError(TEST_VITE_CODES.HMR_TIMEOUT). Timing MUST come from an injectable Clock so the test can simulate elapsed time deterministically.
Scenario: resolves when all expected events fire
- WHEN the server emits
['vite:beforeUpdate', 'vite:afterUpdate']in order AND those are theexpectEvents - THEN the call resolves with the array of captured events in fire order
Scenario: throws HMR_TIMEOUT on missing events
- WHEN the server emits only
'vite:beforeUpdate'ANDtimeoutMselapses - THEN a
TestViteErroris thrown withcode === TEST_VITE_CODES.HMR_TIMEOUT
Verified by: src/hmr-tester/hmr-tester.test.ts::"resolves when all expected events fire" AND src/hmr-tester/hmr-tester.test.ts::"throws HMR_TIMEOUT on missing events"
Requirement: createMatchers exposes typed expect.extend matchers
createMatchers() MUST return an object usable with expect.extend. Matchers:
toBeAccessibleNode(received)— passes whenreceivedhasroleoraria-labeloraria-labelledbydefined as a non-empty string.toMatchDataURL(received, mimePattern)— passes whenreceivedis adata:<mime>;base64,…URL withmimematching the supplied regex.toHaveValidCSSVars(received, names)— passes when every name innamesis a key onreceived.styleAND its value is a non-empty string.
Each matcher MUST return { pass, message } per the Vitest matcher contract.
Scenario: toBeAccessibleNode passes when role is set
- WHEN a node
{ role: 'button' }is passed - THEN the matcher returns
{ pass: true }
Scenario: toMatchDataURL matches mime regex
- WHEN the input is
'data:image/png;base64,AAA='AND mimePattern is/image\// - THEN the matcher returns
{ pass: true }
Scenario: toHaveValidCSSVars validates every name
- WHEN
received.style = { '--brand': '#000', '--accent': '#fff' }AND names are['--brand', '--accent'] - THEN the matcher returns
{ pass: true }
Scenario: failing matchers report descriptive messages
- WHEN
toBeAccessibleNode({})is called on a node with no accessibility hooks - THEN the matcher returns
{ pass: false, message: <non-empty string> }
Verified by: src/matchers/matchers.test.ts::"toBeAccessibleNode passes when role is set" AND src/matchers/matchers.test.ts::"toMatchDataURL matches mime regex" AND src/matchers/matchers.test.ts::"toHaveValidCSSVars validates every name" AND src/matchers/matchers.test.ts::"failing matchers report descriptive messages"
Requirement: TEST_VITE_CODES registry via defineCodes
The package MUST publish a stable TEST_VITE_CODES registry built with defineCodes("TEST_VITE", [...]) covering INVALID_OPTIONS, MISSING_ENTRY, TREE_SHAKE_VIOLATION, SIZE_BUDGET_EXCEEDED, SNAPSHOT_MISSING, PORT_EXHAUSTED, PORT_NOT_HELD, and HMR_TIMEOUT. The accompanying TestViteError extends SuaveplanError<TestViteCode> class MUST carry the code on err.code and the structured details on err.details.
Scenario: every code is namespaced under TEST_VITE.
- WHEN the registry is iterated
- THEN every value matches
/^TEST_VITE\./
Scenario: TestViteError carries supplied code and details
- WHEN
new TestViteError(TEST_VITE_CODES.INVALID_OPTIONS, 'bad', { details: { foo: 1 } })is constructed - THEN the instance has
code === TEST_VITE_CODES.INVALID_OPTIONSANDdetails.foo === 1
Verified by: src/error-codes/error-codes.test.ts::"every code is namespaced under TEST_VITE." AND src/error-codes/error-codes.test.ts::"TestViteError carries supplied code and details"
Requirement: Telemetry instruments emit on public calls
The package MUST register an OTel meter at @suaveplan/test-vite exposing:
test_vite_config_built_totalcounter — incremented per factory invocation with attributekindtest_vite_validator_runs_totalcounter — incremented perverifyTreeShake/snapshotBundleSizetest_vite_validator_violations_totalcounter — incremented per detected violationtest_vite_port_allocations_totalcounter — incremented perallocate()test_vite_hmr_round_trip_mshistogram — recorded perwithHMRresolution
A real in-memory MeterProvider registered before module import MUST observe metric points after public calls.
Scenario: records config build on factory invocation
- WHEN
createVitestConfig({})is called - THEN flushing the test meter yields a
test_vite_config_built_totaldata point withattributes.kind === 'vitest'
Scenario: records validator runs and violations
- WHEN
verifyTreeShakeis called with drift ANDenforce: false - THEN both
test_vite_validator_runs_totalANDtest_vite_validator_violations_totalare incremented
Scenario: records port allocations and HMR round-trip durations
- WHEN
allocatePort()succeeds ANDwithHMRresolves - THEN
test_vite_port_allocations_totalis incremented ANDtest_vite_hmr_round_trip_msrecords a non-negative duration
Verified by: src/telemetry/telemetry.test.ts::"records config build on factory invocation" AND src/telemetry/telemetry.test.ts::"records validator runs and violations" AND src/telemetry/telemetry.test.ts::"records port allocations and HMR round-trip durations"
Requirement: Subpath exports for every feature module
package.json MUST publish subpath exports for every feature: ./vite-library-config, ./vite-app-config, ./vitest-config, ./vitest-browser-config, ./browser-environment, ./tree-shake-validator, ./bundle-size-snapshot, ./port-allocator, ./hmr-tester, ./matchers, ./error-codes, ./telemetry. Each entry MUST resolve to its built index.js + index.d.ts.
Scenario: package.json declares every feature subpath
- WHEN
package.jsonexportsis read - THEN every feature module has a subpath entry with an
import.typesandimport.defaultpair
Verified by: tests/exports.test.ts::"package.json publishes every feature subpath"
Requirement: Sibling .md per implementation .ts
Every non-barrel, non-test, non-.types.ts implementation file MUST have a sibling .md with the six required sections (Purpose, Features, Usage: Basic, Usage: Advanced, API Reference, Implementation Notes), ≥200 words excluding code.
Scenario: docs:check passes for every implementation file
- WHEN
bun run docs:checkis executed at the package level - THEN every
.ts(excluding exemptions) has a sibling.mdand the gate exits zero
Verified by: bun run docs:check at the package level (script wired in package.json).