Files
suaveplan/openspec/archive/2026-05-25-enterprise-test-vite/specs/test-vite/spec.md
2026-05-27 23:29:53 +01:00

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'] and output.preserveModules === true and the dts plugin appears in plugins

Scenario: external baseline is merged with caller-supplied entries

  • WHEN the caller passes external: ['zod', /^@opentelemetry\//]
  • THEN the returned build.rollupOptions.external contains 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 TestViteError is thrown with code === 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.assetFileNames matching assets/[name]-[hash][extname] AND envPrefix === ['APP_']

Scenario: defaults VITE_ envPrefix and es2022 target

  • WHEN createViteAppConfig({ root: '.' }) is called with no envPrefix
  • THEN envPrefix === ['VITE_'] AND build.target === 'es2022'

Scenario: invalid options raise INVALID_OPTIONS

  • WHEN createViteAppConfig({ root: 123 as unknown as string }) is called
  • THEN a TestViteError is thrown with code === 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' AND test.coverage.provider === 'istanbul' AND all four test.coverage.thresholds equal 100

Scenario: accepts environment + setupFiles overrides

  • WHEN createVitestConfig({ environment: 'happy-dom', setupFiles: ['./setup.ts'] }) is called
  • THEN test.environment === 'happy-dom' AND test.setupFiles contains './setup.ts'

Scenario: invalid options raise INVALID_OPTIONS

  • WHEN createVitestConfig({ environment: 'invalid' as unknown as 'node' }) is called
  • THEN a TestViteError is thrown with code === 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' AND test.browser.instances?.[0]?.browser === 'chromium' AND test.browser.headless === true AND test.fileParallelism === false

Scenario: accepts multi-browser matrix

  • WHEN createVitestBrowserConfig({ browsers: ['chromium', 'firefox', 'webkit'] }) is called
  • THEN test.browser.instances lists all three browsers in the supplied order

Scenario: invalid options raise INVALID_OPTIONS

  • WHEN createVitestBrowserConfig({ browsers: [] }) is called
  • THEN a TestViteError is thrown with code === 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, AND target.crypto are defined

Scenario: respects existing real globals

  • WHEN target.matchMedia is already set to a sentinel function
  • AND setupBrowserEnvironment({ target }) is called
  • THEN target.matchMedia is 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.localStorage is 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 exactly foo and bar
  • THEN the result is { ok: true, actual: ['bar', 'foo'] } (sorted)

Scenario: reports drift when exports diverge

  • WHEN the bundle exports foo and baz but the caller expects foo and bar
  • 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 TestViteError is thrown with code === TEST_VITE_CODES.TREE_SHAKE_VIOLATION AND details carrying { missing, unexpected }

Scenario: missing entry raises MISSING_ENTRY

  • WHEN readFile rejects with ENOENT
  • THEN a TestViteError is thrown with code === 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, throw TestViteError(TEST_VITE_CODES.SNAPSHOT_MISSING),
  • when (actual - snapshot) / snapshot > tolerance, throw TestViteError(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 TestViteError is thrown with code === TEST_VITE_CODES.SIZE_BUDGET_EXCEEDED

Scenario: writes snapshot under update mode

  • WHEN update: true AND the snapshot file is absent
  • THEN the call returns { updated: true, size } AND fs.writeFile was invoked with the new size

Scenario: throws SNAPSHOT_MISSING without update

  • WHEN update: false AND fs.readFile for the snapshot rejects with ENOENT
  • THEN a TestViteError is thrown with code === 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() returns 5000, then 5001, then 5002

Scenario: throws PORT_EXHAUSTED when range is full

  • WHEN all three ports in the range are held AND allocate() is called again
  • THEN a TestViteError is thrown with code === 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 TestViteError is thrown with code === 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 the expectEvents
  • 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' AND timeoutMs elapses
  • THEN a TestViteError is thrown with code === 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 when received has role or aria-label or aria-labelledby defined as a non-empty string.
  • toMatchDataURL(received, mimePattern) — passes when received is a data:<mime>;base64,… URL with mime matching the supplied regex.
  • toHaveValidCSSVars(received, names) — passes when every name in names is a key on received.style AND 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_OPTIONS AND details.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_total counter — incremented per factory invocation with attribute kind
  • test_vite_validator_runs_total counter — incremented per verifyTreeShake / snapshotBundleSize
  • test_vite_validator_violations_total counter — incremented per detected violation
  • test_vite_port_allocations_total counter — incremented per allocate()
  • test_vite_hmr_round_trip_ms histogram — recorded per withHMR resolution

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_total data point with attributes.kind === 'vitest'

Scenario: records validator runs and violations

  • WHEN verifyTreeShake is called with drift AND enforce: false
  • THEN both test_vite_validator_runs_total AND test_vite_validator_violations_total are incremented

Scenario: records port allocations and HMR round-trip durations

  • WHEN allocatePort() succeeds AND withHMR resolves
  • THEN test_vite_port_allocations_total is incremented AND test_vite_hmr_round_trip_ms records 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.json exports is read
  • THEN every feature module has a subpath entry with an import.types and import.default pair

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:check is executed at the package level
  • THEN every .ts (excluding exemptions) has a sibling .md and the gate exits zero

Verified by: bun run docs:check at the package level (script wired in package.json).