Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# Design — @suaveplan/test-vite
## Tier and dependency allowlist
Tier: **universal-ish-tooling** — runs in Node-build contexts (`vite build`) and Vitest test files. Not browser-runtime.
Dependency allowlist (production):
- `@suaveplan/error` (workspace) — `defineCodes` + `SuaveplanError` for `TestViteError`.
- `@suaveplan/types` (workspace) — `Clock`, `LoggerLike` injection contracts.
- `@suaveplan/opentelemetry` (workspace, transitive) — meter wiring via `@opentelemetry/api`.
- `@opentelemetry/api` (catalog:observability) — counters + histograms.
- `vite` (catalog:build) — `UserConfig` type + `defineConfig` re-imported by factories.
- `vitest` (catalog:testing) — `UserConfig` / `defineConfig` from `vitest/config`.
- `vite-plugin-dts` (catalog:build) — used inside `createViteLibraryConfig`.
- `zod` (catalog:forms) — boundary validation of every public option object.
- `tslib` (catalog:build) — helper runtime.
Dev-only: `@suaveplan/testing` for `./runner` wrapper, `@opentelemetry/sdk-metrics` for in-memory MeterProvider test setup, `@types/node`, `typescript`, `@vitest/coverage-istanbul`.
`vite` / `vitest` / `vite-plugin-dts` are production deps because callers inherit them via this package (matching the upstream convention from the enterprise plan §2).
## Folder layout
```
src/
index.ts # explicit named re-exports
vite-library-config/
vite-library-config.ts # createViteLibraryConfig(opts)
vite-library-config.md
vite-library-config.test.ts
vite-app-config/
vite-app-config.ts # createViteAppConfig(opts)
vite-app-config.md
vite-app-config.test.ts
vitest-config/
vitest-config.ts # createVitestConfig(opts)
vitest-config.md
vitest-config.test.ts
vitest-browser-config/
vitest-browser-config.ts # createVitestBrowserConfig(opts)
vitest-browser-config.md
vitest-browser-config.test.ts
browser-environment/
browser-environment.ts # setupBrowserEnvironment
browser-environment.md
browser-environment.test.ts
tree-shake-validator/
tree-shake-validator.ts # verifyTreeShake
tree-shake-validator.md
tree-shake-validator.test.ts
bundle-size-snapshot/
bundle-size-snapshot.ts # snapshotBundleSize
bundle-size-snapshot.md
bundle-size-snapshot.test.ts
port-allocator/
port-allocator.ts # allocate/releasePort
port-allocator.md
port-allocator.test.ts
hmr-tester/
hmr-tester.ts # withHMR
hmr-tester.md
hmr-tester.test.ts
matchers/
matchers.ts # createMatchers
matchers.md
matchers.test.ts
error-codes/
error-codes.ts # TEST_VITE_CODES, TestViteError
error-codes.md
error-codes.test.ts
telemetry/
telemetry.ts # OTel instruments
telemetry.md
telemetry.test.ts
types/
test-vite.types.ts # shared interfaces
tests/
setup/
polyfills.ts # in-memory MeterProvider + webcrypto
```
## Error-code plan
`defineCodes("TEST_VITE", [...])` with these codes:
- `INVALID_OPTIONS` — Zod parse failure on any factory option object.
- `MISSING_ENTRY` — bundle file required by `verifyTreeShake` / `snapshotBundleSize` cannot be read.
- `TREE_SHAKE_VIOLATION` — bundle exports a symbol not in `expectedSymbols`.
- `SIZE_BUDGET_EXCEEDED` — bundle size exceeds snapshot + tolerance.
- `SNAPSHOT_MISSING` — snapshot file not found and `update: false`.
- `PORT_EXHAUSTED` — no port in the configured range is available.
- `PORT_NOT_HELD` — release of an unheld port.
- `HMR_TIMEOUT` — HMR round-trip exceeded timeout.
8 codes — every one is exercised by a test (see spec deltas).
## OTel instrument plan
Meter name: `@suaveplan/test-vite`. Instruments:
- `test_vite_config_built_total` (counter) — one per factory invocation; attribute `kind` = `vite_library`/`vite_app`/`vitest`/`vitest_browser`.
- `test_vite_validator_runs_total` (counter) — one per `verifyTreeShake` or `snapshotBundleSize` call.
- `test_vite_validator_violations_total` (counter) — one per detected violation.
- `test_vite_port_allocations_total` (counter) — one per successful `allocatePort`.
- `test_vite_hmr_round_trip_ms` (histogram) — recorded on every `withHMR` resolution.
All wired into a single `telemetry.ts` module with a real MeterProvider asserted in `telemetry.test.ts`.
## Boundary validation
Every public factory accepts an options object validated by a Zod schema. Invalid input → `TestViteError(TEST_VITE_CODES.INVALID_OPTIONS, …)` carrying the Zod issue list on `details`.
## Test-runner discipline
All `*.test.ts` files import from `@suaveplan/testing/runner` (rule 11). No `vitest` or `bun:test` direct imports.
## File-size budget
Every implementation file stays under 300 lines hard / 250 soft. Where logic is dense (`tree-shake-validator`, `bundle-size-snapshot`), shared helpers are extracted into private module-local functions; if necessary they spill into a sibling `<feature>.helpers.ts` (still inside the feature folder, with its own `.md`).

