Files
2026-05-27 23:29:53 +01:00

339 lines
20 KiB
Markdown

# test-vite — Capability Spec
**Package**: `@suaveplan/test-vite`
**Status**: SHIPPED (2026-05-25)
## 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`).