View File

@@ -0,0 +1,63 @@
# Proposal — Migrate @suaveplan/test-vite to genesis
- **Design**: ./design.md
- **Spec delta**: ./specs/test-vite/spec.md
## Why
Upstream `@suaveplan/test-vite` (`/mnt/s/web/suaveplan/packages/browser/test-vite/`) ships as a one-symbol stub (`export const test = "hi"`) despite extensive README copy describing planned factories (`createViteLibraryConfig`, `createVitestConfig`, `setupBrowserEnvironment`, tree-shake validator, bundle-size snapshot, custom matchers). The genesis `enterprise/test-vite.md` audit calls this "the kind of scaffolding CLAUDE.md explicitly forbids" and lays out the full enterprise scope. Every genesis browser-tier package currently rewrites a near-identical `vite.config.ts`/`vitest.config.ts` pair by hand; this package owns those factories and the WSL2 safety defaults documented in `CLAUDE.md` ("Vitest / WSL2").
## What
Land `@suaveplan/test-vite@0.1.0` at `packages/devtools/tooling/test-vite/` with these feature modules (one folder per feature, sibling `.md` per implementation file):
- `vite-library-config/` — `createViteLibraryConfig(opts)` factory producing a `UserConfig` for internal library packages: `vite-plugin-dts`, ESM-first output, `preserveModules: true` tree-shaking, `external` auto-extended with `@suaveplan/*` + `/^node:/` + `tslib`.
- `vite-app-config/` — `createViteAppConfig(opts)` factory for consumer apps: hashed asset names, env-prefix declaration, optional framework plugin slot.
- `vitest-config/` — `createVitestConfig(opts)` factory: `pool: 'forks'`, Istanbul provider, `setupFiles`, 100/100/100/100 thresholds, `fileParallelism` switch.
- `vitest-browser-config/` — `createVitestBrowserConfig(opts)` factory: Playwright provider, `fileParallelism: false`, multi-browser matrix.
- `browser-environment/` — `setupBrowserEnvironment(opts)` runtime helper installing happy-dom / jsdom shims (`localStorage`, `matchMedia`, `ResizeObserver`, `IntersectionObserver`, `crypto.webcrypto`).
- `tree-shake-validator/` — `verifyTreeShake({ entry, expectedSymbols, bundle })` that statically parses a bundle (Rollup-emitted text) and asserts the emitted exports match the allow-list.
- `bundle-size-snapshot/` — `snapshotBundleSize({ entry, snapshot, fs })` that compares a bundle byte-size against a snapshot file and enforces a configurable budget tolerance.
- `port-allocator/` — `allocatePort` / `releasePort` backed by an injectable lock-file store; fixes WSL2 parallel-Storybook/Playwright collisions.
- `hmr-tester/` — `withHMR({ server, edit, expectEvents })` round-trips a Vite HMR cycle via an injectable server adapter (no real network).
- `matchers/` — `expect.extend` matchers: `toBeAccessibleNode`, `toMatchDataURL`, `toHaveValidCSSVars`. Universal (work in node + jsdom + happy-dom).
- `error-codes/` — `TEST_VITE_CODES` via `defineCodes("TEST_VITE", […])` + `TestViteError`.
- `telemetry/` — OTel counters + histograms for factory invocations and validator runs.
- `types/` — shared option/result types.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `export const test = "hi"` placeholder | ✅ | 🚫 | Removed — explicit goal of the enterprise plan. |
| `createViteLibraryConfig` | 📝 (README only) | ✅ | New implementation per enterprise plan §4. |
| `createViteAppConfig` | 📝 (README only) | ✅ | New implementation. |
| `createVitestConfig` (WSL2-safe) | 📝 (README only) | ✅ | New; defaults: `pool: 'forks'`, Istanbul, `setupFiles`. |
| `createVitestBrowserConfig` | 📝 (README only) | ✅ | New; Playwright provider. |
| `setupBrowserEnvironment` | 📝 (README only) | ✅ | New. |
| Tree-shake validator | 📝 | ✅ | New. |
| Bundle-size snapshot | 📝 | ✅ | New. |
| Custom matchers | 📝 | ✅ | New. |
| Port allocator | 📝 | ✅ | New. |
| HMR tester | 📝 | ✅ | New. |
| Error-code registry | 🚫 | ⬆️ | Enhanced — required by genesis. |
| OTel self-telemetry | 🚫 | ⬆️ | Enhanced — required by genesis. |
| Subpath exports | 🚫 | ⬆️ | Enhanced — every feature is its own subpath. |
The "Upstream 📝" rows track features that existed only as README copy upstream; they are net-new implementations on genesis.
## Scope split vs `@suaveplan/testing`
`@suaveplan/testing/config` already owns the *generic* `createTestConfig({ environment })` used across packages. This package, `@suaveplan/test-vite`, owns the **Vite-specific build factories and Vitest configurations** that surface Vite/Rollup-specific options (`vite-plugin-dts`, `preserveModules`, Playwright browser-mode, HMR round-trip). They are complementary: `@suaveplan/testing` is runner-agnostic; `@suaveplan/test-vite` is Vite-aware.
## Impact
### Affected dependents
Net-new package — no current consumers. Downstream browser-tier packages MAY opt into the factories incrementally; the enterprise plan flags `react-tour` as the first migration target.
## Risks
- **Vite/Vitest type surface drift.** The factories return `UserConfig` from the pinned Vite / Vitest catalog versions. Mitigated by importing config types directly from those packages.
- **WSL2 fork-pool requirement.** `createVitestConfig` defaults MUST stay `pool: 'forks'`; deviation will silently re-break `vmThreads` consumers. Pinned by explicit test + spec requirement.
- **Bundle-size snapshot file IO.** The validator accepts an injectable `fs` adapter so tests don't touch the real filesystem; production callers pass `node:fs/promises`.

View File

@@ -0,0 +1,338 @@
# 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`).

View File

@@ -0,0 +1,25 @@
# Tasks — Migrate @suaveplan/test-vite
1. [x] Author proposal.md, design.md, spec delta.
2. [x] Scaffold package at `packages/devtools/tooling/test-vite/` with `package.json` (v0.1.0, subpath exports), `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`, `tests/setup/polyfills.ts`.
3. [x] Implement `src/error-codes/error-codes.ts` with `defineCodes("TEST_VITE", [...])` and `TestViteError`.
4. [x] Implement `src/telemetry/telemetry.ts` with the 4 counters + 1 histogram and helpers.
5. [x] Implement `src/types/test-vite.types.ts` with the shared interfaces.
6. [x] Implement `src/vite-library-config/vite-library-config.ts`.
7. [x] Implement `src/vite-app-config/vite-app-config.ts`.
8. [x] Implement `src/vitest-config/vitest-config.ts`.
9. [x] Implement `src/vitest-browser-config/vitest-browser-config.ts`.
10. [x] Implement `src/browser-environment/browser-environment.ts`.
11. [x] Implement `src/tree-shake-validator/tree-shake-validator.ts`.
12. [x] Implement `src/bundle-size-snapshot/bundle-size-snapshot.ts`.
13. [x] Implement `src/port-allocator/port-allocator.ts`.
14. [x] Implement `src/hmr-tester/hmr-tester.ts`.
15. [x] Implement `src/matchers/matchers.ts`.
16. [x] Write sibling `.md` doc per implementation file with the six required sections.
17. [x] Write `.test.ts` files exercising every spec scenario; import test-runner primitives from `@suaveplan/testing/runner`.
18. [x] Run `bunx turbo lint typecheck test build --filter=@suaveplan/test-vite` — green.
19. [x] Run `bunx turbo test:coverage --filter=@suaveplan/test-vite` — 100/100/100/100.
20. [x] Refresh MANIFEST.md via `bun scripts/folder-reorg/generate-manifest.ts`.
21. [x] Flip STATUS.md row from `–` to `✅` with one-line README description.
22. [x] Fold `specs/test-vite/spec.md` into `openspec/specs/test-vite/spec.md`.
23. [x] Archive `openspec/changes/enterprise-test-vite/` → `openspec/archive/2026-05-25-enterprise-test-vite/`.