Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
import {
type ChildProcess,
spawn as nodeSpawn,
type SpawnOptions,
} from "node:child_process";
import { fileURLToPath } from "node:url";
import { recordDuration, recordExecution } from "../telemetry/telemetry.js";
import type {
ExecutionContext,
SandboxEngine,
SandboxEngineConfig,
SandboxModule,
SandboxOptions,
SandboxResult,
} from "../types/sandbox.types.js";
/** Process spawner seam. Defaults to `node:child_process.spawn`. */
export type ProcessSpawner = (
command: string,
args: readonly string[],
options: SpawnOptions
) => ChildProcess;
/** Spawn-engine config. */
export interface SpawnEngineOptions extends SandboxEngineConfig {
/** Path to the runner script. */
readonly runnerPath?: string;
/** Path to the interpreter binary. Defaults to `process.execPath`. */
readonly execPath?: string;
/** Spawner seam — tests can inject a custom factory. */
readonly spawner?: ProcessSpawner;
/** Source environment for the child. Defaults to `process.env`. */
readonly env?: Readonly<NodeJS.ProcessEnv>;
}
const SENSITIVE_PATTERNS = [
"SECRET",
"KEY",
"TOKEN",
"PASSWORD",
"CREDENTIAL",
"AUTH",
] as const;
const STRIPPED_KEYS = new Set(["HOME", "USER", "SHELL", "LOGNAME"]);
/**
* Strip sensitive + identity-revealing env keys from the source env. Returns
* a fresh object containing only the keys that survive the filter.
*/
export function sanitiseEnv(
source: Readonly<NodeJS.ProcessEnv>
): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (value === undefined || value === "") continue;
if (STRIPPED_KEYS.has(key)) continue;
const upper = key.toUpperCase();
if (SENSITIVE_PATTERNS.some((p) => upper.includes(p))) continue;
out[key] = value;
}
return out;
}
function defaultRunnerPath(): string {
return fileURLToPath(new URL("./runner-bin.js", import.meta.url));
}
/**
* Real `node:child_process`-backed sandbox engine. Each `execute` call spawns
* a fresh child process pinned to `process.execPath`, writes a JSON envelope
* to stdin, reads JSON from stdout, and enforces the timeout via
* `child.kill("SIGKILL")`. The process boundary is the security guarantee.
*/
export class SpawnEngine implements SandboxEngine {
public readonly name = "spawn";
private readonly config: SandboxEngineConfig;
private readonly runnerPath: string;
private readonly execPath: string;
private readonly spawner: ProcessSpawner;
private readonly env: Readonly<NodeJS.ProcessEnv>;
private modules: SandboxModule[];
private disposed = false;
public constructor(options: SpawnEngineOptions) {
this.config = {
timeout: options.timeout,
maxOutputSize: options.maxOutputSize,
memoryLimit: options.memoryLimit,
...(options.modules === undefined
? {}
: { modules: options.modules }),
};
this.modules = [...(options.modules ?? [])];
this.runnerPath = options.runnerPath ?? defaultRunnerPath();
this.execPath = options.execPath ?? process.execPath;
this.spawner =
options.spawner ??
((cmd, args, opts) => nodeSpawn(cmd, [...args], opts));
this.env = options.env ?? process.env;
}
/** Expose the resolved runner path (useful for tooling/diagnostics). */
public getRunnerPath(): string {
return this.runnerPath;
}
/** Expose the sanitised env (also called from tests). */
public getSanitisedEnv(): Record<string, string> {
return sanitiseEnv(this.env);
}
public async execute<T = unknown>(
code: string,
context: ExecutionContext = {},
options: SandboxOptions = {}
): Promise<SandboxResult<T>> {
if (this.disposed) {
recordExecution("spawn", "failure");
return failure<T>("Engine has been disposed", 0);
}
const timeout = options.timeout ?? this.config.timeout;
const maxOutputSize =
options.maxOutputSize ?? this.config.maxOutputSize;
const payload = {
code,
context,
modules: this.modules,
maxOutputSize,
};
const start = performance.now();
const child = this.spawner(this.execPath, [this.runnerPath], {
stdio: ["pipe", "pipe", "pipe"],
env: this.getSanitisedEnv(),
});
if (child.stdin === null || child.stdout === null) {
recordExecution("spawn", "failure");
return failure<T>("Child process did not expose stdio pipes", 0);
}
child.stdin.write(JSON.stringify(payload));
child.stdin.end();
const outcome = await raceChild(child, timeout);
const duration = performance.now() - start;
if (outcome.kind === "timeout") {
recordExecution("spawn", "failure");
recordDuration("spawn", "failure", duration);
return failure<T>("Child execution timed out", duration);
}
if (outcome.kind === "no-output") {
recordExecution("spawn", "failure");
recordDuration("spawn", "failure", duration);
return failure<T>("Child process produced no output", duration);
}
if (outcome.kind === "parse-error") {
recordExecution("spawn", "failure");
recordDuration("spawn", "failure", duration);
return failure<T>(
`Failed to parse child output: ${outcome.error}`,
duration
);
}
const parsed = outcome.result;
recordExecution("spawn", parsed.success ? "success" : "failure");
recordDuration(
"spawn",
parsed.success ? "success" : "failure",
duration
);
return {
success: parsed.success,
result: parsed.result as T,
error: parsed.error,
duration,
memoryUsed: parsed.memoryUsed ?? 0,
logs: parsed.logs ?? [],
};
}
public registerModule(mod: SandboxModule): void {
this.modules = this.modules.filter((m) => m.name !== mod.name);
this.modules.push(mod);
}
public unregisterModule(name: string): boolean {
const before = this.modules.length;
this.modules = this.modules.filter((m) => m.name !== name);
return this.modules.length < before;
}
public reset(): void {
this.modules = [...(this.config.modules ?? [])];
}
public destroy(): void {
this.disposed = true;
this.modules = [];
}
}
interface ChildReply {
readonly success: boolean;
readonly result?: unknown;
readonly error?: string;
readonly logs?: readonly string[];
readonly memoryUsed?: number;
}
type ChildOutcome =
| { kind: "ok"; result: ChildReply }
| { kind: "timeout" }
| { kind: "no-output" }
| { kind: "parse-error"; error: string };
function raceChild(
child: ChildProcess,
timeout: number
): Promise<ChildOutcome> {
return new Promise<ChildOutcome>((resolve) => {
const chunks: Buffer[] = [];
let settled = false;
const settle = (outcome: ChildOutcome): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(outcome);
};
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
/* ignore */
}
settle({ kind: "timeout" });
}, timeout);
child.stdout?.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
child.on("error", (err: Error) => {
settle({ kind: "parse-error", error: err.message });
});
child.on("close", () => {
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (raw === "") {
settle({ kind: "no-output" });
return;
}
try {
const parsed = JSON.parse(raw) as ChildReply;
settle({ kind: "ok", result: parsed });
} catch (err) {
// JSON.parse only throws SyntaxError (a subclass of Error),
// so `err.message` is always available.
settle({
kind: "parse-error",
error: (err as Error).message,
});
}
});
});
}
function failure<T>(error: string, duration: number): SandboxResult<T> {
return {
success: false,
result: undefined,
error,
duration,
memoryUsed: 0,
logs: [],
};
}

View File

@@ -0,0 +1,15 @@
export type {
PoolAcquisitionResult,
SandboxEngineLabel,
SandboxOutcome,
} from "./telemetry.js";
export {
recordDuration,
recordExecution,
recordPoolAcquisition,
recordRuleBlock,
sandboxExecutionDurationHistogram,
sandboxExecutionsCounter,
sandboxPoolAcquisitionsCounter,
sandboxRuleBlocksCounter,
} from "./telemetry.js";

View File

@@ -0,0 +1,60 @@
# Telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/sandbox` ships first-class OpenTelemetry instrumentation so consumers running the sandbox at any scale can observe execution health without wrapping the API in their own counters. Every public failure mode and every successful execution increments a tagged counter, and every execution duration lands in a histogram tagged by engine kind and outcome.
The meter resolves lazily from the globally registered `MeterProvider`. When no provider is registered the OTel API returns no-op instruments, so the helpers can be called from every code path unconditionally — production code that has opted out of telemetry pays only the no-op cost.
## Features
- Four instruments on the `@suaveplan/sandbox` meter.
- Counter `sandbox_executions_total` — `{ engine, outcome }`.
- Counter `sandbox_rule_blocks_total` — `{ rule }`.
- Counter `sandbox_pool_acquisitions_total` — `{ result }` (`acquired` /
`created` / `exhausted`).
- Histogram `sandbox_execution_duration_ms` — `{ engine, outcome }`.
- Four record helpers so call sites never construct attribute bags manually.
## Usage: Basic
```ts
import { recordExecution } from "@suaveplan/sandbox/telemetry";
recordExecution("vm", "success");
```
## Usage: Advanced
Combine the duration histogram with the success counter to derive a p95
latency by engine kind, scoped by outcome, in your OTel collector:
```promql
histogram_quantile(0.95,
sum(rate(sandbox_execution_duration_ms_bucket{outcome="success"}[5m]))
by (engine, le))
```
## API Reference
- `sandboxExecutionsCounter: Counter`
- `sandboxRuleBlocksCounter: Counter`
- `sandboxPoolAcquisitionsCounter: Counter`
- `sandboxExecutionDurationHistogram: Histogram`
- `recordExecution(engine, outcome): void`
- `recordRuleBlock(rule): void`
- `recordPoolAcquisition(result): void`
- `recordDuration(engine, outcome, ms): void`
## Implementation Notes
Instruments are created at module-load time on a memoised meter — the OTel API
caches by name, so re-importing this module from another package does not
register duplicate instruments. The recording helpers are deliberately
parameter-positional (not options bags) so call sites stay short and the
attribute bag construction happens in one place rather than being repeated at
every recording site.

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordDuration,
recordExecution,
recordPoolAcquisition,
recordRuleBlock,
sandboxExecutionDurationHistogram,
sandboxExecutionsCounter,
sandboxPoolAcquisitionsCounter,
sandboxRuleBlocksCounter,
} from "./telemetry.js";
describe("telemetry", () => {
it("exports four instruments and four recorders", { timeout: 5000 }, () => {
expect(sandboxExecutionsCounter).toBeDefined();
expect(sandboxRuleBlocksCounter).toBeDefined();
expect(sandboxPoolAcquisitionsCounter).toBeDefined();
expect(sandboxExecutionDurationHistogram).toBeDefined();
expect(recordExecution).toBeTypeOf("function");
expect(recordRuleBlock).toBeTypeOf("function");
expect(recordPoolAcquisition).toBeTypeOf("function");
expect(recordDuration).toBeTypeOf("function");
});
it("recorders execute without throwing against the no-op meter", {
timeout: 5000,
}, () => {
expect(() => {
recordExecution("vm", "success");
recordExecution("worker", "failure");
recordExecution("spawn", "success");
recordExecution("isolate", "failure");
recordRuleBlock("block-process");
recordPoolAcquisition("acquired");
recordPoolAcquisition("created");
recordPoolAcquisition("exhausted");
recordDuration("vm", "success", 1.5);
}).not.toThrow();
});
});

View File

@@ -0,0 +1,76 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/sandbox";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns no-op
* instruments, so the helpers below are safe to call from production code
* that has not opted into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
export type SandboxOutcome = "success" | "failure";
export type SandboxEngineLabel = "vm" | "worker" | "spawn" | "isolate";
export type PoolAcquisitionResult = "acquired" | "created" | "exhausted";
/** Counter incremented once per sandbox execution. */
export const sandboxExecutionsCounter: Counter = meter.createCounter(
"sandbox_executions_total",
{
description:
"Sandbox executions, tagged with engine kind and success/failure outcome",
}
);
/** Counter incremented once per code-transformer rule block. */
export const sandboxRuleBlocksCounter: Counter = meter.createCounter(
"sandbox_rule_blocks_total",
{
description:
"Code-transformer rule blocks, tagged with the offending rule name",
}
);
/** Counter incremented once per `SandboxPool.execute` acquisition attempt. */
export const sandboxPoolAcquisitionsCounter: Counter = meter.createCounter(
"sandbox_pool_acquisitions_total",
{
description:
"Sandbox pool acquisitions, tagged with acquired/created/exhausted result",
}
);
/** Histogram of execution durations in milliseconds. */
export const sandboxExecutionDurationHistogram: Histogram =
meter.createHistogram("sandbox_execution_duration_ms", {
description: "Sandbox execution durations in milliseconds",
unit: "ms",
});
/** Record a single sandbox execution outcome. */
export function recordExecution(
engine: SandboxEngineLabel,
outcome: SandboxOutcome
): void {
sandboxExecutionsCounter.add(1, { engine, outcome });
}
/** Record a code-transformer rule block. */
export function recordRuleBlock(rule: string): void {
sandboxRuleBlocksCounter.add(1, { rule });
}
/** Record a pool acquisition outcome. */
export function recordPoolAcquisition(result: PoolAcquisitionResult): void {
sandboxPoolAcquisitionsCounter.add(1, { result });
}
/** Record an execution duration. */
export function recordDuration(
engine: SandboxEngineLabel,
outcome: SandboxOutcome,
ms: number
): void {
sandboxExecutionDurationHistogram.record(ms, { engine, outcome });
}

View File

@@ -0,0 +1,16 @@
export type {
AuditFilter,
AuditLogEntry,
AuditStatistics,
Clock,
ExecutionContext,
SandboxConfig,
SandboxEngine,
SandboxEngineConfig,
SandboxEngineKind,
SandboxModule,
SandboxOptions,
SandboxResult,
SyncSandboxEngine,
} from "./sandbox.types.js";
export { DEFAULT_CONFIG } from "./sandbox.types.js";

View File

@@ -0,0 +1,124 @@
/**
* Shared types for `@suaveplan/sandbox`.
*
* These are types-only and have no runtime cost. Every public surface in the
* package consumes types declared here so contracts stay single-sourced.
*/
import type { AuditFilter } from "../audit-logger/audit-logger.types.js";
/** Pluggable clock; defaults to `Date.now`. */
export type Clock = () => number;
/** Configuration for creating a sandbox instance. */
export interface SandboxConfig {
readonly timeout: number;
readonly memoryLimit: number;
readonly allowedModules: readonly string[];
readonly maxOutputSize: number;
readonly maxExecutionsPerWindow: number | undefined;
readonly rateLimitWindow: number;
readonly maxCodeLength: number;
readonly engine: SandboxEngineKind;
readonly onAuditLog?: (entry: AuditLogEntry) => void;
}
/** Engine kind selector for `createSandbox`. */
export type SandboxEngineKind = "vm" | "worker" | "spawn" | "isolate";
/** Per-execution option overrides. */
export interface SandboxOptions {
readonly timeout?: number;
readonly memoryLimit?: number;
readonly maxOutputSize?: number;
}
/** Module exposed inside the sandbox. */
export interface SandboxModule {
readonly name: string;
readonly exports: Record<string, unknown>;
}
/** Variables/functions injected into the sandbox execution context. */
export type ExecutionContext = Record<string, unknown>;
/** Structured result of a sandbox execution. */
export interface SandboxResult<T = unknown> {
readonly success: boolean;
readonly result: T | undefined;
readonly error: string | undefined;
readonly duration: number;
readonly memoryUsed: number;
readonly logs: readonly string[];
}
/** Audit log entry. */
export interface AuditLogEntry {
readonly timestamp: string;
readonly codeHash: string;
readonly duration: number;
readonly success: boolean;
readonly memoryUsed: number;
readonly error?: string;
}
/** Aggregate statistics across audit entries. */
export interface AuditStatistics {
readonly totalRuns: number;
readonly successCount: number;
readonly failureCount: number;
readonly successRate: number;
readonly avgDuration: number;
readonly totalMemoryUsed: number;
}
/** Re-export filter contract for audit search. */
export type { AuditFilter };
/** Default sandbox configuration. */
export const DEFAULT_CONFIG: SandboxConfig = {
timeout: 5000,
memoryLimit: 50 * 1024 * 1024,
allowedModules: [],
maxOutputSize: 1024 * 1024,
maxExecutionsPerWindow: undefined,
rateLimitWindow: 60_000,
maxCodeLength: 100_000,
engine: "vm",
};
/** Engine configuration passed to every concrete engine constructor. */
export interface SandboxEngineConfig {
readonly timeout: number;
readonly maxOutputSize: number;
readonly memoryLimit: number;
readonly modules?: readonly SandboxModule[];
}
/** Engine contract. Concrete engines implement this. */
export interface SandboxEngine {
readonly name: string;
execute<T = unknown>(
code: string,
context?: ExecutionContext,
options?: SandboxOptions
): Promise<SandboxResult<T>>;
registerModule(mod: SandboxModule): void;
unregisterModule(name: string): boolean;
reset(): void;
destroy(): void;
}
/** Lower-level synchronous engine (vm, isolate). */
export interface SyncSandboxEngine {
readonly name: string;
executeSync<T = unknown>(
code: string,
context?: ExecutionContext,
options?: SandboxOptions
): SandboxResult<T>;
registerModule(mod: SandboxModule): void;
unregisterModule(name: string): boolean;
reset(): void;
destroy(): void;
}

View File

@@ -0,0 +1 @@
export { VmEngine } from "./vm-engine.js";

View File

@@ -0,0 +1,86 @@
# VmEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VmEngine` is the default sandbox engine. It uses `node:vm` directly: caller code is compiled with `new vm.Script(...)` and executed via `script.runInContext(ctx, { timeout })`. The context is created with `codeGeneration: { strings: false, wasm: false }` so `eval` and the Function constructor are syntactically unavailable inside the sandbox even if the code-transformer's regex filter fails. Context variables and registered modules are injected as own properties of the context object; the host's intrinsics (`Object`, `Array`, etc.) are reachable through the shared realm.
`VmEngine` is NOT a security boundary against adversarial code. `node:vm`'s
context-sharing model means a determined attacker with code-injection can
escape via `this.constructor.constructor("...")` or prototype-chain tricks.
For adversarial isolation use `worker-engine` (OS-thread isolation) or
`spawn-engine` (process isolation).
## Features
- Real `node:vm` execution — synchronous, deterministic.
- Frozen intrinsics flag (`codeGeneration: { strings: false, wasm: false }`).
- Captured console (`log`/`warn`/`error`) with output-size cap.
- Module + context-variable injection per execution.
- V8 heap statistics via `v8.getHeapStatistics()`.
- OTel `recordExecution` + `recordDuration` emission per call.
- `executeSync` companion for pool callers that prefer synchronous APIs.
## Usage: Basic
```ts
import { VmEngine } from "@suaveplan/sandbox/vm-engine";
const engine = new VmEngine({
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
});
const r = await engine.execute("1 + 1");
console.log(r.result); // 2
engine.destroy();
```
## Usage: Advanced
Inject a host bridge as a module so user code can interact with the
controlled-vocabulary API rather than the host's intrinsics:
```ts
import { VmEngine } from "@suaveplan/sandbox/vm-engine";
const engine = new VmEngine({
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
modules: [
{
name: "host",
exports: {
fetchProduct: (id: string) => loadProduct(id),
},
},
],
});
const r = await engine.execute('host.fetchProduct("sku-42")');
```
## API Reference
- `new VmEngine(config: SandboxEngineConfig)`.
- `execute(code, context?, options?): Promise<SandboxResult>`.
- `executeSync(code, context?, options?): SandboxResult`.
- `registerModule(mod): void`.
- `unregisterModule(name): boolean`.
- `getHeapStatistics(): { usedHeapSize, heapSizeLimit } | null`.
- `reset(): void`.
- `destroy(): void`.
## Implementation Notes
The compiled script wraps the user code in `(${code})` so a bare expression
(e.g. `1 + 1`) returns its value rather than throwing a syntax error from a
missing semicolon. An IIFE (`function(){...}()`) compiles to
`(function(){...}())` which is also valid. The wrapper is intentionally
lightweight; callers who want statement-level execution should use
`executeFunction` on the facade or wrap their code in a function themselves.
The `breakOnSigint: true` flag is set so a SIGINT delivered to the host
terminates the running script rather than killing the entire process.

View File

@@ -0,0 +1,248 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VmEngine } from "./vm-engine.js";
const baseConfig = {
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
};
describe("VmEngine", () => {
it("executes a simple expression", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute("1 + 1");
expect(r.success).toBe(true);
expect(r.result).toBe(2);
expect(r.duration).toBeGreaterThanOrEqual(0);
e.destroy();
});
it("captures console output", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
'function(){ console.log("hello", 42); return 1; }()'
);
expect(r.success).toBe(true);
expect(r.logs).toContain("hello 42");
e.destroy();
});
it("captures console.warn with [warn] prefix", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
'function(){ console.warn("w"); return 1; }()'
);
expect(r.logs).toContain("[warn] w");
e.destroy();
});
it("captures console.error with [error] prefix", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
'function(){ console.error("oops"); return 1; }()'
);
expect(r.logs).toContain("[error] oops");
e.destroy();
});
it("logs null and undefined", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
"function(){ console.log(null, undefined); return 0; }()"
);
expect(r.logs).toContain("null undefined");
e.destroy();
});
it("logs objects as JSON", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
"function(){ console.log({a:1}); return 0; }()"
);
expect(r.logs).toContain('{"a":1}');
e.destroy();
});
it("logs unstringifiable objects as String() fallback", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
// Circular ref forces JSON.stringify to throw → fallback to String().
const r = await e.execute(
"function(){ var o = {}; o.self = o; console.log(o); return 0; }()"
);
expect(r.success).toBe(true);
expect(r.logs.length).toBe(1);
e.destroy();
});
it("truncates console output above maxOutputSize", {
timeout: 5000,
}, async () => {
const e = new VmEngine({ ...baseConfig, maxOutputSize: 5 });
const r = await e.execute(
'function(){ console.log("aaaaaaaaaa"); console.log("more"); return 1; }()'
);
// First log line has length 10 > 5 limit, so no log is captured.
expect(r.logs.length).toBe(0);
e.destroy();
});
it("injects context variables", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute("x + y", { x: 2, y: 3 });
expect(r.result).toBe(5);
e.destroy();
});
it("injects modules", { timeout: 5000 }, async () => {
const e = new VmEngine({
...baseConfig,
modules: [{ name: "cfg", exports: { mult: 7 } }],
});
const r = await e.execute("cfg.mult * 6");
expect(r.result).toBe(42);
e.destroy();
});
it("registers and unregisters modules", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
e.registerModule({ name: "cfg", exports: { v: 1 } });
e.registerModule({ name: "cfg", exports: { v: 2 } }); // overwrite
expect(e.unregisterModule("cfg")).toBe(true);
expect(e.unregisterModule("cfg")).toBe(false);
e.destroy();
});
it("surfaces thrown errors as failure result", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute('function(){ throw new Error("boom"); }()');
expect(r.success).toBe(false);
expect(r.error).toContain("boom");
e.destroy();
});
it("surfaces non-Error throws via String()", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute('function(){ throw "plain"; }()');
expect(r.success).toBe(false);
expect(r.error).toBe("plain");
e.destroy();
});
it("enforces sync timeout", { timeout: 10000 }, async () => {
const e = new VmEngine({ ...baseConfig, timeout: 100 });
const r = await e.execute("function(){ while(true){} }()");
expect(r.success).toBe(false);
expect(r.error).toMatch(/timed out|timeout/i);
e.destroy();
});
it("respects per-execution timeout override", {
timeout: 10000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
"function(){ while(true){} }()",
{},
{ timeout: 100 }
);
expect(r.success).toBe(false);
e.destroy();
});
it("respects per-execution maxOutputSize override", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
'function(){ console.log("aaaa"); return 1; }()',
{},
{ maxOutputSize: 2 }
);
expect(r.logs.length).toBe(0);
e.destroy();
});
it("getHeapStatistics returns null after destroy", {
timeout: 5000,
}, () => {
const e = new VmEngine(baseConfig);
const stats = e.getHeapStatistics();
expect(stats?.usedHeapSize).toBeGreaterThan(0);
e.destroy();
expect(e.getHeapStatistics()).toBeNull();
});
it("returns failure result after destroy", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
e.destroy();
const r = await e.execute("1");
expect(r.success).toBe(false);
expect(r.error).toContain("disposed");
});
it("reset restores initial modules", { timeout: 5000 }, async () => {
const e = new VmEngine({
...baseConfig,
modules: [{ name: "cfg", exports: { v: 1 } }],
});
e.unregisterModule("cfg");
e.reset();
const r = await e.execute("cfg.v");
expect(r.result).toBe(1);
e.destroy();
});
it("reset works when no initial modules", { timeout: 5000 }, () => {
const e = new VmEngine(baseConfig);
e.reset();
e.destroy();
});
it("name is 'vm'", { timeout: 5000 }, () => {
const e = new VmEngine(baseConfig);
expect(e.name).toBe("vm");
e.destroy();
});
it("executeSync invoked with no context+options uses defaults", {
timeout: 5000,
}, () => {
const e = new VmEngine(baseConfig);
const r = e.executeSync("1 + 1");
expect(r.success).toBe(true);
expect(r.result).toBe(2);
e.destroy();
});
it("executeSync with non-Error thrown via vm", {
timeout: 5000,
}, () => {
const e = new VmEngine(baseConfig);
const r = e.executeSync("function(){ var x = 5; throw x; }()");
expect(r.success).toBe(false);
expect(r.error).toBe("5");
e.destroy();
});
it("executeSync with primitive null throw", {
timeout: 5000,
}, () => {
const e = new VmEngine(baseConfig);
const r = e.executeSync("function(){ throw null; }()");
expect(r.success).toBe(false);
expect(r.error).toBe("null");
e.destroy();
});
});

View File

@@ -0,0 +1,192 @@
import { getHeapStatistics } from "node:v8";
import vm from "node:vm";
import { recordDuration, recordExecution } from "../telemetry/telemetry.js";
import type {
ExecutionContext,
SandboxEngine,
SandboxEngineConfig,
SandboxModule,
SandboxOptions,
SandboxResult,
} from "../types/sandbox.types.js";
/**
* Real `node:vm`-backed sandbox engine. The default engine for
* `createSandbox`. NOT a security boundary against adversarial code —
* `vm.runInContext` shares the host realm; a determined attacker can escape
* via prototype-chain tricks. For adversarial isolation use
* `worker-engine` or `spawn-engine`.
*/
export class VmEngine implements SandboxEngine {
public readonly name = "vm";
private readonly config: SandboxEngineConfig;
private modules: SandboxModule[];
private disposed = false;
public constructor(config: SandboxEngineConfig) {
this.config = config;
this.modules = [...(config.modules ?? [])];
}
public execute<T = unknown>(
code: string,
context: ExecutionContext = {},
options: SandboxOptions = {}
): Promise<SandboxResult<T>> {
return Promise.resolve(this.executeSync<T>(code, context, options));
}
/** Synchronous executor — useful for the pool which iterates many calls. */
public executeSync<T = unknown>(
code: string,
context: ExecutionContext = {},
options: SandboxOptions = {}
): SandboxResult<T> {
if (this.disposed) {
recordExecution("vm", "failure");
return errorResult<T>("Engine has been disposed", 0, 0);
}
const timeout = options.timeout ?? this.config.timeout;
const maxOutputSize =
options.maxOutputSize ?? this.config.maxOutputSize;
const logs: string[] = [];
let totalLogSize = 0;
const appendLog = (line: string): void => {
totalLogSize += line.length;
if (totalLogSize <= maxOutputSize) {
logs.push(line);
}
};
const sandboxConsole = {
log: (...args: unknown[]) =>
appendLog(args.map(stringify).join(" ")),
warn: (...args: unknown[]) =>
appendLog(`[warn] ${args.map(stringify).join(" ")}`),
error: (...args: unknown[]) =>
appendLog(`[error] ${args.map(stringify).join(" ")}`),
};
const sandboxObject: Record<string, unknown> = {
console: sandboxConsole,
...context,
};
for (const mod of this.modules) {
sandboxObject[mod.name] = mod.exports;
}
const ctx = vm.createContext(sandboxObject, {
codeGeneration: { strings: false, wasm: false },
});
const start = performance.now();
const heapBefore = safeHeapUsed();
try {
const script = new vm.Script(`(${code})`);
const result = script.runInContext(ctx, {
timeout,
breakOnSigint: true,
});
const duration = performance.now() - start;
const heapAfter = safeHeapUsed();
recordExecution("vm", "success");
recordDuration("vm", "success", duration);
return {
success: true,
result: result as T,
error: undefined,
duration,
memoryUsed: Math.max(0, heapAfter - heapBefore),
logs,
};
} catch (err: unknown) {
const duration = performance.now() - start;
const message = toErrorMessage(err);
recordExecution("vm", "failure");
recordDuration("vm", "failure", duration);
return {
success: false,
result: undefined,
error: message,
duration,
memoryUsed: 0,
logs,
};
}
}
public registerModule(mod: SandboxModule): void {
this.modules = this.modules.filter((m) => m.name !== mod.name);
this.modules.push(mod);
}
public unregisterModule(name: string): boolean {
const before = this.modules.length;
this.modules = this.modules.filter((m) => m.name !== name);
return this.modules.length < before;
}
/** Returns engine heap stats or null when disposed. */
public getHeapStatistics(): {
usedHeapSize: number;
heapSizeLimit: number;
} | null {
if (this.disposed) return null;
const stats = getHeapStatistics();
return {
usedHeapSize: stats.used_heap_size,
heapSizeLimit: stats.heap_size_limit,
};
}
public reset(): void {
this.modules = [...(this.config.modules ?? [])];
}
public destroy(): void {
this.disposed = true;
this.modules = [];
}
}
function stringify(value: unknown): string {
if (typeof value === "string") return value;
if (value === null) return "null";
if (value === undefined) return "undefined";
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function toErrorMessage(err: unknown): string {
// The Error class inside a vm.Context is NOT the host Error; instanceof
// is therefore unreliable across the realm boundary. Duck-type on the
// `message` property.
if (
err !== null &&
typeof err === "object" &&
"message" in err &&
typeof (err as { message: unknown }).message === "string"
) {
return (err as { message: string }).message;
}
return String(err);
}
function errorResult<T>(
error: string,
duration: number,
memoryUsed: number
): SandboxResult<T> {
return {
success: false,
result: undefined,
error,
duration,
memoryUsed,
logs: [],
};
}
function safeHeapUsed(): number {
return getHeapStatistics().used_heap_size;
}

View File

@@ -0,0 +1 @@
export { WorkerEngine } from "./worker-engine.js";

View File

@@ -0,0 +1,70 @@
# WorkerEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerEngine` uses real `node:worker_threads` for true OS-thread isolation. Each `execute` call spawns a fresh `Worker` constructed from an inline source string (`eval: true`), posts the code + context + modules over the structured-clone message channel, and awaits the structured reply. Intrinsics are NOT shared with the host realm — the worker has its own V8 isolate, its own heap, and its own event loop. Timeouts are enforced by calling `worker.terminate()`, which is a hard stop the running script cannot intercept.
Use `WorkerEngine` when the code being sandboxed may be adversarial: a true OS-thread boundary defeats the prototype-chain escapes that compromise `vm-engine`. The cost is per-call worker startup, which is non-trivial; pair with `SandboxPool` when latency matters.
## Features
- Real `node:worker_threads` execution — OS-thread isolation.
- Worker spawned per call, terminated after every result.
- `worker.terminate()` enforces timeout — script cannot intercept.
- Structured-clone passes context + modules.
- Output-size cap applied inside the worker before postMessage.
- OTel `recordExecution` + `recordDuration` per call.
## Usage: Basic
```ts
import { WorkerEngine } from "@suaveplan/sandbox/worker-engine";
const e = new WorkerEngine({
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
});
const r = await e.execute("2 + 2");
e.destroy();
```
## Usage: Advanced
Wrap the engine in a pool to amortise startup:
```ts
import { SandboxPool } from "@suaveplan/sandbox/pool";
import { WorkerEngine } from "@suaveplan/sandbox/worker-engine";
const pool = new SandboxPool({
minSize: 2,
maxSize: 8,
engineConfig: { timeout: 5000, maxOutputSize: 1024, memoryLimit: 5e7 },
factory: (cfg) => new WorkerEngine(cfg),
});
```
## API Reference
- `new WorkerEngine(config: SandboxEngineConfig)`.
- `execute(code, context?, options?): Promise<SandboxResult>`.
- `registerModule(mod): void`.
- `unregisterModule(name): boolean`.
- `reset(): void`.
- `destroy(): void`.
## Implementation Notes
The inline worker source is a single string literal. The worker uses a
runtime `Reflect.construct(Function, [...keys, source])` to wrap the user
code in an isolated function scope — this is acceptable inside the worker
because the worker IS the security boundary. Outside the worker, in the host,
no dynamic code construction happens. The post-message channel uses
structured clone, which copies primitives + plain objects + typed arrays but
not functions or symbols — module exports must therefore be data, not host
references; for host bridges use `spawn-engine` plus an IPC channel.

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WorkerEngine } from "./worker-engine.js";
const baseConfig = {
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
};
describe("WorkerEngine", () => {
it("executes 2+2", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute("2 + 2");
expect(r.success).toBe(true);
expect(r.result).toBe(4);
expect(r.duration).toBeGreaterThanOrEqual(0);
e.destroy();
});
it("captures console output", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute(
'function(){ console.log("hi"); return 1; }()'
);
expect(r.logs).toContain("hi");
e.destroy();
});
it("captures context variables", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute("a + b", { a: 3, b: 4 });
expect(r.result).toBe(7);
e.destroy();
});
it("propagates thrown errors", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute('function(){ throw new Error("boom"); }()');
expect(r.success).toBe(false);
expect(r.error).toContain("boom");
e.destroy();
});
it("terminates on timeout", { timeout: 15000 }, async () => {
const e = new WorkerEngine({ ...baseConfig, timeout: 200 });
const r = await e.execute("function(){ while(true){} }()");
expect(r.success).toBe(false);
expect(r.error).toContain("timed out");
e.destroy();
});
it("registerModule injects exports into the worker", {
timeout: 15000,
}, async () => {
const e = new WorkerEngine(baseConfig);
e.registerModule({ name: "cfg", exports: { v: 21 } });
const r = await e.execute("cfg.v * 2");
expect(r.result).toBe(42);
e.destroy();
});
it("registerModule overwrites by name", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
e.registerModule({ name: "cfg", exports: { v: 1 } });
e.registerModule({ name: "cfg", exports: { v: 2 } });
const r = await e.execute("cfg.v");
expect(r.result).toBe(2);
e.destroy();
});
it("unregisterModule returns boolean", { timeout: 5000 }, () => {
const e = new WorkerEngine(baseConfig);
e.registerModule({ name: "cfg", exports: {} });
expect(e.unregisterModule("cfg")).toBe(true);
expect(e.unregisterModule("cfg")).toBe(false);
e.destroy();
});
it("rejects executions after destroy", { timeout: 5000 }, async () => {
const e = new WorkerEngine(baseConfig);
e.destroy();
const r = await e.execute("1");
expect(r.success).toBe(false);
expect(r.error).toContain("disposed");
});
it("reset restores initial modules", { timeout: 15000 }, async () => {
const e = new WorkerEngine({
...baseConfig,
modules: [{ name: "cfg", exports: { v: 9 } }],
});
e.unregisterModule("cfg");
e.reset();
const r = await e.execute("cfg.v");
expect(r.result).toBe(9);
e.destroy();
});
it("per-execution timeout override fires", {
timeout: 15000,
}, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute(
"function(){ while(true){} }()",
{},
{ timeout: 150 }
);
expect(r.success).toBe(false);
e.destroy();
});
it("per-execution maxOutputSize override truncates logs", {
timeout: 15000,
}, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute(
'function(){ console.log("aaaaaaaaaa"); return 1; }()',
{},
{ maxOutputSize: 2 }
);
expect(r.logs.length).toBe(0);
e.destroy();
});
it("name is 'worker'", { timeout: 5000 }, () => {
const e = new WorkerEngine(baseConfig);
expect(e.name).toBe("worker");
e.destroy();
});
it("surfaces worker syntax errors", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute("}{not valid(");
expect(r.success).toBe(false);
expect(r.error).toBeDefined();
e.destroy();
});
it("execute uses default context+options", {
timeout: 15000,
}, async () => {
const e = new WorkerEngine(baseConfig);
// Call execute(code) with no context/options at all so the default
// parameter values are exercised.
const r = await e.execute("3 * 3");
expect(r.result).toBe(9);
e.destroy();
});
it("reset works when no initial modules", { timeout: 5000 }, () => {
const e = new WorkerEngine(baseConfig);
e.reset();
e.destroy();
});
});

View File

@@ -0,0 +1,196 @@
import { Worker } from "node:worker_threads";
import { recordDuration, recordExecution } from "../telemetry/telemetry.js";
import type {
ExecutionContext,
SandboxEngine,
SandboxEngineConfig,
SandboxModule,
SandboxOptions,
SandboxResult,
} from "../types/sandbox.types.js";
interface WorkerPayload {
readonly code: string;
readonly context: ExecutionContext;
readonly modules: readonly SandboxModule[];
readonly maxOutputSize: number;
}
interface WorkerReply {
readonly success: boolean;
readonly result: unknown;
readonly error: string | undefined;
readonly logs: readonly string[];
readonly memoryUsed: number;
}
const WORKER_SOURCE = `
const { parentPort } = require('worker_threads');
parentPort.once('message', (payload) => {
const logs = [];
let totalLogSize = 0;
const stringify = (v) => {
if (typeof v === 'string') return v;
if (v === null) return 'null';
if (v === undefined) return 'undefined';
try { return JSON.stringify(v); } catch { return String(v); }
};
const append = (line) => {
totalLogSize += line.length;
if (totalLogSize <= payload.maxOutputSize) logs.push(line);
};
const sandboxConsole = {
log: (...a) => append(a.map(stringify).join(' ')),
warn: (...a) => append('[warn] ' + a.map(stringify).join(' ')),
error: (...a) => append('[error] ' + a.map(stringify).join(' ')),
};
const scope = { console: sandboxConsole, ...payload.context };
for (const m of payload.modules) scope[m.name] = m.exports;
const memBefore = process.memoryUsage().heapUsed;
try {
const keys = Object.keys(scope);
const vals = keys.map(k => scope[k]);
const factory = Reflect.construct(Function, [...keys, '"use strict";\\nreturn (' + payload.code + ')']);
const result = factory(...vals);
const memAfter = process.memoryUsage().heapUsed;
parentPort.postMessage({
success: true, result, error: undefined,
logs, memoryUsed: Math.max(0, memAfter - memBefore),
});
} catch (err) {
const memAfter = process.memoryUsage().heapUsed;
parentPort.postMessage({
success: false, result: undefined,
error: err instanceof Error ? err.message : String(err),
logs, memoryUsed: Math.max(0, memAfter - memBefore),
});
}
});
`;
/**
* Real `node:worker_threads`-backed sandbox engine. Each `execute` call spawns
* a fresh worker, posts the code + context + modules over the message channel,
* receives the structured reply, and enforces the timeout via
* `worker.terminate()`. The worker boundary is a true OS thread; intrinsics
* are NOT shared with the host realm.
*/
export class WorkerEngine implements SandboxEngine {
public readonly name = "worker";
private readonly config: SandboxEngineConfig;
private modules: SandboxModule[];
private disposed = false;
public constructor(config: SandboxEngineConfig) {
this.config = config;
this.modules = [...(config.modules ?? [])];
}
public async execute<T = unknown>(
code: string,
context: ExecutionContext = {},
options: SandboxOptions = {}
): Promise<SandboxResult<T>> {
if (this.disposed) {
recordExecution("worker", "failure");
return failure<T>("Engine has been disposed", 0);
}
const timeout = options.timeout ?? this.config.timeout;
const maxOutputSize =
options.maxOutputSize ?? this.config.maxOutputSize;
const payload: WorkerPayload = {
code,
context,
modules: this.modules,
maxOutputSize,
};
const start = performance.now();
const worker = new Worker(WORKER_SOURCE, { eval: true });
try {
const reply = await raceWorker<WorkerReply>(
worker,
payload,
timeout
);
const duration = performance.now() - start;
if (reply === "timeout") {
recordExecution("worker", "failure");
recordDuration("worker", "failure", duration);
return failure<T>("Worker execution timed out", duration);
}
recordExecution("worker", reply.success ? "success" : "failure");
recordDuration(
"worker",
reply.success ? "success" : "failure",
duration
);
return {
success: reply.success,
result: reply.result as T,
error: reply.error,
duration,
memoryUsed: reply.memoryUsed,
logs: reply.logs,
};
} finally {
await worker.terminate();
}
}
public registerModule(mod: SandboxModule): void {
this.modules = this.modules.filter((m) => m.name !== mod.name);
this.modules.push(mod);
}
public unregisterModule(name: string): boolean {
const before = this.modules.length;
this.modules = this.modules.filter((m) => m.name !== name);
return this.modules.length < before;
}
public reset(): void {
this.modules = [...(this.config.modules ?? [])];
}
public destroy(): void {
this.disposed = true;
this.modules = [];
}
}
function raceWorker<T>(
worker: Worker,
payload: WorkerPayload,
timeout: number
): Promise<T | "timeout"> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
cleanup();
resolve("timeout");
}, timeout);
const onMessage = (msg: unknown): void => {
cleanup();
resolve(msg as T);
};
const cleanup = (): void => {
clearTimeout(timer);
worker.off("message", onMessage);
};
worker.on("message", onMessage);
// Worker eval boundary surfaces all sub-evaluation errors through
// the structured reply payload — see WORKER_SOURCE's try/catch.
worker.postMessage(payload);
});
}
function failure<T>(error: string, duration: number): SandboxResult<T> {
return {
success: false,
result: undefined,
error,
duration,
memoryUsed: 0,
logs: [],
};
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,59 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/devtools-tooling-sandbox",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"types/index": "src/types/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"schemas/index": "src/schemas/index.ts",
"telemetry/index": "src/telemetry/index.ts",
"audit-logger/index": "src/audit-logger/index.ts",
"resource-limiter/index": "src/resource-limiter/index.ts",
"module-resolver/index": "src/module-resolver/index.ts",
"code-transformer/index": "src/code-transformer/index.ts",
"vm-engine/index": "src/vm-engine/index.ts",
"worker-engine/index": "src/worker-engine/index.ts",
"spawn-engine/index": "src/spawn-engine/index.ts",
"spawn-engine/runner": "src/spawn-engine/runner.ts",
"spawn-engine/runner-bin": "src/spawn-engine/runner-bin.ts",
"isolate-engine/index": "src/isolate-engine/index.ts",
"pool/index": "src/pool/index.ts",
"result-streamer/index": "src/result-streamer/index.ts",
"sandbox/index": "src/sandbox/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/types/**",
"src/spawn-engine/runner-bin.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
hookTimeout: 10000,
},
});

View File

@@ -0,0 +1,65 @@
# @suaveplan/test-vite - Vite + Vitest configuration factories and validators: createViteLibraryConfig/createViteAppConfig/createVitestConfig/createVitestBrowserConfig, browser-environment shims, tree-shake validator, bundle-size snapshot, port allocator, HMR tester, custom matchers, TEST_VITE_CODES error registry, and OTel self-telemetry.
Vite + Vitest configuration factories and validators for Suaveplan: `createViteLibraryConfig`, `createViteAppConfig`, `createVitestConfig`, `createVitestBrowserConfig`, `setupBrowserEnvironment`, tree-shake validator, bundle-size snapshot, port allocator, HMR tester, custom matchers, `TEST_VITE_CODES` error registry, and OTel self-telemetry.
<!-- begin:auto-docs -->
## Documentation tree
- [browser-environment](./src/browser-environment/browser-environment.md) — `setupBrowserEnvironment` installs the DOM and Web-platform shims that suaveplan browser packages depend on when running…
- [browser-environment](./src/browser-environment/browser-environment.md) — `setupBrowserEnvironment` installs the DOM and Web-platform shims that suaveplan browser packages depend on when running…
- [bundle-size-snapshot](./src/bundle-size-snapshot/bundle-size-snapshot.md) — `snapshotBundleSize` compares the byte size of an emitted bundle against a previously-recorded snapshot file and enforc…
- [bundle-size-snapshot](./src/bundle-size-snapshot/bundle-size-snapshot.md) — `snapshotBundleSize` compares the byte size of an emitted bundle against a previously-recorded snapshot file and enforc…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` exports the canonical stable error-code registry used by every other module in `@suaveplan/test-vite`. Th…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` exports the canonical stable error-code registry used by every other module in `@suaveplan/test-vite`. Th…
- [hmr-tester](./src/hmr-tester/hmr-tester.md) — `withHMR` round-trips a single Vite HMR edit through an injectable `HmrServerLike` adapter and asserts that the expecte…
- [hmr-tester](./src/hmr-tester/hmr-tester.md) — `withHMR` round-trips a single Vite HMR edit through an injectable `HmrServerLike` adapter and asserts that the expecte…
- [matchers](./src/matchers/matchers.md) — `createMatchers` returns a bundle of typed `expect.extend` matchers used across suaveplan browser-tier test suites. The m…
- [matchers](./src/matchers/matchers.md) — `createMatchers` returns a bundle of typed `expect.extend` matchers used across suaveplan browser-tier test suites. The m…
- [port-allocator](./src/port-allocator/port-allocator.md) — `createPortAllocator` builds a deterministic port allocator over a contiguous TCP range backed by an injectable store.…
- [port-allocator](./src/port-allocator/port-allocator.md) — `createPortAllocator` builds a deterministic port allocator over a contiguous TCP range backed by an injectable store.…
- [telemetry](./src/telemetry/telemetry.md) — `telemetry` registers the OTel meter for `@suaveplan/test-vite` and exposes the counters and histogram that every other…
- [telemetry](./src/telemetry/telemetry.md) — `telemetry` registers the OTel meter for `@suaveplan/test-vite` and exposes the counters and histogram that every other…
- [tree-shake-validator](./src/tree-shake-validator/tree-shake-validator.md) — `verifyTreeShake` reads a built bundle and asserts that its set of exported symbols matches the caller-supplied allow-l…
- [tree-shake-validator](./src/tree-shake-validator/tree-shake-validator.md) — `verifyTreeShake` reads a built bundle and asserts that its set of exported symbols matches the caller-supplied allow-l…
- [vite-app-config](./src/vite-app-config/vite-app-config.md) — `createViteAppConfig` builds a Vite `UserConfig` tailored for consumer applications rather than library packages. It de…
- [vite-app-config](./src/vite-app-config/vite-app-config.md) — `createViteAppConfig` builds a Vite `UserConfig` tailored for consumer applications rather than library packages. It de…
- [vite-library-config](./src/vite-library-config/vite-library-config.md) — `createViteLibraryConfig` builds an opinionated Vite `UserConfig` suitable for any `@suaveplan/*` library package. It e…
- [vite-library-config](./src/vite-library-config/vite-library-config.md) — `createViteLibraryConfig` builds an opinionated Vite `UserConfig` suitable for any `@suaveplan/*` library package. It e…
- [vitest-browser-config](./src/vitest-browser-config/vitest-browser-config.md) — `createVitestBrowserConfig` builds a Vitest configuration that drives tests through Playwright in real browsers. The fa…
- [vitest-browser-config](./src/vitest-browser-config/vitest-browser-config.md) — `createVitestBrowserConfig` builds a Vitest configuration that drives tests through Playwright in real browsers. The fa…
- [vitest-config](./src/vitest-config/vitest-config.md) — `createVitestConfig` returns a WSL2-safe Vitest configuration with the suaveplan enterprise contract baked in. The factor…
- [vitest-config](./src/vitest-config/vitest-config.md) — `createVitestConfig` returns a WSL2-safe Vitest configuration with the suaveplan enterprise contract baked in. The factor…
<!-- end:auto-docs -->
## Why
Every browser-tier Suaveplan package needs the same Vite library build + WSL2-safe Vitest configuration. Inlining that copy across hundreds of packages is brittle — when the project learns a new safety default (Istanbul, `pool: 'forks'`, `fileParallelism`, the `external` baseline), each package has to be updated by hand. `@suaveplan/test-vite` owns those factories in one place.
## Features
- Opinionated Vite library build (`createViteLibraryConfig`) with `preserveModules`, dts emission, and the `external` baseline (`/^@suaveplan\//`, `/^node:/`, `'tslib'`).
- Vite consumer-app config (`createViteAppConfig`) with hashed assets and `envPrefix`.
- WSL2-safe Vitest config (`createVitestConfig`) — `pool: 'forks'`, Istanbul provider, 100/100/100/100 thresholds.
- Browser-mode Vitest config (`createVitestBrowserConfig`) driven by Playwright.
- DOM shims (`setupBrowserEnvironment`) — idempotent installer for `localStorage`, `matchMedia`, `ResizeObserver`, `IntersectionObserver`, `crypto`.
- Tree-shake validator (`verifyTreeShake`) — guard the exported-symbol set.
- Bundle-size snapshot (`snapshotBundleSize`) — track build-size drift with tolerance.
- Port allocator (`createPortAllocator`) — coordinate parallel servers on WSL2.
- HMR tester (`withHMR`) — round-trip a Vite HMR cycle in tests.
- Typed `expect.extend` matchers — `toBeAccessibleNode`, `toMatchDataURL`, `toHaveValidCSSVars`.
- Stable `TEST_VITE_CODES` registry + `TestViteError`.
- OTel self-telemetry — counters + histogram, no-op when no provider is registered.
## Install
```ts
import { createViteLibraryConfig } from "@suaveplan/test-vite/vite-library-config";
import { createVitestConfig } from "@suaveplan/test-vite/vitest-config";
```
Every feature module is its own subpath export for tree-shake friendliness.
## License
MIT

View File

@@ -0,0 +1,131 @@
{
"name": "@suaveplan/test-vite",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Vite + Vitest configuration factories and validators: createViteLibraryConfig/createViteAppConfig/createVitestConfig/createVitestBrowserConfig, browser-environment shims, tree-shake validator, bundle-size snapshot, port allocator, HMR tester, custom matchers, TEST_VITE_CODES error registry, and OTel self-telemetry.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./vite-library-config": {
"import": {
"types": "./dist/vite-library-config/index.d.ts",
"default": "./dist/vite-library-config/index.js"
}
},
"./vite-app-config": {
"import": {
"types": "./dist/vite-app-config/index.d.ts",
"default": "./dist/vite-app-config/index.js"
}
},
"./vitest-config": {
"import": {
"types": "./dist/vitest-config/index.d.ts",
"default": "./dist/vitest-config/index.js"
}
},
"./vitest-browser-config": {
"import": {
"types": "./dist/vitest-browser-config/index.d.ts",
"default": "./dist/vitest-browser-config/index.js"
}
},
"./browser-environment": {
"import": {
"types": "./dist/browser-environment/index.d.ts",
"default": "./dist/browser-environment/index.js"
}
},
"./tree-shake-validator": {
"import": {
"types": "./dist/tree-shake-validator/index.d.ts",
"default": "./dist/tree-shake-validator/index.js"
}
},
"./bundle-size-snapshot": {
"import": {
"types": "./dist/bundle-size-snapshot/index.d.ts",
"default": "./dist/bundle-size-snapshot/index.js"
}
},
"./port-allocator": {
"import": {
"types": "./dist/port-allocator/index.d.ts",
"default": "./dist/port-allocator/index.js"
}
},
"./hmr-tester": {
"import": {
"types": "./dist/hmr-tester/index.d.ts",
"default": "./dist/hmr-tester/index.js"
}
},
"./matchers": {
"import": {
"types": "./dist/matchers/index.d.ts",
"default": "./dist/matchers/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "^1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,54 @@
# browser-environment
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`setupBrowserEnvironment` installs the DOM and Web-platform shims that suaveplan browser packages depend on when running under happy-dom, jsdom, or bare Node. The function is idempotent — re-running it on a target that already has real implementations is a no-op — so it is safe to call from package setup files without worrying about clobbering whatever the test runner has already installed.
## Features
- Idempotent installer: real implementations are preserved.
- Per-shim opt-out via `mock*` flags.
- Injectable `target` parameter for unit testing.
- Backed by Node's `webcrypto` for the `crypto` shim — no third-party dependency.
- Memory-backed `localStorage` implementing the standard `Storage` shape.
## Usage: Basic
```ts
import { setupBrowserEnvironment } from "@suaveplan/test-vite/browser-environment";
setupBrowserEnvironment();
```
## Usage: Advanced
Install only the shims your tests need, against a synthetic target:
```ts
import { setupBrowserEnvironment } from "@suaveplan/test-vite/browser-environment";
const target = {} as Record<string, unknown>;
setupBrowserEnvironment({
target,
mockLocalStorage: true,
mockMatchMedia: true,
mockResizeObserver: true,
mockIntersectionObserver: false,
mockCrypto: true,
});
```
## API Reference
- `setupBrowserEnvironment(opts?: BrowserEnvironmentOptions): void`.
- `BrowserEnvironmentOptions` — `{ target?, mockLocalStorage?, mockMatchMedia?, mockResizeObserver?, mockIntersectionObserver?, mockCrypto? }`. All booleans default to `true`.
- Throws `TestViteError` with code `TEST_VITE.INVALID_OPTIONS` on unknown options.
## Implementation Notes
`MemoryStorage` keeps a `Map<string, string>` rather than a plain object so insertion order is preserved across `key(index)` calls. The `matchMedia` stub returns `matches: false` for every query so tests do not accidentally trigger media-query-conditional behaviour without explicit opt-in. Observer stubs implement no-op `observe` / `unobserve` / `disconnect` methods so client code that wires them up does not crash. The crypto shim delegates to Node's standard `webcrypto` import to avoid bundling extra runtime weight.

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { setupBrowserEnvironment } from "./browser-environment.js";
describe("setupBrowserEnvironment", () => {
it("works with no arguments against globalThis", { timeout: 5000 }, () => {
// setup file already installed shims; this exercises the default
// opts + default target branches without throwing.
setupBrowserEnvironment();
expect((globalThis as Record<string, unknown>)["crypto"]).toBeDefined();
});
it("installs missing DOM globals on a fresh target", {
timeout: 5000,
}, () => {
const target: Record<string, unknown> = {};
setupBrowserEnvironment({ target });
expect(target["localStorage"]).toBeDefined();
expect(typeof target["matchMedia"]).toBe("function");
expect(typeof target["ResizeObserver"]).toBe("function");
expect(typeof target["IntersectionObserver"]).toBe("function");
expect(target["crypto"]).toBeDefined();
});
it("respects pre-existing real implementations", { timeout: 5000 }, () => {
const sentinel = () => ({});
const target: Record<string, unknown> = { matchMedia: sentinel };
setupBrowserEnvironment({ target });
expect(target["matchMedia"]).toBe(sentinel);
});
it("opt-out flag suppresses individual shim", { timeout: 5000 }, () => {
const target: Record<string, unknown> = {};
setupBrowserEnvironment({ target, mockLocalStorage: false });
expect(target["localStorage"]).toBeUndefined();
expect(target["matchMedia"]).toBeDefined();
});
it("MemoryStorage supports the Storage contract", { timeout: 5000 }, () => {
const target: Record<string, unknown> = {};
setupBrowserEnvironment({ target });
const storage = target["localStorage"] as Storage;
storage.setItem("a", "1");
storage.setItem("b", "2");
expect(storage.length).toBe(2);
expect(storage.getItem("a")).toBe("1");
expect(storage.getItem("missing")).toBeNull();
expect(storage.key(0)).toBe("a");
expect(storage.key(99)).toBeNull();
storage.removeItem("a");
expect(storage.getItem("a")).toBeNull();
storage.clear();
expect(storage.length).toBe(0);
});
it("matchMedia stub exposes the expected event-target shape", {
timeout: 5000,
}, () => {
const target: Record<string, unknown> = {};
setupBrowserEnvironment({ target });
const mq = (
target["matchMedia"] as (q: string) => {
matches: boolean;
media: string;
addEventListener: () => void;
removeEventListener: () => void;
addListener: () => void;
removeListener: () => void;
onchange: null;
dispatchEvent: () => boolean;
}
)("(min-width: 100px)");
expect(mq.matches).toBe(false);
expect(mq.media).toBe("(min-width: 100px)");
expect(mq.dispatchEvent()).toBe(true);
mq.addEventListener();
mq.removeEventListener();
mq.addListener();
mq.removeListener();
expect(mq.onchange).toBeNull();
});
it("observer stubs are callable", { timeout: 5000 }, () => {
const target: Record<string, unknown> = {};
setupBrowserEnvironment({ target });
const ResizeCtor = target["ResizeObserver"] as new () => {
observe: () => void;
unobserve: () => void;
disconnect: () => void;
};
const ro = new ResizeCtor();
ro.observe();
ro.unobserve();
ro.disconnect();
const IntersectionCtor = target["IntersectionObserver"] as new () => {
observe: () => void;
unobserve: () => void;
disconnect: () => void;
takeRecords: () => readonly unknown[];
root: null;
rootMargin: string;
thresholds: readonly number[];
};
const io = new IntersectionCtor();
io.observe();
io.unobserve();
io.disconnect();
expect(io.takeRecords()).toEqual([]);
expect(io.root).toBeNull();
expect(io.rootMargin).toBe("");
expect(io.thresholds).toEqual([]);
});
it("skips crypto when mockCrypto is false", { timeout: 5000 }, () => {
const target: Record<string, unknown> = {};
setupBrowserEnvironment({ target, mockCrypto: false });
expect(target["crypto"]).toBeUndefined();
});
it("skips ResizeObserver and IntersectionObserver via flags", {
timeout: 5000,
}, () => {
const target: Record<string, unknown> = {};
setupBrowserEnvironment({
target,
mockResizeObserver: false,
mockIntersectionObserver: false,
mockMatchMedia: false,
});
expect(target["ResizeObserver"]).toBeUndefined();
expect(target["IntersectionObserver"]).toBeUndefined();
expect(target["matchMedia"]).toBeUndefined();
});
it("throws INVALID_OPTIONS on unknown options", { timeout: 5000 }, () => {
let captured: unknown;
try {
setupBrowserEnvironment({
bogus: true,
} as unknown as Parameters<typeof setupBrowserEnvironment>[0]);
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
});

View File

@@ -0,0 +1,136 @@
import { webcrypto } from "node:crypto";
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
const BrowserEnvironmentOptionsSchema = z
.object({
target: z.unknown().optional(),
mockLocalStorage: z.boolean().default(true),
mockMatchMedia: z.boolean().default(true),
mockResizeObserver: z.boolean().default(true),
mockIntersectionObserver: z.boolean().default(true),
mockCrypto: z.boolean().default(true),
})
.strict();
export type BrowserEnvironmentOptions = z.input<
typeof BrowserEnvironmentOptionsSchema
>;
type TargetRecord = Record<string, unknown>;
class MemoryStorage {
private readonly store = new Map<string, string>();
public get length(): number {
return this.store.size;
}
public clear(): void {
this.store.clear();
}
public getItem(key: string): string | null {
return this.store.has(key) ? (this.store.get(key) as string) : null;
}
public key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null;
}
public removeItem(key: string): void {
this.store.delete(key);
}
public setItem(key: string, value: string): void {
this.store.set(key, String(value));
}
}
function fakeMatchMedia(query: string): {
matches: boolean;
media: string;
addEventListener: () => void;
removeEventListener: () => void;
addListener: () => void;
removeListener: () => void;
onchange: null;
dispatchEvent: () => boolean;
} {
return {
matches: false,
media: query,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
onchange: null,
dispatchEvent: () => true,
};
}
class FakeResizeObserver {
public observe(): void {
/* noop */
}
public unobserve(): void {
/* noop */
}
public disconnect(): void {
/* noop */
}
}
class FakeIntersectionObserver {
public readonly root: null = null;
public readonly rootMargin: string = "";
public readonly thresholds: readonly number[] = [];
public observe(): void {
/* noop */
}
public unobserve(): void {
/* noop */
}
public disconnect(): void {
/* noop */
}
public takeRecords(): readonly unknown[] {
return [];
}
}
/**
* Install DOM environment shims on `target` (defaults to `globalThis`).
*
* Each shim is installed only when absent on the target — re-running the
* function on an already-set-up target is a no-op. Individual shims can
* be skipped with the `mock*: false` flags.
*
* @throws {TestViteError} with `TEST_VITE.INVALID_OPTIONS` on bad input.
*/
export function setupBrowserEnvironment(
opts: BrowserEnvironmentOptions = {}
): void {
const parsed = BrowserEnvironmentOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"setupBrowserEnvironment: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
const target = (o.target ?? globalThis) as TargetRecord;
if (o.mockLocalStorage && target["localStorage"] === undefined) {
target["localStorage"] = new MemoryStorage();
}
if (o.mockMatchMedia && target["matchMedia"] === undefined) {
target["matchMedia"] = fakeMatchMedia;
}
if (o.mockResizeObserver && target["ResizeObserver"] === undefined) {
target["ResizeObserver"] = FakeResizeObserver;
}
if (
o.mockIntersectionObserver &&
target["IntersectionObserver"] === undefined
) {
target["IntersectionObserver"] = FakeIntersectionObserver;
}
if (o.mockCrypto && target["crypto"] === undefined) {
target["crypto"] = webcrypto;
}
}

View File

@@ -0,0 +1,2 @@
export type { BrowserEnvironmentOptions } from "./browser-environment.js";
export { setupBrowserEnvironment } from "./browser-environment.js";

View File

@@ -0,0 +1,60 @@
# bundle-size-snapshot
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`snapshotBundleSize` compares the byte size of an emitted bundle against a previously-recorded snapshot file and enforces a configurable growth tolerance. It is the bundle-size equivalent of a Vitest snapshot — when the bundle grows past tolerance the call throws `TestViteError(TEST_VITE.SIZE_BUDGET_EXCEEDED)` so CI fails with a clear diagnostic. Run with `update: true` from a developer machine to refresh the snapshot after an intentional change.
## Features
- Default 5% growth tolerance (`tolerance: 0.05`).
- `update: true` mode writes the current size to the snapshot file.
- Injectable filesystem adapter so the validator can run in environments without `node:fs`.
- ENOENT-aware error mapping: missing snapshot → `SNAPSHOT_MISSING`; missing bundle → `MISSING_ENTRY`.
- OTel counters: `test_vite_validator_runs_total{kind="bundle_size"}` per call, `test_vite_validator_violations_total` on budget violations.
## Usage: Basic
```ts
import { readFile, writeFile } from "node:fs/promises";
import { snapshotBundleSize } from "@suaveplan/test-vite/bundle-size-snapshot";
const fs = {
readFile: (p: string) => readFile(p),
writeFile: (p: string, data: string) => writeFile(p, data, "utf8"),
};
await snapshotBundleSize({
entry: "dist/index.js",
snapshot: "dist/index.size.snap",
fs,
});
```
## Usage: Advanced
Refresh the snapshot after a deliberate change:
```ts
await snapshotBundleSize({
entry: "dist/index.js",
snapshot: "dist/index.size.snap",
tolerance: 0.02,
update: true,
fs,
});
```
## API Reference
- `snapshotBundleSize(opts: SnapshotBundleSizeOptions): Promise<BundleSizeResult>`.
- `SnapshotBundleSizeOptions` — `{ entry, snapshot, tolerance?, update?, fs }`.
- `BundleSizeResult` — `{ updated: boolean, size: number, delta?: number }`.
- Throws `TestViteError` with `TEST_VITE.INVALID_OPTIONS`, `TEST_VITE.MISSING_ENTRY`, `TEST_VITE.SNAPSHOT_MISSING`, or `TEST_VITE.SIZE_BUDGET_EXCEEDED`.
## Implementation Notes
The snapshot file stores a single decimal integer — the byte count of the last accepted bundle. Whitespace is trimmed before parsing so editors that auto-add a trailing newline do not break the comparison. Read failures on the snapshot are uniformly classified as `SNAPSHOT_MISSING` whether the cause is ENOENT or another IO error so the diagnostic stays consistent across platforms.

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import type { FsAdapter } from "../types/test-vite.types.js";
import { snapshotBundleSize } from "./bundle-size-snapshot.js";
function makeFs(seed: Record<string, Uint8Array> = {}): FsAdapter & {
written: Map<string, string>;
} {
const store = new Map<string, Uint8Array>(Object.entries(seed));
const written = new Map<string, string>();
return {
async readFile(path) {
const v = store.get(path);
if (v === undefined) {
const err = new Error("ENOENT") as Error & { code: string };
err.code = "ENOENT";
throw err;
}
return v;
},
async writeFile(path, data) {
written.set(path, data);
store.set(path, new TextEncoder().encode(data));
},
written,
};
}
describe("snapshotBundleSize", () => {
it("returns delta within tolerance", { timeout: 5000 }, async () => {
const fs = makeFs({
"bundle.js": new Uint8Array(1050),
"bundle.snap": new TextEncoder().encode("1000"),
});
const result = await snapshotBundleSize({
entry: "bundle.js",
snapshot: "bundle.snap",
fs,
});
expect(result.updated).toBe(false);
expect(result.size).toBe(1050);
expect(result.delta).toBeCloseTo(0.05, 5);
});
it("throws SIZE_BUDGET_EXCEEDED past tolerance", {
timeout: 5000,
}, async () => {
const fs = makeFs({
"bundle.js": new Uint8Array(1100),
"bundle.snap": new TextEncoder().encode("1000"),
});
let captured: unknown;
try {
await snapshotBundleSize({
entry: "bundle.js",
snapshot: "bundle.snap",
tolerance: 0.05,
fs,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.SIZE_BUDGET_EXCEEDED
);
});
it("writes snapshot under update mode", { timeout: 5000 }, async () => {
const fs = makeFs({ "bundle.js": new Uint8Array(700) });
const result = await snapshotBundleSize({
entry: "bundle.js",
snapshot: "bundle.snap",
update: true,
fs,
});
expect(result.updated).toBe(true);
expect(result.size).toBe(700);
expect(fs.written.get("bundle.snap")).toBe("700");
});
it("throws SNAPSHOT_MISSING without update", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "bundle.js": new Uint8Array(1000) });
let captured: unknown;
try {
await snapshotBundleSize({
entry: "bundle.js",
snapshot: "bundle.snap",
fs,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.SNAPSHOT_MISSING
);
});
it("throws MISSING_ENTRY for absent bundle", {
timeout: 5000,
}, async () => {
const fs = makeFs({});
let captured: unknown;
try {
await snapshotBundleSize({
entry: "bundle.js",
snapshot: "bundle.snap",
fs,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.MISSING_ENTRY
);
});
it("throws SNAPSHOT_MISSING for unparseable snapshot contents", {
timeout: 5000,
}, async () => {
const fs = makeFs({
"bundle.js": new Uint8Array(100),
"bundle.snap": new TextEncoder().encode("not-a-number"),
});
let captured: unknown;
try {
await snapshotBundleSize({
entry: "bundle.js",
snapshot: "bundle.snap",
fs,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.SNAPSHOT_MISSING
);
});
it("wraps non-ENOENT read failures as SNAPSHOT_MISSING", {
timeout: 5000,
}, async () => {
const fs: FsAdapter = {
async readFile(path) {
if (path === "bundle.js") {
return new Uint8Array(50);
}
throw new Error("EACCES");
},
async writeFile() {
/* noop */
},
};
let captured: unknown;
try {
await snapshotBundleSize({
entry: "bundle.js",
snapshot: "bundle.snap",
fs,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.SNAPSHOT_MISSING
);
});
it("wraps non-object read errors as SNAPSHOT_MISSING", {
timeout: 5000,
}, async () => {
const fs: FsAdapter = {
async readFile(path) {
if (path === "bundle.js") {
return new Uint8Array(50);
}
const literal: unknown = "string failure";
throw literal;
},
async writeFile() {
/* noop */
},
};
let captured: unknown;
try {
await snapshotBundleSize({
entry: "bundle.js",
snapshot: "bundle.snap",
fs,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.SNAPSHOT_MISSING
);
});
it("throws INVALID_OPTIONS on missing fs", { timeout: 5000 }, async () => {
let captured: unknown;
try {
await snapshotBundleSize({
entry: "x",
snapshot: "y",
fs: undefined as unknown as FsAdapter,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
});

View File

@@ -0,0 +1,107 @@
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import {
recordValidatorRun,
recordValidatorViolation,
} from "../telemetry/telemetry.js";
import type { BundleSizeResult, FsAdapter } from "../types/test-vite.types.js";
const SnapshotBundleSizeOptionsSchema = z
.object({
entry: z.string().min(1),
snapshot: z.string().min(1),
tolerance: z.number().nonnegative().default(0.05),
update: z.boolean().default(false),
fs: z.custom<FsAdapter>(
(value): value is FsAdapter =>
typeof value === "object" &&
value !== null &&
typeof (value as FsAdapter).readFile === "function" &&
typeof (value as FsAdapter).writeFile === "function",
{ message: "fs must implement readFile + writeFile" }
),
})
.strict();
export type SnapshotBundleSizeOptions = z.input<
typeof SnapshotBundleSizeOptionsSchema
>;
function isEnoent(err: unknown): boolean {
if (typeof err !== "object" || err === null) {
return false;
}
const code = (err as { code?: unknown }).code;
return code === "ENOENT";
}
/**
* Compare an emitted bundle's byte length against a stored snapshot file.
*/
export async function snapshotBundleSize(
opts: SnapshotBundleSizeOptions
): Promise<BundleSizeResult> {
const parsed = SnapshotBundleSizeOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"snapshotBundleSize: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
recordValidatorRun("bundle_size");
let bundle: Uint8Array;
try {
bundle = await o.fs.readFile(o.entry);
} catch (cause) {
throw new TestViteError(
TEST_VITE_CODES.MISSING_ENTRY,
"snapshotBundleSize: cannot read bundle entry",
{ cause, details: { entry: o.entry } }
);
}
const size = bundle.byteLength;
if (o.update) {
await o.fs.writeFile(o.snapshot, String(size));
return { updated: true, size };
}
let snapshotBytes: Uint8Array;
try {
snapshotBytes = await o.fs.readFile(o.snapshot);
} catch (cause) {
if (isEnoent(cause)) {
throw new TestViteError(
TEST_VITE_CODES.SNAPSHOT_MISSING,
"snapshotBundleSize: snapshot file missing",
{ cause, details: { snapshot: o.snapshot } }
);
}
throw new TestViteError(
TEST_VITE_CODES.SNAPSHOT_MISSING,
"snapshotBundleSize: snapshot unreadable",
{ cause, details: { snapshot: o.snapshot } }
);
}
const snapshotText = new TextDecoder().decode(snapshotBytes).trim();
const previous = Number(snapshotText);
if (!Number.isFinite(previous) || previous <= 0) {
throw new TestViteError(
TEST_VITE_CODES.SNAPSHOT_MISSING,
"snapshotBundleSize: snapshot contents not a positive number",
{ details: { snapshot: o.snapshot, contents: snapshotText } }
);
}
const delta = (size - previous) / previous;
if (delta > o.tolerance) {
recordValidatorViolation("bundle_size");
throw new TestViteError(
TEST_VITE_CODES.SIZE_BUDGET_EXCEEDED,
"snapshotBundleSize: bundle grew past tolerance",
{ details: { size, previous, delta, tolerance: o.tolerance } }
);
}
return { updated: false, size, delta };
}

View File

@@ -0,0 +1,2 @@
export type { SnapshotBundleSizeOptions } from "./bundle-size-snapshot.js";
export { snapshotBundleSize } from "./bundle-size-snapshot.js";

View File

@@ -0,0 +1,64 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes` exports the canonical stable error-code registry used by every other module in `@suaveplan/test-vite`. The registry is built with `@suaveplan/error`'s `defineCodes` so each code value becomes a namespaced string of the form `TEST_VITE.<REASON>`. This namespace survives module boundaries, structured cloning, RPC boundaries, and JSON serialisation, so callers can discriminate on `error.code` rather than relying on prototype chains. The companion `TestViteError` class extends `SuaveplanError<TestViteCode>` so static analysis narrows `err.code` to the literal union of registered codes.
## Features
- 8-code namespaced registry: `INVALID_OPTIONS`, `MISSING_ENTRY`, `TREE_SHAKE_VIOLATION`, `SIZE_BUDGET_EXCEEDED`, `SNAPSHOT_MISSING`, `PORT_EXHAUSTED`, `PORT_NOT_HELD`, `HMR_TIMEOUT`.
- `TestViteError` class extending `SuaveplanError<TestViteCode>` with a public constructor.
- Structured `details` payload propagated as the `SuaveplanError` `context` field.
- Cause-chain support via the standard `Error.cause` constructor option.
- No marketing language — each code names a concrete, reachable failure mode.
## Usage: Basic
```ts
import { TEST_VITE_CODES, TestViteError } from "@suaveplan/test-vite/error-codes";
try {
doSomething();
} catch (err) {
if (err instanceof TestViteError && err.code === TEST_VITE_CODES.INVALID_OPTIONS) {
console.error("bad options", err.details);
}
}
```
## Usage: Advanced
Discriminate on the namespaced code value directly so the handler survives RPC and serialisation:
```ts
import { TEST_VITE_CODES } from "@suaveplan/test-vite/error-codes";
function isTestViteError(err: unknown): err is { code: string; details: Record<string, unknown> } {
return typeof err === "object" && err !== null && typeof (err as { code?: unknown }).code === "string";
}
function classify(err: unknown): "retry" | "abort" {
if (!isTestViteError(err)) return "abort";
switch (err.code) {
case TEST_VITE_CODES.PORT_EXHAUSTED:
case TEST_VITE_CODES.HMR_TIMEOUT:
return "retry";
default:
return "abort";
}
}
```
## API Reference
- `TEST_VITE_CODES` — `Readonly<Record<string, TestViteCode>>` built via `defineCodes("TEST_VITE", [...])`.
- `TestViteCode` — string literal union of every registered code.
- `TestViteError extends SuaveplanError<TestViteCode>` — `new TestViteError(code, message, { cause?, details? })`. Exposes `code: TestViteCode`, `message: string`, `details: Record<string, unknown>`, and inherits `timestamp`, `name`, `cause` from `SuaveplanError` / `Error`.
## Implementation Notes
The constructor passes the `details` payload to `SuaveplanError` as `context` so the OTel span-event adapter picks it up automatically. We mirror the payload on a public `details` field for ergonomic access from non-TypeScript consumers. `cause` is forwarded only when supplied so the standard `Error.prototype.cause` behaviour stays clean.

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "./error-codes.js";
describe("TEST_VITE_CODES", () => {
it("every code is namespaced under TEST_VITE.", { timeout: 5000 }, () => {
for (const code of Object.values(TEST_VITE_CODES)) {
expect(code.startsWith("TEST_VITE.")).toBe(true);
}
});
it("TestViteError carries supplied code and details", {
timeout: 5000,
}, () => {
const err = new TestViteError(TEST_VITE_CODES.INVALID_OPTIONS, "bad", {
details: { foo: 1 },
});
expect(err.code).toBe(TEST_VITE_CODES.INVALID_OPTIONS);
expect(err.details["foo"]).toBe(1);
expect(err.name).toBe("TestViteError");
expect(err.message).toBe("bad");
});
it("TestViteError defaults details to empty object", {
timeout: 5000,
}, () => {
const err = new TestViteError(
TEST_VITE_CODES.PORT_EXHAUSTED,
"no ports"
);
expect(err.details).toEqual({});
});
it("TestViteError accepts cause", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new TestViteError(
TEST_VITE_CODES.MISSING_ENTRY,
"no file",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,51 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/test-vite`.
*
* Codes follow `TEST_VITE.<REASON>` and are produced by
* `@suaveplan/error`'s `defineCodes` so the namespace survives module
* boundaries and runtime serialisation. Consumers SHOULD discriminate on
* `error.code` rather than `instanceof` so the discriminator works across
* worker, RPC, and serialised-error boundaries.
*/
export const TEST_VITE_CODES = defineCodes("TEST_VITE", [
"INVALID_OPTIONS",
"MISSING_ENTRY",
"TREE_SHAKE_VIOLATION",
"SIZE_BUDGET_EXCEEDED",
"SNAPSHOT_MISSING",
"PORT_EXHAUSTED",
"PORT_NOT_HELD",
"HMR_TIMEOUT",
] as const);
export type TestViteCode =
(typeof TEST_VITE_CODES)[keyof typeof TEST_VITE_CODES];
/**
* Typed error class for every `@suaveplan/test-vite` failure path.
*
* Carries the registered code, a human-readable message, and an optional
* structured `details` payload through the `SuaveplanError` `context`
* field so callers can drive diagnostics without re-parsing the message.
*/
export class TestViteError extends SuaveplanError<TestViteCode> {
public readonly details: Record<string, unknown>;
public constructor(
code: TestViteCode,
message: string,
options?: {
cause?: unknown;
details?: Record<string, unknown>;
}
) {
super(code, message, {
...(options?.cause === undefined ? {} : { cause: options.cause }),
context: options?.details ?? {},
});
this.name = "TestViteError";
this.details = options?.details ?? {};
}
}

View File

@@ -0,0 +1,2 @@
export type { TestViteCode } from "./error-codes.js";
export { TEST_VITE_CODES, TestViteError } from "./error-codes.js";

View File

@@ -0,0 +1,65 @@
# hmr-tester
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`withHMR` round-trips a single Vite HMR edit through an injectable `HmrServerLike` adapter and asserts that the expected events fire in order. It is designed for tests that exercise HMR-driven UI logic without spinning up a real Vite dev server — the adapter can wrap an in-memory event bus, a Playwright page, or a real `ws` connection depending on the testing strategy.
## Features
- Injectable server adapter (`send` + `onEvent`).
- Ordered event matching — events fire in the order specified in `expectEvents`.
- Configurable `timeoutMs` with `TestViteError(TEST_VITE.HMR_TIMEOUT)` on expiry.
- Injectable `Clock` for deterministic duration accounting.
- OTel `test_vite_hmr_round_trip_ms` histogram recorded on every resolution.
## Usage: Basic
```ts
import { withHMR } from "@suaveplan/test-vite/hmr-tester";
const events = await withHMR({
server: viteServerAdapter,
edit: { file: "src/widget.ts", content: "..." },
expectEvents: ["vite:beforeUpdate", "vite:afterUpdate"],
timeoutMs: 5000,
});
```
## Usage: Advanced
Drive a synthetic server in tests:
```ts
import { withHMR } from "@suaveplan/test-vite/hmr-tester";
let listener: ((event: string) => void) | undefined;
const server = {
send: () => setTimeout(() => listener?.("vite:afterUpdate"), 1),
onEvent: (cb: (event: string) => void) => {
listener = cb;
return () => { listener = undefined; };
},
};
await withHMR({
server,
edit: { file: "a.ts", content: "1" },
expectEvents: ["vite:afterUpdate"],
timeoutMs: 100,
clock: () => Date.now(),
});
```
## API Reference
- `withHMR(opts: WithHmrOptions): Promise<readonly string[]>`.
- `WithHmrOptions` — `{ server, edit: { file, content }, expectEvents, timeoutMs?, clock? }`.
- Throws `TestViteError` with `TEST_VITE.INVALID_OPTIONS` or `TEST_VITE.HMR_TIMEOUT`.
## Implementation Notes
`withHMR` matches expected events in order — non-matching events are captured but do not advance the cursor. Once the final expected event fires the promise resolves with the complete capture array, including any non-matching events that arrived along the way. The timer is cleared and the `onEvent` disposer invoked before resolution so the caller does not have to wire teardown. Test consumers can swap the clock to record deterministic durations and exercise edge cases without real wall-clock delays.

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import type { HmrServerLike } from "../types/test-vite.types.js";
import { withHMR } from "./hmr-tester.js";
function makeServer(emit: readonly string[], delayMs = 1): HmrServerLike {
let listener: ((event: string) => void) | undefined;
return {
send: () => {
for (const [index, event] of emit.entries()) {
setTimeout(() => listener?.(event), delayMs * (index + 1));
}
},
onEvent: (cb) => {
listener = cb;
return () => {
listener = undefined;
};
},
};
}
describe("withHMR", () => {
it("resolves when all expected events fire", {
timeout: 5000,
}, async () => {
const events = await withHMR({
server: makeServer(["vite:beforeUpdate", "vite:afterUpdate"]),
edit: { file: "a.ts", content: "1" },
expectEvents: ["vite:beforeUpdate", "vite:afterUpdate"],
timeoutMs: 200,
});
expect(events).toEqual(["vite:beforeUpdate", "vite:afterUpdate"]);
});
it("throws HMR_TIMEOUT on missing events", { timeout: 5000 }, async () => {
let captured: unknown;
try {
await withHMR({
server: makeServer(["vite:beforeUpdate"]),
edit: { file: "a.ts", content: "1" },
expectEvents: ["vite:beforeUpdate", "vite:afterUpdate"],
timeoutMs: 50,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.HMR_TIMEOUT
);
});
it("uses injected clock for duration", { timeout: 5000 }, async () => {
let now = 0;
const events = await withHMR({
server: makeServer(["done"]),
edit: { file: "a.ts", content: "1" },
expectEvents: ["done"],
timeoutMs: 1000,
clock: () => {
now += 25;
return now;
},
});
expect(events).toEqual(["done"]);
});
it("throws INVALID_OPTIONS for empty expectEvents", {
timeout: 5000,
}, async () => {
let captured: unknown;
try {
await withHMR({
server: makeServer([]),
edit: { file: "a.ts", content: "1" },
expectEvents: [],
timeoutMs: 50,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
it("ignores out-of-order non-matching events", {
timeout: 5000,
}, async () => {
const events = await withHMR({
server: makeServer(["noise", "wanted"]),
edit: { file: "a.ts", content: "1" },
expectEvents: ["wanted"],
timeoutMs: 200,
});
expect(events).toEqual(["noise", "wanted"]);
});
});

View File

@@ -0,0 +1,108 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { recordHmrRoundTrip } from "../telemetry/telemetry.js";
import type { HmrServerLike } from "../types/test-vite.types.js";
const HmrEditSchema = z.object({
file: z.string().min(1),
content: z.string(),
});
const WithHmrOptionsSchema = z
.object({
server: z.custom<HmrServerLike>(
(value): value is HmrServerLike =>
typeof value === "object" &&
value !== null &&
typeof (value as HmrServerLike).send === "function" &&
typeof (value as HmrServerLike).onEvent === "function",
{ message: "server must implement HmrServerLike" }
),
edit: HmrEditSchema,
expectEvents: z.array(z.string().min(1)).min(1),
timeoutMs: z.number().int().positive().default(5000),
clock: z
.custom<Clock>((value) => typeof value === "function", {
message: "clock must be a function",
})
.optional(),
})
.strict();
export type WithHmrOptions = z.input<typeof WithHmrOptionsSchema>;
const defaultClock: Clock = () => Date.now();
/**
* Round-trip a single HMR edit and assert the named events fire in order.
*
* Returns the captured-event array on success. Resolves with the
* complete ordered event list as observed from `server.onEvent`. Throws
* `TestViteError(TEST_VITE.HMR_TIMEOUT)` when `timeoutMs` elapses before
* every event has been observed.
*/
export async function withHMR(
opts: WithHmrOptions
): Promise<readonly string[]> {
const parsed = WithHmrOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"withHMR: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
const clock = o.clock ?? defaultClock;
const start = clock();
const captured: string[] = [];
let cursor = 0;
return await new Promise<readonly string[]>((resolve, reject) => {
const timer = setTimeout(() => {
finish({
ok: false,
error: new TestViteError(
TEST_VITE_CODES.HMR_TIMEOUT,
"withHMR: timed out waiting for expected events",
{
details: {
captured,
expectEvents: o.expectEvents,
timeoutMs: o.timeoutMs,
},
}
),
});
}, o.timeoutMs);
const dispose = o.server.onEvent((event) => {
captured.push(event);
const expected = o.expectEvents[cursor];
if (expected !== undefined && event === expected) {
cursor += 1;
if (cursor === o.expectEvents.length) {
finish({ ok: true, events: captured });
}
}
});
function finish(
outcome:
| { ok: true; events: readonly string[] }
| { ok: false; error: TestViteError }
): void {
clearTimeout(timer);
dispose();
if (outcome.ok) {
recordHmrRoundTrip(clock() - start);
resolve(outcome.events);
} else {
reject(outcome.error);
}
}
o.server.send(o.edit);
});
}

View File

@@ -0,0 +1,2 @@
export type { WithHmrOptions } from "./hmr-tester.js";
export { withHMR } from "./hmr-tester.js";

View File

@@ -0,0 +1,52 @@
export type { BrowserEnvironmentOptions } from "./browser-environment/browser-environment.js";
export { setupBrowserEnvironment } from "./browser-environment/browser-environment.js";
export type { SnapshotBundleSizeOptions } from "./bundle-size-snapshot/bundle-size-snapshot.js";
export { snapshotBundleSize } from "./bundle-size-snapshot/bundle-size-snapshot.js";
export type { TestViteCode } from "./error-codes/error-codes.js";
export { TEST_VITE_CODES, TestViteError } from "./error-codes/error-codes.js";
export type { WithHmrOptions } from "./hmr-tester/hmr-tester.js";
export { withHMR } from "./hmr-tester/hmr-tester.js";
export type { TestViteMatchers } from "./matchers/matchers.js";
export { createMatchers } from "./matchers/matchers.js";
export type { PortAllocatorOptions } from "./port-allocator/port-allocator.js";
export { createPortAllocator } from "./port-allocator/port-allocator.js";
export type {
TestViteConfigKind,
TestViteValidatorKind,
} from "./telemetry/telemetry.js";
export {
configBuiltCounter,
hmrRoundTripHistogram,
portAllocationsCounter,
recordConfigBuilt,
recordHmrRoundTrip,
recordPortAllocation,
recordValidatorRun,
recordValidatorViolation,
validatorRunsCounter,
validatorViolationsCounter,
} from "./telemetry/telemetry.js";
export type { VerifyTreeShakeOptions } from "./tree-shake-validator/tree-shake-validator.js";
export { verifyTreeShake } from "./tree-shake-validator/tree-shake-validator.js";
export type {
AccessibleNode,
BundleSizeResult,
CssVarHost,
FsAdapter,
HmrServerLike,
MatcherResult,
PortAllocator,
PortStore,
ReadFileFn,
TreeShakeDrift,
TreeShakeOk,
TreeShakeResult,
} from "./types/test-vite.types.js";
export type { ViteAppConfigOptions } from "./vite-app-config/vite-app-config.js";
export { createViteAppConfig } from "./vite-app-config/vite-app-config.js";
export type { ViteLibraryConfigOptions } from "./vite-library-config/vite-library-config.js";
export { createViteLibraryConfig } from "./vite-library-config/vite-library-config.js";
export type { VitestBrowserConfigOptions } from "./vitest-browser-config/vitest-browser-config.js";
export { createVitestBrowserConfig } from "./vitest-browser-config/vitest-browser-config.js";
export type { VitestConfigOptions } from "./vitest-config/vitest-config.js";
export { createVitestConfig } from "./vitest-config/vitest-config.js";

View File

@@ -0,0 +1,2 @@
export type { TestViteMatchers } from "./matchers.js";
export { createMatchers } from "./matchers.js";

View File

@@ -0,0 +1,61 @@
# matchers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createMatchers` returns a bundle of typed `expect.extend` matchers used across suaveplan browser-tier test suites. The matchers cover three recurring assertions that come up often enough that hand-rolled equivalents drift across packages: accessibility hooks on rendered nodes, base64-encoded data URLs with a mime guard, and CSS-custom-property availability on a host element.
## Features
- `toBeAccessibleNode(node)` — passes when the node exposes `role`, `aria-label`, or `aria-labelledby` as a non-empty string.
- `toMatchDataURL(value, mimePattern)` — passes when the value is a `data:<mime>;base64,<payload>` URL and `mime` matches the supplied regex.
- `toHaveValidCSSVars(host, names)` — passes when every name in `names` is set on `host.style` as a non-empty string.
- Each matcher returns the Vitest contract `{ pass, message }` so failures surface descriptive diagnostics.
- Universal — works in node + jsdom + happy-dom without DOM lookups.
## Usage: Basic
```ts
import { expect } from "vitest";
import { createMatchers } from "@suaveplan/test-vite/matchers";
expect.extend(createMatchers());
const button = { role: "button", "aria-label": "Close" };
expect(button).toBeAccessibleNode();
```
## Usage: Advanced
Custom matchers in a server-rendered snapshot test:
```ts
import { createMatchers } from "@suaveplan/test-vite/matchers";
const matchers = createMatchers();
const widget = renderToObject();
const accessibility = matchers.toBeAccessibleNode(widget);
if (!accessibility.pass) {
throw new Error(accessibility.message());
}
const cssVars = matchers.toHaveValidCSSVars(widget, ["--brand", "--accent"]);
if (!cssVars.pass) {
throw new Error(cssVars.message());
}
```
## API Reference
- `createMatchers(): TestViteMatchers`.
- `TestViteMatchers.toBeAccessibleNode(received): MatcherResult`.
- `TestViteMatchers.toMatchDataURL(received, mimePattern): MatcherResult`.
- `TestViteMatchers.toHaveValidCSSVars(received, names): MatcherResult`.
## Implementation Notes
The data-URL parser uses a strict regex that allows only base64 payload characters (`A-Z`, `a-z`, `0-9`, `+`, `/`, `=`); URL-encoded data URLs are intentionally rejected because base64 is the only payload format suaveplan components emit. The CSS-vars matcher inspects the host's `style` record directly rather than calling `getComputedStyle`, so it works on both real DOM nodes and synthetic objects.

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMatchers } from "./matchers.js";
const matchers = createMatchers();
describe("createMatchers", () => {
it("toBeAccessibleNode passes when role is set", { timeout: 5000 }, () => {
const result = matchers.toBeAccessibleNode({ role: "button" });
expect(result.pass).toBe(true);
});
it("toBeAccessibleNode passes when aria-label is set", {
timeout: 5000,
}, () => {
const result = matchers.toBeAccessibleNode({
"aria-label": "Close",
});
expect(result.pass).toBe(true);
});
it("toBeAccessibleNode passes when aria-labelledby is set", {
timeout: 5000,
}, () => {
const result = matchers.toBeAccessibleNode({
"aria-labelledby": "x",
});
expect(result.pass).toBe(true);
});
it("toMatchDataURL matches mime regex", { timeout: 5000 }, () => {
const result = matchers.toMatchDataURL(
"data:image/png;base64,AAA=",
/image\//
);
expect(result.pass).toBe(true);
});
it("toMatchDataURL fails when mime does not match", {
timeout: 5000,
}, () => {
const result = matchers.toMatchDataURL(
"data:text/plain;base64,AAA=",
/image\//
);
expect(result.pass).toBe(false);
expect(result.message()).toContain("text/plain");
});
it("toMatchDataURL fails on malformed string", { timeout: 5000 }, () => {
const result = matchers.toMatchDataURL("not a data url", /image\//);
expect(result.pass).toBe(false);
});
it("toMatchDataURL fails on non-string input", { timeout: 5000 }, () => {
const result = matchers.toMatchDataURL(123, /image\//);
expect(result.pass).toBe(false);
});
it("toHaveValidCSSVars validates every name", { timeout: 5000 }, () => {
const result = matchers.toHaveValidCSSVars(
{ style: { "--brand": "#000", "--accent": "#fff" } },
["--brand", "--accent"]
);
expect(result.pass).toBe(true);
});
it("toHaveValidCSSVars fails for an empty var", { timeout: 5000 }, () => {
const result = matchers.toHaveValidCSSVars(
{ style: { "--brand": "" } },
["--brand"]
);
expect(result.pass).toBe(false);
expect(result.message()).toContain("--brand");
});
it("toHaveValidCSSVars fails for missing style", { timeout: 5000 }, () => {
const result = matchers.toHaveValidCSSVars({}, ["--brand"]);
expect(result.pass).toBe(false);
});
it("failing matchers report descriptive messages", {
timeout: 5000,
}, () => {
const result = matchers.toBeAccessibleNode({});
expect(result.pass).toBe(false);
expect(result.message().length).toBeGreaterThan(0);
});
it("toBeAccessibleNode rejects non-objects", { timeout: 5000 }, () => {
const result = matchers.toBeAccessibleNode(null);
expect(result.pass).toBe(false);
expect(result.message()).toBe(
"expected node to expose role / aria-label / aria-labelledby"
);
});
it("calls passing-message thunks for coverage", { timeout: 5000 }, () => {
const accessible = matchers.toBeAccessibleNode({ role: "button" });
expect(accessible.message()).toBe("node is accessible");
const url = matchers.toMatchDataURL(
"data:image/png;base64,AAA=",
/image\//
);
expect(url.message()).toBe("data URL matches mime");
const css = matchers.toHaveValidCSSVars({ style: { "--x": "1" } }, [
"--x",
]);
expect(css.message()).toBe("every CSS var resolves");
const nonString = matchers.toMatchDataURL(123, /image\//);
expect(nonString.message()).toBe("expected received to be a string");
const malformed = matchers.toMatchDataURL("not a data url", /image\//);
expect(malformed.message()).toBe(
"expected received to match data:<mime>;base64,<payload>"
);
const noStyle = matchers.toHaveValidCSSVars({}, ["--x"]);
expect(noStyle.message()).toBe(
"expected received to have a .style record"
);
});
});

View File

@@ -0,0 +1,106 @@
import type {
AccessibleNode,
CssVarHost,
MatcherResult,
} from "../types/test-vite.types.js";
export interface TestViteMatchers {
toBeAccessibleNode(received: unknown): MatcherResult;
toMatchDataURL(received: unknown, mimePattern: RegExp): MatcherResult;
toHaveValidCSSVars(
received: unknown,
names: readonly string[]
): MatcherResult;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function hasAccessibleHook(node: AccessibleNode): boolean {
return (
isNonEmptyString(node.role) ||
isNonEmptyString(node["aria-label"]) ||
isNonEmptyString(node["aria-labelledby"])
);
}
const dataUrlPattern = /^data:([^;,]+);base64,([A-Za-z0-9+/=]*)$/;
/**
* Build the test-vite matcher bundle ready for `expect.extend`.
*/
export function createMatchers(): TestViteMatchers {
return {
toBeAccessibleNode(received: unknown): MatcherResult {
if (
typeof received !== "object" ||
received === null ||
!hasAccessibleHook(received as AccessibleNode)
) {
return {
pass: false,
message: () =>
"expected node to expose role / aria-label / aria-labelledby",
};
}
return {
pass: true,
message: () => "node is accessible",
};
},
toMatchDataURL(received: unknown, mimePattern: RegExp): MatcherResult {
if (typeof received !== "string") {
return {
pass: false,
message: () => "expected received to be a string",
};
}
const match = received.match(dataUrlPattern);
if (match === null) {
return {
pass: false,
message: () =>
"expected received to match data:<mime>;base64,<payload>",
};
}
const mime = match[1] as string;
if (!mimePattern.test(mime)) {
return {
pass: false,
message: () =>
`expected mime '${mime}' to match ${mimePattern.source}`,
};
}
return { pass: true, message: () => "data URL matches mime" };
},
toHaveValidCSSVars(
received: unknown,
names: readonly string[]
): MatcherResult {
if (
typeof received !== "object" ||
received === null ||
typeof (received as CssVarHost).style !== "object" ||
(received as CssVarHost).style === null
) {
return {
pass: false,
message: () => "expected received to have a .style record",
};
}
const style = (received as CssVarHost).style;
for (const name of names) {
const value = style[name];
if (!isNonEmptyString(value)) {
return {
pass: false,
message: () =>
`expected CSS var '${name}' to be a non-empty string`,
};
}
}
return { pass: true, message: () => "every CSS var resolves" };
},
};
}

View File

@@ -0,0 +1,2 @@
export type { PortAllocatorOptions } from "./port-allocator.js";
export { createPortAllocator } from "./port-allocator.js";

View File

@@ -0,0 +1,54 @@
# port-allocator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createPortAllocator` builds a deterministic port allocator over a contiguous TCP range backed by an injectable store. It is designed to coordinate parallel Playwright / Storybook / Vite-server runs on WSL2, where naïve `random-port` allocation strategies collide under load. The store is injectable so production callers can plug in a file-backed lock store to coordinate across processes while tests use the default in-memory map.
## Features
- Lowest-port-first allocation (deterministic, debuggable).
- Injectable `PortStore` for file-backed cross-process coordination.
- `PORT_EXHAUSTED` / `PORT_NOT_HELD` errors with stable codes.
- `snapshot()` returns the current set of held ports sorted ascending.
- Zod-validated boundary; bad input throws `TestViteError(TEST_VITE.INVALID_OPTIONS)`.
## Usage: Basic
```ts
import { createPortAllocator } from "@suaveplan/test-vite/port-allocator";
const allocator = createPortAllocator({ range: [5000, 5100] });
const port = allocator.allocate();
try {
await startServer(port);
} finally {
allocator.release(port);
}
```
## Usage: Advanced
Coordinate across processes via a file-backed `PortStore`:
```ts
import { createPortAllocator } from "@suaveplan/test-vite/port-allocator";
import type { PortStore } from "@suaveplan/test-vite";
const fileStore: PortStore = makeFileLockStore("/tmp/suaveplan-ports.lock");
const allocator = createPortAllocator({ range: [5000, 5100], store: fileStore });
```
## API Reference
- `createPortAllocator(opts: PortAllocatorOptions): PortAllocator`.
- `PortAllocatorOptions` — `{ range: [start, end], store? }`.
- `PortAllocator` — `{ allocate(): number, release(port: number): void, snapshot(): readonly number[] }`.
- Throws `TestViteError` with `TEST_VITE.INVALID_OPTIONS`, `TEST_VITE.PORT_EXHAUSTED`, or `TEST_VITE.PORT_NOT_HELD`.
## Implementation Notes
`MemoryPortStore` uses a `Set<number>` so `has` / `add` / `delete` are O(1). The snapshot is sorted before return so callers can rely on a stable ordering. The allocator does not bind real sockets — it is a coordination primitive, not a port prober — so callers that need defence against external bindings should wrap `allocate()` with a probing helper that retries on failure.

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import type { PortStore } from "../types/test-vite.types.js";
import { createPortAllocator } from "./port-allocator.js";
describe("createPortAllocator", () => {
it("allocates the lowest free port", { timeout: 5000 }, () => {
const allocator = createPortAllocator({ range: [5000, 5002] });
expect(allocator.allocate()).toBe(5000);
expect(allocator.allocate()).toBe(5001);
expect(allocator.allocate()).toBe(5002);
});
it("throws PORT_EXHAUSTED when range is full", { timeout: 5000 }, () => {
const allocator = createPortAllocator({ range: [5000, 5001] });
allocator.allocate();
allocator.allocate();
let captured: unknown;
try {
allocator.allocate();
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.PORT_EXHAUSTED
);
});
it("release frees the port for reallocation", { timeout: 5000 }, () => {
const allocator = createPortAllocator({ range: [5000, 5002] });
allocator.allocate();
const port = allocator.allocate();
allocator.release(port);
expect(allocator.allocate()).toBe(port);
});
it("releasing an unheld port throws PORT_NOT_HELD", {
timeout: 5000,
}, () => {
const allocator = createPortAllocator({ range: [5000, 5002] });
let captured: unknown;
try {
allocator.release(5999);
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.PORT_NOT_HELD
);
});
it("snapshot returns held ports sorted", { timeout: 5000 }, () => {
const allocator = createPortAllocator({ range: [5000, 5002] });
allocator.allocate();
allocator.allocate();
expect(allocator.snapshot()).toEqual([5000, 5001]);
});
it("throws INVALID_OPTIONS on inverted range", { timeout: 5000 }, () => {
let captured: unknown;
try {
createPortAllocator({ range: [6000, 5000] });
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
it("accepts a custom store", { timeout: 5000 }, () => {
const held = new Set<number>();
const store: PortStore = {
has: (p) => held.has(p),
add: (p) => {
held.add(p);
},
delete: (p) => {
held.delete(p);
},
values: () => held.values(),
};
const allocator = createPortAllocator({ range: [4000, 4001], store });
allocator.allocate();
expect(held.has(4000)).toBe(true);
allocator.release(4000);
expect(held.has(4000)).toBe(false);
});
});

View File

@@ -0,0 +1,99 @@
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { recordPortAllocation } from "../telemetry/telemetry.js";
import type { PortAllocator, PortStore } from "../types/test-vite.types.js";
const PortAllocatorOptionsSchema = z
.object({
range: z
.tuple([
z.number().int().min(1).max(65_535),
z.number().int().min(1).max(65_535),
])
.refine(([start, end]) => start <= end, {
message: "range start must be <= range end",
}),
store: z
.custom<PortStore>(
(value): value is PortStore =>
typeof value === "object" &&
value !== null &&
typeof (value as PortStore).has === "function" &&
typeof (value as PortStore).add === "function" &&
typeof (value as PortStore).delete === "function" &&
typeof (value as PortStore).values === "function",
{ message: "store must implement PortStore" }
)
.optional(),
})
.strict();
export type PortAllocatorOptions = z.input<typeof PortAllocatorOptionsSchema>;
class MemoryPortStore implements PortStore {
private readonly held = new Set<number>();
public has(port: number): boolean {
return this.held.has(port);
}
public add(port: number): void {
this.held.add(port);
}
public delete(port: number): void {
this.held.delete(port);
}
public values(): IterableIterator<number> {
return this.held.values();
}
}
/**
* Build a deterministic port allocator over a contiguous range.
*
* The allocator hands out the lowest unused port from `range`, marks it
* held in the injectable `store`, and supports release + snapshot. A
* shared store (e.g. file-backed) lets parallel Playwright / Storybook
* runs coordinate without colliding.
*/
export function createPortAllocator(opts: PortAllocatorOptions): PortAllocator {
const parsed = PortAllocatorOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"createPortAllocator: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const { range } = parsed.data;
const store: PortStore = parsed.data.store ?? new MemoryPortStore();
const [start, end] = range;
return {
allocate(): number {
for (let port = start; port <= end; port += 1) {
if (!store.has(port)) {
store.add(port);
recordPortAllocation();
return port;
}
}
throw new TestViteError(
TEST_VITE_CODES.PORT_EXHAUSTED,
"createPortAllocator: range exhausted",
{ details: { range } }
);
},
release(port: number): void {
if (!store.has(port)) {
throw new TestViteError(
TEST_VITE_CODES.PORT_NOT_HELD,
"createPortAllocator: port not held",
{ details: { port } }
);
}
store.delete(port);
},
snapshot(): readonly number[] {
return Array.from(store.values()).sort((a, b) => a - b);
},
};
}

View File

@@ -0,0 +1,16 @@
export type {
TestViteConfigKind,
TestViteValidatorKind,
} from "./telemetry.js";
export {
configBuiltCounter,
hmrRoundTripHistogram,
portAllocationsCounter,
recordConfigBuilt,
recordHmrRoundTrip,
recordPortAllocation,
recordValidatorRun,
recordValidatorViolation,
validatorRunsCounter,
validatorViolationsCounter,
} from "./telemetry.js";

View File

@@ -0,0 +1,60 @@
# telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`telemetry` registers the OTel meter for `@suaveplan/test-vite` and exposes the counters and histogram that every other module records against. Instruments are bound lazily through `metrics.getMeter("@suaveplan/test-vite")` so importing the module is safe when no `MeterProvider` is registered — in that case the OTel API returns a no-op meter and recordings become inexpensive no-ops. Tests register a real in-memory `MeterProvider` before module import so they can assert exported metric points.
## Features
- Four typed `Counter` instruments + one `Histogram`.
- Lazy meter binding — production callers can opt out of OTel entirely.
- Tag-aware recorders (`recordConfigBuilt(kind)`, `recordValidatorRun(kind)`) so dashboards can split by factory kind.
- Wired into every public call site so the §6 spec verification can grep for usage.
## Usage: Basic
```ts
import { recordConfigBuilt } from "@suaveplan/test-vite/telemetry";
recordConfigBuilt("vitest"); // increments test_vite_config_built_total{kind="vitest"}
```
## Usage: Advanced
Register an in-memory exporter before module import to assert metric points in tests:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
// Import test-vite AFTER the provider is registered so the meter binds to it.
const { createVitestConfig } = await import("@suaveplan/test-vite/vitest-config");
createVitestConfig({});
```
## API Reference
- `configBuiltCounter`, `validatorRunsCounter`, `validatorViolationsCounter`, `portAllocationsCounter` — `Counter`.
- `hmrRoundTripHistogram` — `Histogram` in milliseconds.
- `recordConfigBuilt(kind: TestViteConfigKind): void` — `kind` ∈ `"vite_library" | "vite_app" | "vitest" | "vitest_browser"`.
- `recordValidatorRun(kind: TestViteValidatorKind): void` — `kind` ∈ `"tree_shake" | "bundle_size"`.
- `recordValidatorViolation(kind: TestViteValidatorKind): void`.
- `recordPortAllocation(): void`.
- `recordHmrRoundTrip(durationMs: number): void`.
## Implementation Notes
Counters use `add(1, attrs)` so cumulative-temporality exporters report stable point values. The histogram records milliseconds (`unit: 'ms'`) so dashboards can display it directly without unit conversion. Recorders are split from the instrument constants so call sites cannot accidentally pass a wrong shape — every recorder narrows its `kind` argument to the literal-union type at the boundary.

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { snapshotBundleSize } from "../bundle-size-snapshot/bundle-size-snapshot.js";
import { withHMR } from "../hmr-tester/hmr-tester.js";
import { createPortAllocator } from "../port-allocator/port-allocator.js";
import { verifyTreeShake } from "../tree-shake-validator/tree-shake-validator.js";
import type { FsAdapter } from "../types/test-vite.types.js";
import { createViteLibraryConfig } from "../vite-library-config/vite-library-config.js";
import { createVitestConfig } from "../vitest-config/vitest-config.js";
function makeMemoryFs(seed: Record<string, Uint8Array> = {}): FsAdapter {
const store = new Map<string, Uint8Array>(Object.entries(seed));
return {
async readFile(path) {
const v = store.get(path);
if (v === undefined) {
const err = new Error("ENOENT") as Error & { code: string };
err.code = "ENOENT";
throw err;
}
return v;
},
async writeFile(path, data) {
store.set(path, new TextEncoder().encode(data));
},
};
}
function getMetricPoints(
collected: Awaited<ReturnType<typeof flushTestMetrics>>,
name: string
) {
const points: Array<{
value: number;
attributes: Record<string, unknown>;
}> = [];
for (const rm of collected) {
for (const sm of rm.scopeMetrics) {
for (const metric of sm.metrics) {
if (metric.descriptor.name === name) {
for (const dp of metric.dataPoints) {
const value =
typeof dp.value === "number"
? dp.value
: (dp.value as { count: number }).count;
points.push({
value,
attributes: dp.attributes as Record<
string,
unknown
>,
});
}
}
}
}
}
return points;
}
describe("telemetry", () => {
it("records config build on factory invocation", {
timeout: 5000,
}, async () => {
createVitestConfig({});
const collected = await flushTestMetrics();
const pts = getMetricPoints(collected, "test_vite_config_built_total");
expect(pts.some((p) => p.attributes["kind"] === "vitest")).toBe(true);
});
it("records validator runs and violations", { timeout: 5000 }, async () => {
await verifyTreeShake({
entry: "x.js",
expectedSymbols: ["a"],
readFile: async () => "export const b = 1;",
});
const collected = await flushTestMetrics();
expect(
getMetricPoints(collected, "test_vite_validator_runs_total").length
).toBeGreaterThan(0);
expect(
getMetricPoints(collected, "test_vite_validator_violations_total")
.length
).toBeGreaterThan(0);
});
it("records port allocations and HMR round-trip durations", {
timeout: 5000,
}, async () => {
const allocator = createPortAllocator({ range: [7000, 7001] });
allocator.allocate();
let listener: ((event: string) => void) | undefined;
const server = {
send: () => {
setTimeout(() => listener?.("done"), 0);
},
onEvent: (cb: (event: string) => void) => {
listener = cb;
return () => {
listener = undefined;
};
},
};
await withHMR({
server,
edit: { file: "a.ts", content: "1" },
expectEvents: ["done"],
timeoutMs: 1000,
});
const collected = await flushTestMetrics();
expect(
getMetricPoints(collected, "test_vite_port_allocations_total")
.length
).toBeGreaterThan(0);
expect(
getMetricPoints(collected, "test_vite_hmr_round_trip_ms").length
).toBeGreaterThan(0);
});
it("covers vite_library and bundle_size kinds via real call sites", {
timeout: 5000,
}, async () => {
createViteLibraryConfig({
entries: { index: "src/index.ts" },
});
await snapshotBundleSize({
entry: "b.js",
snapshot: "b.snap",
update: true,
fs: makeMemoryFs({ "b.js": new Uint8Array([1, 2, 3]) }),
});
const collected = await flushTestMetrics();
const cfg = getMetricPoints(collected, "test_vite_config_built_total");
expect(cfg.some((p) => p.attributes["kind"] === "vite_library")).toBe(
true
);
const runs = getMetricPoints(
collected,
"test_vite_validator_runs_total"
);
expect(runs.some((p) => p.attributes["kind"] === "bundle_size")).toBe(
true
);
});
it("covers vite_app and vitest_browser kinds via real call sites", {
timeout: 5000,
}, async () => {
const { createViteAppConfig } = await import(
"../vite-app-config/vite-app-config.js"
);
const { createVitestBrowserConfig } = await import(
"../vitest-browser-config/vitest-browser-config.js"
);
createViteAppConfig({ root: "." });
createVitestBrowserConfig({});
const collected = await flushTestMetrics();
const cfg = getMetricPoints(collected, "test_vite_config_built_total");
expect(cfg.some((p) => p.attributes["kind"] === "vite_app")).toBe(true);
expect(cfg.some((p) => p.attributes["kind"] === "vitest_browser")).toBe(
true
);
});
});

View File

@@ -0,0 +1,88 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/test-vite";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns a
* no-op meter, so instruments are safe to import in production code that
* does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
export type TestViteConfigKind =
| "vite_library"
| "vite_app"
| "vitest"
| "vitest_browser";
export type TestViteValidatorKind = "tree_shake" | "bundle_size";
/** Counter incremented per factory invocation, tagged with `kind`. */
export const configBuiltCounter: Counter = meter.createCounter(
"test_vite_config_built_total",
{
description:
"Vite/Vitest config factory invocations, tagged with the factory kind",
}
);
/** Counter incremented per validator run (`verifyTreeShake` / `snapshotBundleSize`). */
export const validatorRunsCounter: Counter = meter.createCounter(
"test_vite_validator_runs_total",
{
description:
"Validator invocations across tree-shake and bundle-size checks",
}
);
/** Counter incremented per detected validator violation. */
export const validatorViolationsCounter: Counter = meter.createCounter(
"test_vite_validator_violations_total",
{
description:
"Detected violations from tree-shake / bundle-size validators",
}
);
/** Counter incremented per successful port allocation. */
export const portAllocationsCounter: Counter = meter.createCounter(
"test_vite_port_allocations_total",
{
description: "Successful port allocations from the port allocator",
}
);
/** Histogram recording HMR round-trip durations in milliseconds. */
export const hmrRoundTripHistogram: Histogram = meter.createHistogram(
"test_vite_hmr_round_trip_ms",
{
description: "Duration of HMR round-trips driven by withHMR",
unit: "ms",
}
);
/** Record a factory invocation against the named kind. */
export function recordConfigBuilt(kind: TestViteConfigKind): void {
configBuiltCounter.add(1, { kind });
}
/** Record a validator run against the named kind. */
export function recordValidatorRun(kind: TestViteValidatorKind): void {
validatorRunsCounter.add(1, { kind });
}
/** Record a single validator violation against the named kind. */
export function recordValidatorViolation(kind: TestViteValidatorKind): void {
validatorViolationsCounter.add(1, { kind });
}
/** Record a successful port allocation. */
export function recordPortAllocation(): void {
portAllocationsCounter.add(1);
}
/** Record an HMR round-trip duration in milliseconds. */
export function recordHmrRoundTrip(durationMs: number): void {
hmrRoundTripHistogram.record(durationMs);
}

View File

@@ -0,0 +1,2 @@
export type { VerifyTreeShakeOptions } from "./tree-shake-validator.js";
export { verifyTreeShake } from "./tree-shake-validator.js";

View File

@@ -0,0 +1,55 @@
# tree-shake-validator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`verifyTreeShake` reads a built bundle and asserts that its set of exported symbols matches the caller-supplied allow-list. Tree-shake regressions are silent failures — the package compiles, tests pass, but downstream consumers suddenly ship a hundred extra kilobytes. The validator catches drift at build time by parsing the emitted bundle for `export const|function|class|let|var` statements and `export { … }` re-export lists, comparing the actual set against the expected set, and reporting `missing` / `unexpected` symbols.
## Features
- Parses both `export <kind> <name>` and `export { … }` re-export lists.
- Returns a discriminated `{ ok: true, actual }` / `{ ok: false, missing, unexpected }` result so callers can branch without `try/catch`.
- Optional `enforce: true` mode that throws `TestViteError(TEST_VITE.TREE_SHAKE_VIOLATION)` on drift.
- Injectable `readFile` so the validator never touches the filesystem directly.
- Maps `readFile` rejections to `TestViteError(TEST_VITE.MISSING_ENTRY)`.
- OTel counters: `test_vite_validator_runs_total{kind="tree_shake"}` per call, `test_vite_validator_violations_total` on drift.
## Usage: Basic
```ts
import { readFile } from "node:fs/promises";
import { verifyTreeShake } from "@suaveplan/test-vite/tree-shake-validator";
const result = await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: ["createWidget", "WidgetError"],
readFile: async (p) => (await readFile(p)).toString("utf8"),
});
```
## Usage: Advanced
CI guard that fails the build on drift:
```ts
await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: PUBLIC_API_ALLOWLIST,
readFile: async (p) => readFile(p, "utf8"),
enforce: true,
});
```
## API Reference
- `verifyTreeShake(opts: VerifyTreeShakeOptions): Promise<TreeShakeResult>`.
- `VerifyTreeShakeOptions` — `{ entry, expectedSymbols, readFile, enforce? }`.
- `TreeShakeResult` — `{ ok: true, actual } | { ok: false, actual, missing, unexpected }`.
- Throws `TestViteError` with `TEST_VITE.INVALID_OPTIONS`, `TEST_VITE.MISSING_ENTRY`, or `TEST_VITE.TREE_SHAKE_VIOLATION` (`enforce: true` only).
## Implementation Notes
The parser uses two complementary global regexes — one for inline declarations and one for `export { … }` lists — driven through `matchAll` so the regex internal state is not shared across calls. Aliases (`export { foo as bar }`) are normalised to the exported name (`bar`). The list of returned symbols is alphabetically sorted to make snapshot diffs stable.

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { verifyTreeShake } from "./tree-shake-validator.js";
const matchingBundle = `
export const foo = 1;
export function bar() { return foo; }
`;
const driftBundle = `
export const foo = 1;
export const baz = 2;
`;
const reExportBundle = `
const internal = 1;
export { internal as foo, internal as bar };
`;
const bytesBundle = new TextEncoder().encode(matchingBundle);
describe("verifyTreeShake", () => {
it("reports ok when exports match", { timeout: 5000 }, async () => {
const result = await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: ["foo", "bar"],
readFile: async () => matchingBundle,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.actual).toEqual(["bar", "foo"]);
}
});
it("reports drift when exports diverge", { timeout: 5000 }, async () => {
const result = await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: ["foo", "bar"],
readFile: async () => driftBundle,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.missing).toEqual(["bar"]);
expect(result.unexpected).toEqual(["baz"]);
}
});
it("enforce mode throws TREE_SHAKE_VIOLATION", {
timeout: 5000,
}, async () => {
let captured: unknown;
try {
await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: ["foo", "bar"],
readFile: async () => driftBundle,
enforce: true,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.TREE_SHAKE_VIOLATION
);
expect((captured as TestViteError).details["missing"]).toEqual(["bar"]);
});
it("missing entry raises MISSING_ENTRY", { timeout: 5000 }, async () => {
let captured: unknown;
try {
await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: ["foo"],
readFile: async () => {
const err = new Error("ENOENT") as Error & {
code: string;
};
err.code = "ENOENT";
throw err;
},
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.MISSING_ENTRY
);
});
it("parses re-export lists with aliases", { timeout: 5000 }, async () => {
const result = await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: ["foo", "bar"],
readFile: async () => reExportBundle,
});
expect(result.ok).toBe(true);
});
it("ignores empty pieces in re-export lists", {
timeout: 5000,
}, async () => {
const bundle = "export { foo, , bar };";
const result = await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: ["foo", "bar"],
readFile: async () => bundle,
});
expect(result.ok).toBe(true);
});
it("accepts Uint8Array bundle bytes", { timeout: 5000 }, async () => {
const result = await verifyTreeShake({
entry: "dist/index.js",
expectedSymbols: ["foo", "bar"],
readFile: async () => bytesBundle,
});
expect(result.ok).toBe(true);
});
it("throws INVALID_OPTIONS on missing readFile", {
timeout: 5000,
}, async () => {
let captured: unknown;
try {
await verifyTreeShake({
entry: "x.js",
expectedSymbols: ["foo"],
readFile: undefined as unknown as () => Promise<string>,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
});

View File

@@ -0,0 +1,93 @@
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import {
recordValidatorRun,
recordValidatorViolation,
} from "../telemetry/telemetry.js";
import type { TreeShakeResult } from "../types/test-vite.types.js";
const VerifyTreeShakeOptionsSchema = z
.object({
entry: z.string().min(1),
expectedSymbols: z.array(z.string().min(1)),
readFile: z.custom<(path: string) => Promise<string | Uint8Array>>(
(value) => typeof value === "function",
{ message: "readFile must be a function" }
),
enforce: z.boolean().default(false),
})
.strict();
export type VerifyTreeShakeOptions = z.input<
typeof VerifyTreeShakeOptionsSchema
>;
const namedExportPattern =
/export\s+(?:const|let|var|function|class|async\s+function)\s+([A-Za-z_$][\w$]*)/g;
const reExportListPattern = /export\s*\{\s*([^}]+)\s*\}/g;
function parseExportedSymbols(source: string): string[] {
const symbols = new Set<string>();
for (const match of source.matchAll(namedExportPattern)) {
symbols.add(match[1] as string);
}
for (const match of source.matchAll(reExportListPattern)) {
const body = match[1] as string;
for (const piece of body.split(",")) {
const trimmed = piece.trim();
if (trimmed === "") {
continue;
}
const exported = trimmed.split(/\s+as\s+/).at(-1) as string;
symbols.add(exported);
}
}
return Array.from(symbols).sort();
}
/**
* Parse `entry` and assert its exported symbol set equals `expectedSymbols`.
*/
export async function verifyTreeShake(
opts: VerifyTreeShakeOptions
): Promise<TreeShakeResult> {
const parsed = VerifyTreeShakeOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"verifyTreeShake: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
recordValidatorRun("tree_shake");
let raw: string | Uint8Array;
try {
raw = await o.readFile(o.entry);
} catch (cause) {
throw new TestViteError(
TEST_VITE_CODES.MISSING_ENTRY,
"verifyTreeShake: cannot read entry",
{ cause, details: { entry: o.entry } }
);
}
const source =
typeof raw === "string" ? raw : new TextDecoder().decode(raw);
const actual = parseExportedSymbols(source);
const expected = [...o.expectedSymbols].sort();
const missing = expected.filter((name) => !actual.includes(name));
const unexpected = actual.filter((name) => !expected.includes(name));
if (missing.length === 0 && unexpected.length === 0) {
return { ok: true, actual };
}
recordValidatorViolation("tree_shake");
if (o.enforce) {
throw new TestViteError(
TEST_VITE_CODES.TREE_SHAKE_VIOLATION,
"verifyTreeShake: drift detected",
{ details: { missing, unexpected, actual } }
);
}
return { ok: false, actual, missing, unexpected };
}

View File

@@ -0,0 +1,54 @@
# test-vite.types
## Purpose
`types/test-vite.types.ts` is the canonical interface surface for cross-module structural types used by `@suaveplan/test-vite`. Every factory and validator references these shared types so plug-points like `FsAdapter`, `PortStore`, `HmrServerLike`, and `MatcherResult` stay consistent across feature folders. Centralising them here lets consumers depend on the structural contract without pulling in the implementation modules and keeps the package tree-shake-friendly.
## Features
- `ReadFileFn` — async byte/string reader for tree-shake validation.
- `FsAdapter` — minimal `readFile` / `writeFile` surface used by `bundle-size-snapshot`.
- `HmrServerLike` — `send` + `onEvent` contract used by `withHMR`.
- `PortStore` — pluggable store backing `createPortAllocator`.
- `PortAllocator` — return type of `createPortAllocator`.
- `TreeShakeResult` (discriminated `TreeShakeOk` | `TreeShakeDrift`) — return type of `verifyTreeShake`.
- `BundleSizeResult` — return type of `snapshotBundleSize`.
- `AccessibleNode`, `CssVarHost`, `MatcherResult` — matcher input/output shapes.
## Usage: Basic
```ts
import type { FsAdapter } from "@suaveplan/test-vite";
const fs: FsAdapter = {
async readFile(path) { /* … */ return new Uint8Array(); },
async writeFile(path, data) { /* … */ },
};
```
## Usage: Advanced
Define a custom port store backed by a file lock:
```ts
import type { PortStore } from "@suaveplan/test-vite";
class FilePortStore implements PortStore {
has(port: number): boolean { /* … */ return false; }
add(port: number): void { /* … */ }
delete(port: number): void { /* … */ }
*values(): IterableIterator<number> { /* … */ }
}
```
## API Reference
Every exported interface and type alias is structural — consumers can satisfy the contract without subclassing.
- `ReadFileFn`, `FsAdapter`, `HmrServerLike`, `PortStore`, `PortAllocator`.
- `TreeShakeOk`, `TreeShakeDrift`, `TreeShakeResult`, `BundleSizeResult`.
- `AccessibleNode`, `CssVarHost`, `MatcherResult`.
## Implementation Notes
Each interface uses the minimal viable surface so the adapters stay swappable. `FsAdapter.readFile` returns `Uint8Array` rather than `Buffer` to keep the type browser-tier compatible even though the production caller usually wraps `node:fs/promises`. The `PortStore` mirrors `Set<number>` so the default in-memory implementation can satisfy it without extra adapter code.

View File

@@ -0,0 +1,68 @@
/**
* Shared interfaces and option bundles for `@suaveplan/test-vite`.
*
* The factory options are validated at the boundary by Zod schemas that
* live alongside each factory; the types here mirror the parsed shapes so
* consumers can type-annotate inputs without paying the runtime parse
* cost.
*/
export type ReadFileFn = (path: string) => Promise<string | Uint8Array>;
export interface FsAdapter {
readFile(path: string): Promise<Uint8Array>;
writeFile(path: string, data: string): Promise<void>;
}
export interface HmrServerLike {
send(edit: { file: string; content: string }): void;
onEvent(listener: (event: string) => void): () => void;
}
export interface PortStore {
has(port: number): boolean;
add(port: number): void;
delete(port: number): void;
values(): IterableIterator<number>;
}
export interface PortAllocator {
allocate(): number;
release(port: number): void;
snapshot(): readonly number[];
}
export interface TreeShakeOk {
ok: true;
actual: readonly string[];
}
export interface TreeShakeDrift {
ok: false;
actual: readonly string[];
missing: readonly string[];
unexpected: readonly string[];
}
export type TreeShakeResult = TreeShakeOk | TreeShakeDrift;
export interface BundleSizeResult {
updated: boolean;
size: number;
delta?: number;
}
export interface AccessibleNode {
role?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
}
export interface CssVarHost {
style: Record<string, string>;
}
export interface MatcherResult {
pass: boolean;
message: () => string;
}

View File

@@ -0,0 +1,2 @@
export type { ViteAppConfigOptions } from "./vite-app-config.js";
export { createViteAppConfig } from "./vite-app-config.js";

View File

@@ -0,0 +1,54 @@
# vite-app-config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createViteAppConfig` builds a Vite `UserConfig` tailored for consumer applications rather than library packages. It defaults to hashed asset filenames so long-term caching works out of the box, targets `es2022` to match the project's TypeScript output, and configures `envPrefix` so the standard `VITE_*` (or any caller-supplied prefix) is the only env surface exposed to client bundles.
## Features
- Hashed asset / chunk / entry filenames under `assets/[name]-[hash][extname]`.
- `target: 'es2022'` default to match the `tsconfig.base.json` setting.
- `envPrefix` default `['VITE_']` with full opt-in override.
- Zod-validated option boundary; bad input throws `TestViteError(TEST_VITE.INVALID_OPTIONS)`.
- OTel counter `test_vite_config_built_total{kind="vite_app"}` incremented per invocation.
## Usage: Basic
```ts
// vite.config.ts
import { createViteAppConfig } from "@suaveplan/test-vite/vite-app-config";
export default createViteAppConfig({ root: __dirname });
```
## Usage: Advanced
Custom env prefix + plugins:
```ts
import react from "@vitejs/plugin-react";
import { createViteAppConfig } from "@suaveplan/test-vite/vite-app-config";
export default createViteAppConfig({
root: __dirname,
outDir: "dist",
target: "es2023",
envPrefix: ["APP_", "PUBLIC_"],
sourcemap: true,
plugins: [react()],
});
```
## API Reference
- `createViteAppConfig(opts: ViteAppConfigOptions): UserConfig`.
- `ViteAppConfigOptions` — `{ root, outDir?, target?, envPrefix?, sourcemap?, plugins? }`.
- Throws `TestViteError` with code `TEST_VITE.INVALID_OPTIONS` on Zod failure.
## Implementation Notes
The factory normalises `envPrefix` to a string array even when only one prefix is supplied so downstream tooling can iterate uniformly. Asset hashing uses the standard Rollup placeholders; consumers that need a different layout can spread the returned config and override `build.rollupOptions.output`. The factory does not register a framework plugin — pass `react()`, `vue()`, or another framework plugin through the `plugins` option so the factory stays framework-agnostic and you keep one source of truth for plugin order. The Zod boundary rejects unknown options strictly so typos surface immediately at build time rather than failing silently inside Vite.

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { createViteAppConfig } from "./vite-app-config.js";
describe("createViteAppConfig", () => {
it("returns app config with hashed asset filenames", {
timeout: 5000,
}, () => {
const config = createViteAppConfig({
root: ".",
envPrefix: ["APP_"],
});
const output = (config.build?.rollupOptions?.output ?? {}) as {
assetFileNames?: string;
};
expect(output.assetFileNames).toBe("assets/[name]-[hash][extname]");
expect(config.envPrefix).toEqual(["APP_"]);
});
it("defaults envPrefix to VITE_ and target to es2022", {
timeout: 5000,
}, () => {
const config = createViteAppConfig({ root: "." });
expect(config.envPrefix).toEqual(["VITE_"]);
expect(config.build?.target).toBe("es2022");
});
it("throws INVALID_OPTIONS on non-string root", { timeout: 5000 }, () => {
let captured: unknown;
try {
createViteAppConfig({
root: 123 as unknown as string,
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
it("honours custom outDir and sourcemap", { timeout: 5000 }, () => {
const config = createViteAppConfig({
root: ".",
outDir: "build",
sourcemap: false,
plugins: [],
});
expect(config.build?.outDir).toBe("build");
expect(config.build?.sourcemap).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import type { Plugin, UserConfig } from "vite";
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { recordConfigBuilt } from "../telemetry/telemetry.js";
const ViteAppConfigOptionsSchema = z
.object({
root: z.string(),
outDir: z.string().default("dist"),
target: z.string().default("es2022"),
envPrefix: z.array(z.string().min(1)).default(["VITE_"]),
sourcemap: z.boolean().default(true),
plugins: z.array(z.custom<Plugin>()).default([]),
})
.strict();
export type ViteAppConfigOptions = z.input<typeof ViteAppConfigOptionsSchema>;
/**
* Build a Vite `UserConfig` tailored for a consumer application.
*
* - Hashed asset filenames under `assets/[name]-[hash][extname]`.
* - `target` defaults to `es2022`.
* - `envPrefix` defaults to `['VITE_']`.
*
* @throws {TestViteError} with `TEST_VITE.INVALID_OPTIONS` on bad input.
*/
export function createViteAppConfig(opts: ViteAppConfigOptions): UserConfig {
const parsed = ViteAppConfigOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"createViteAppConfig: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
const config: UserConfig = {
root: o.root,
envPrefix: o.envPrefix,
plugins: [...o.plugins],
build: {
outDir: o.outDir,
sourcemap: o.sourcemap,
target: o.target,
rollupOptions: {
output: {
assetFileNames: "assets/[name]-[hash][extname]",
chunkFileNames: "assets/[name]-[hash].js",
entryFileNames: "assets/[name]-[hash].js",
},
},
},
};
recordConfigBuilt("vite_app");
return config;
}

View File

@@ -0,0 +1,2 @@
export type { ViteLibraryConfigOptions } from "./vite-library-config.js";
export { createViteLibraryConfig } from "./vite-library-config.js";

View File

@@ -0,0 +1,63 @@
# vite-library-config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createViteLibraryConfig` builds an opinionated Vite `UserConfig` suitable for any `@suaveplan/*` library package. It encodes the suaveplan conventions for tree-shake-friendly output, type-declaration emission via `vite-plugin-dts`, and the `external` baseline (`/^@suaveplan\//`, `/^node:/`, `'tslib'`). Every package in the project can drop a one-line `vite.config.ts` and inherit the contract: ESM-first output with `preserveModules`, named exports, and sourcemaps.
## Features
- `preserveModules: true` with `preserveModulesRoot` mirroring the source root.
- `vite-plugin-dts` plugin pre-pended ahead of caller-supplied plugins.
- `external` automatically merged with the suaveplan baseline.
- Zod-validated option boundary; bad input throws `TestViteError(TEST_VITE.INVALID_OPTIONS)`.
- OTel counter `test_vite_config_built_total{kind="vite_library"}` incremented per invocation.
## Usage: Basic
```ts
// vite.config.ts
import { createViteLibraryConfig } from "@suaveplan/test-vite/vite-library-config";
export default createViteLibraryConfig({
entries: { index: "src/index.ts" },
tsconfigPath: "./tsconfig.lib.json",
});
```
## Usage: Advanced
Multi-entry build with extra externals and caller plugins:
```ts
import path from "node:path";
import { createViteLibraryConfig } from "@suaveplan/test-vite/vite-library-config";
export default createViteLibraryConfig({
root: __dirname,
entries: {
index: "src/index.ts",
"submodule/index": "src/submodule/index.ts",
},
entryRoot: "src",
outDir: "dist",
tsconfigPath: "./tsconfig.lib.json",
external: ["zod", /^@opentelemetry\//],
plugins: [],
sourcemap: true,
emptyOutDir: true,
});
```
## API Reference
- `createViteLibraryConfig(opts: ViteLibraryConfigOptions): UserConfig`.
- `ViteLibraryConfigOptions` — `{ root?, entries, tsconfigPath?, entryRoot?, outDir?, external?, sourcemap?, emptyOutDir?, plugins? }`.
- Throws `TestViteError` with code `TEST_VITE.INVALID_OPTIONS` on Zod failure.
## Implementation Notes
The factory mutates nothing on the input; the returned config is freshly constructed. Rollup `output.entryFileNames` is set to `[name].js` so the emitted directory mirrors the source tree. The dts plugin is added with `insertTypesEntry: true` so a single `index.d.ts` shim is emitted for the package root regardless of how many entries are configured. The `external` baseline is intentionally merged via simple concatenation rather than de-duplication so caller-supplied entries always take precedence in Rollup's match order.

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { createViteLibraryConfig } from "./vite-library-config.js";
describe("createViteLibraryConfig", () => {
it("returns ESM-first config with preserveModules", {
timeout: 5000,
}, () => {
const config = createViteLibraryConfig({
entries: { index: "src/index.ts" },
tsconfigPath: "./tsconfig.lib.json",
root: "/tmp/pkg",
});
expect(config.root).toBe("/tmp/pkg");
expect(config.build?.lib).toMatchObject({ formats: ["es"] });
const output = (config.build?.rollupOptions?.output ?? {}) as {
preserveModules?: boolean;
preserveModulesRoot?: string;
};
expect(output.preserveModules).toBe(true);
expect(output.preserveModulesRoot).toBe("src");
expect(Array.isArray(config.plugins)).toBe(true);
expect((config.plugins ?? []).length).toBeGreaterThan(0);
});
it("merges caller external with baseline", { timeout: 5000 }, () => {
const config = createViteLibraryConfig({
entries: { index: "src/index.ts" },
external: ["zod", /^@opentelemetry\//],
});
const external = config.build?.rollupOptions?.external as Array<
string | RegExp
>;
expect(external).toContain("zod");
expect(external).toContain("tslib");
expect(
external.some(
(entry) =>
entry instanceof RegExp &&
entry.source === /^@suaveplan\//.source
)
).toBe(true);
expect(
external.some(
(entry) =>
entry instanceof RegExp && entry.source === /^node:/.source
)
).toBe(true);
expect(
external.some(
(entry) =>
entry instanceof RegExp &&
entry.source === /^@opentelemetry\//.source
)
).toBe(true);
});
it("throws INVALID_OPTIONS on empty entries", { timeout: 5000 }, () => {
let captured: unknown;
try {
createViteLibraryConfig({ entries: {} });
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
it("accepts custom entryRoot and outDir", { timeout: 5000 }, () => {
const config = createViteLibraryConfig({
entries: { index: "lib/index.ts" },
entryRoot: "lib",
outDir: "build",
sourcemap: false,
emptyOutDir: false,
plugins: [],
});
expect(config.build?.outDir).toBe("build");
expect(config.build?.sourcemap).toBe(false);
expect(config.build?.emptyOutDir).toBe(false);
const output = (config.build?.rollupOptions?.output ?? {}) as {
preserveModulesRoot?: string;
};
expect(output.preserveModulesRoot).toBe("lib");
});
});

View File

@@ -0,0 +1,94 @@
import type { Plugin, UserConfig } from "vite";
import dts from "vite-plugin-dts";
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { recordConfigBuilt } from "../telemetry/telemetry.js";
const baselineExternal: ReadonlyArray<string | RegExp> = [
/^@suaveplan\//,
/^node:/,
"tslib",
];
const ExternalEntry = z.union([z.string(), z.instanceof(RegExp)]);
const ViteLibraryConfigOptionsSchema = z
.object({
root: z.string().optional(),
entries: z
.record(z.string(), z.string())
.refine((value) => Object.keys(value).length > 0, {
message: "entries must contain at least one entry",
}),
tsconfigPath: z.string().default("./tsconfig.lib.json"),
entryRoot: z.string().default("src"),
outDir: z.string().default("dist"),
external: z.array(ExternalEntry).default([]),
sourcemap: z.boolean().default(true),
emptyOutDir: z.boolean().default(true),
plugins: z.array(z.custom<Plugin>()).default([]),
})
.strict();
export type ViteLibraryConfigOptions = z.input<
typeof ViteLibraryConfigOptionsSchema
>;
/**
* Build an opinionated `UserConfig` for a `@suaveplan/*` library package.
*
* - `formats: ['es']`, `preserveModules: true`, `preserveModulesRoot: 'src'`
* - `vite-plugin-dts` plugin appended ahead of caller plugins
* - `external` extended with the genesis baseline (`/^@suaveplan\//`,
* `/^node:/`, `'tslib'`).
*
* @throws {TestViteError} with `TEST_VITE.INVALID_OPTIONS` on bad input.
*/
export function createViteLibraryConfig(
opts: ViteLibraryConfigOptions
): UserConfig {
const parsed = ViteLibraryConfigOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"createViteLibraryConfig: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
const external = [...baselineExternal, ...o.external];
const config: UserConfig = {
plugins: [
dts({
entryRoot: o.entryRoot,
tsconfigPath: o.tsconfigPath,
insertTypesEntry: true,
}),
...o.plugins,
],
build: {
outDir: o.outDir,
emptyOutDir: o.emptyOutDir,
sourcemap: o.sourcemap,
lib: {
entry: o.entries,
formats: ["es"],
},
rollupOptions: {
treeshake: true,
external,
output: {
preserveModules: true,
preserveModulesRoot: o.entryRoot,
entryFileNames: "[name].js",
},
},
},
};
if (o.root !== undefined) {
config.root = o.root;
}
recordConfigBuilt("vite_library");
return config;
}

View File

@@ -0,0 +1,2 @@
export type { VitestBrowserConfigOptions } from "./vitest-browser-config.js";
export { createVitestBrowserConfig } from "./vitest-browser-config.js";

View File

@@ -0,0 +1,52 @@
# vitest-browser-config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createVitestBrowserConfig` builds a Vitest configuration that drives tests through Playwright in real browsers. The factory encodes the suaveplan rules for browser-mode tests: `pool: 'forks'`, `fileParallelism: false` (required so Playwright workers do not contend), `provider: 'playwright'`, and headless-by-default. Packages opting into the browser runner inherit a known-good baseline and only have to declare the browser matrix they need.
## Features
- Playwright provider with caller-supplied browser matrix (`chromium`, `firefox`, `webkit`).
- Headless-by-default with explicit opt-out.
- `fileParallelism: false` pinned by the spec.
- Setup files accepted for environment bootstrap.
- Zod-validated boundary; bad input throws `TestViteError(TEST_VITE.INVALID_OPTIONS)`.
- OTel counter `test_vite_config_built_total{kind="vitest_browser"}` incremented per invocation.
## Usage: Basic
```ts
// vitest.browser.config.ts
import { createVitestBrowserConfig } from "@suaveplan/test-vite/vitest-browser-config";
export default createVitestBrowserConfig({});
```
## Usage: Advanced
Multi-browser matrix with custom include + setup:
```ts
import { createVitestBrowserConfig } from "@suaveplan/test-vite/vitest-browser-config";
export default createVitestBrowserConfig({
browsers: ["chromium", "firefox", "webkit"],
headless: false,
setupFiles: ["./tests/setup/browser.ts"],
include: ["src/**/*.browser.test.ts"],
});
```
## API Reference
- `createVitestBrowserConfig(opts?: VitestBrowserConfigOptions): UserConfig`.
- `VitestBrowserConfigOptions` — `{ browsers?, headless?, setupFiles?, include? }`.
- Throws `TestViteError` with code `TEST_VITE.INVALID_OPTIONS` on Zod failure (including empty browser list).
## Implementation Notes
The `browser.instances` array is built by mapping each requested browser into a `{ browser }` object so Vitest's browser runner can attach per-browser overrides if a caller spreads the result. The factory deliberately does not expose every Playwright option — packages with bespoke needs should spread the returned config and override the `browser` block. Vitest 4 requires `browser.provider` to be a provider factory imported from `@vitest/browser/providers/playwright`; to avoid a hard subpath dependency we accept that factory through `opts.provider`. When omitted we emit a tagged sentinel object that consumers can replace with a one-line spread before exporting their config.

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { createVitestBrowserConfig } from "./vitest-browser-config.js";
describe("createVitestBrowserConfig", () => {
it("works with no arguments", { timeout: 5000 }, () => {
const config = createVitestBrowserConfig();
expect(config.test?.fileParallelism).toBe(false);
});
it("accepts a custom provider override", { timeout: 5000 }, () => {
const provider = { name: "custom" };
const config = createVitestBrowserConfig({ provider });
const browser = config.test?.browser as { provider?: unknown };
expect(browser.provider).toBe(provider);
});
it("defaults to chromium headless with fileParallelism off", {
timeout: 5000,
}, () => {
const config = createVitestBrowserConfig({});
const browser = config.test?.browser as {
provider?: { name?: string };
headless?: boolean;
instances?: Array<{ browser: string }>;
};
expect(browser.provider?.name).toBe("playwright");
expect(browser.headless).toBe(true);
expect(browser.instances?.[0]?.browser).toBe("chromium");
expect(config.test?.fileParallelism).toBe(false);
});
it("accepts multi-browser matrix", { timeout: 5000 }, () => {
const config = createVitestBrowserConfig({
browsers: ["chromium", "firefox", "webkit"],
headless: false,
setupFiles: ["./setup.ts"],
include: ["custom/**/*.test.ts"],
});
const browser = config.test?.browser as {
instances?: Array<{ browser: string }>;
headless?: boolean;
};
expect(browser.instances?.map((entry) => entry.browser)).toEqual([
"chromium",
"firefox",
"webkit",
]);
expect(browser.headless).toBe(false);
expect(config.test?.setupFiles).toEqual(["./setup.ts"]);
expect(config.test?.include).toEqual(["custom/**/*.test.ts"]);
});
it("throws INVALID_OPTIONS on empty browsers", { timeout: 5000 }, () => {
let captured: unknown;
try {
createVitestBrowserConfig({ browsers: [] });
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
});

View File

@@ -0,0 +1,72 @@
import type { ViteUserConfig as UserConfig } from "vitest/config";
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { recordConfigBuilt } from "../telemetry/telemetry.js";
const BrowserSchema = z.enum(["chromium", "firefox", "webkit"]);
const VitestBrowserConfigOptionsSchema = z
.object({
browsers: z
.array(BrowserSchema)
.default(["chromium"])
.refine((value) => value.length > 0, {
message: "browsers must list at least one browser",
}),
headless: z.boolean().default(true),
setupFiles: z.array(z.string()).default([]),
include: z.array(z.string()).default(["src/**/*.browser.test.ts"]),
provider: z.unknown().optional(),
})
.strict();
export type VitestBrowserConfigOptions = z.input<
typeof VitestBrowserConfigOptionsSchema
>;
/**
* Build a Vitest browser-mode `UserConfig` driven by Playwright.
*
* Vitest 4 requires `browser.provider` to be a provider factory imported
* from `@vitest/browser/providers/playwright`. To avoid taking a hard
* dependency on that subpath we accept it via `opts.provider`. When not
* supplied, a tagged sentinel object is emitted so consumers can plug
* the real factory in via a one-line spread:
*
* ```ts
* import { playwright } from "@vitest/browser/providers/playwright";
* export default { ...config, test: { ...config.test, browser: { ...config.test.browser, provider: playwright() }}};
* ```
*
* @throws {TestViteError} with `TEST_VITE.INVALID_OPTIONS` on bad input.
*/
export function createVitestBrowserConfig(
opts: VitestBrowserConfigOptions = {}
): UserConfig {
const parsed = VitestBrowserConfigOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"createVitestBrowserConfig: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
const provider = o.provider ?? { name: "playwright" };
const config = {
test: {
pool: "forks" as const,
fileParallelism: false,
setupFiles: o.setupFiles,
include: o.include,
browser: {
provider,
enabled: true,
headless: o.headless,
instances: o.browsers.map((browser) => ({ browser })),
},
},
} as unknown as UserConfig;
recordConfigBuilt("vitest_browser");
return config;
}

View File

@@ -0,0 +1,2 @@
export type { VitestConfigOptions } from "./vitest-config.js";
export { createVitestConfig } from "./vitest-config.js";

View File

@@ -0,0 +1,55 @@
# vitest-config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createVitestConfig` returns a WSL2-safe Vitest configuration with the suaveplan enterprise contract baked in. The factory exists so every package in the project can share the same coverage thresholds (100/100/100/100 on Istanbul), the same `pool: 'forks'` default required by Bun on WSL2, and the same setup-file plumbing without copy-pasting twenty-line config blocks.
## Features
- `pool: 'forks'` default (both `vmThreads` and `threads` fail under Bun on WSL2).
- Istanbul coverage provider with 100/100/100/100 thresholds.
- Reasonable `include` / `exclude` defaults that match the project layout.
- `setupFiles` accepted from opts so callers can wire `@suaveplan/testing/polyfills` or package-specific bootstrap.
- Configurable test/hook timeouts, `fileParallelism` switch, and `coverage.include`/`exclude` overrides.
- Zod-validated option boundary; bad input throws `TestViteError(TEST_VITE.INVALID_OPTIONS)`.
- OTel counter `test_vite_config_built_total{kind="vitest"}` incremented per invocation.
## Usage: Basic
```ts
// vitest.config.ts
import { createVitestConfig } from "@suaveplan/test-vite/vitest-config";
export default createVitestConfig({});
```
## Usage: Advanced
DOM tests with package-specific setup file:
```ts
import { createVitestConfig } from "@suaveplan/test-vite/vitest-config";
export default createVitestConfig({
environment: "happy-dom",
setupFiles: ["@suaveplan/testing/polyfills", "./tests/setup/dom.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
fileParallelism: false,
testTimeout: 30000,
hookTimeout: 30000,
});
```
## API Reference
- `createVitestConfig(opts?: VitestConfigOptions): UserConfig` (from `vitest/config`).
- `VitestConfigOptions` — `{ environment?, setupFiles?, include?, exclude?, fileParallelism?, coverageInclude?, coverageExclude?, testTimeout?, hookTimeout? }`.
- Throws `TestViteError` with code `TEST_VITE.INVALID_OPTIONS` on Zod failure.
## Implementation Notes
Coverage `exclude` defaults are aligned with the package layout — `src/**/*.test.ts`, `src/**/*.types.ts`, `src/**/index.ts`, `src/index.ts`, and `tests/**` so barrel files and type-only modules do not artificially inflate or deflate coverage. Override `coverageExclude` to opt back in to those paths when measuring a specific module.

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { createVitestConfig } from "./vitest-config.js";
describe("createVitestConfig", () => {
it("works with no arguments", { timeout: 5000 }, () => {
const config = createVitestConfig();
expect(config.test?.pool).toBe("forks");
});
it("defaults to forks pool and istanbul provider", {
timeout: 5000,
}, () => {
const config = createVitestConfig({});
expect(config.test?.pool).toBe("forks");
expect(config.test?.coverage?.provider).toBe("istanbul");
const thresholds = config.test?.coverage?.thresholds as Record<
string,
number
>;
expect(thresholds["statements"]).toBe(100);
expect(thresholds["branches"]).toBe(100);
expect(thresholds["functions"]).toBe(100);
expect(thresholds["lines"]).toBe(100);
});
it("accepts environment and setupFiles overrides", {
timeout: 5000,
}, () => {
const config = createVitestConfig({
environment: "happy-dom",
setupFiles: ["./setup.ts"],
});
expect(config.test?.environment).toBe("happy-dom");
expect(config.test?.setupFiles).toContain("./setup.ts");
});
it("throws INVALID_OPTIONS on bad environment", { timeout: 5000 }, () => {
let captured: unknown;
try {
createVitestConfig({
environment: "invalid" as unknown as "node",
});
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(TestViteError);
expect((captured as TestViteError).code).toBe(
TEST_VITE_CODES.INVALID_OPTIONS
);
});
it("honours custom timeouts and exclude lists", { timeout: 5000 }, () => {
const config = createVitestConfig({
include: ["custom/**/*.test.ts"],
exclude: ["custom-exclude/**"],
fileParallelism: false,
testTimeout: 9000,
hookTimeout: 7000,
coverageInclude: ["lib/**/*.ts"],
coverageExclude: ["lib/**/*.test.ts"],
});
expect(config.test?.include).toEqual(["custom/**/*.test.ts"]);
expect(config.test?.exclude).toEqual(["custom-exclude/**"]);
expect(config.test?.fileParallelism).toBe(false);
expect(config.test?.testTimeout).toBe(9000);
expect(config.test?.hookTimeout).toBe(7000);
expect(config.test?.coverage?.include).toEqual(["lib/**/*.ts"]);
expect(config.test?.coverage?.exclude).toEqual(["lib/**/*.test.ts"]);
});
});

View File

@@ -0,0 +1,87 @@
import type { ViteUserConfig as UserConfig } from "vitest/config";
import z from "zod";
import { TEST_VITE_CODES, TestViteError } from "../error-codes/error-codes.js";
import { recordConfigBuilt } from "../telemetry/telemetry.js";
const EnvironmentSchema = z.enum([
"node",
"happy-dom",
"jsdom",
"edge-runtime",
]);
const VitestConfigOptionsSchema = z
.object({
environment: EnvironmentSchema.default("node"),
setupFiles: z.array(z.string()).default([]),
include: z
.array(z.string())
.default(["src/**/*.test.ts", "tests/**/*.test.ts"]),
exclude: z.array(z.string()).default(["dist/**", "node_modules/**"]),
fileParallelism: z.boolean().default(true),
coverageInclude: z.array(z.string()).default(["src/**/*.ts"]),
coverageExclude: z
.array(z.string())
.default([
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
]),
testTimeout: z.number().int().positive().default(5000),
hookTimeout: z.number().int().positive().default(5000),
})
.strict();
export type VitestConfigOptions = z.input<typeof VitestConfigOptionsSchema>;
/**
* Build a WSL2-safe Vitest `UserConfig` with Istanbul 100% thresholds.
*
* Defaults pinned by the genesis enterprise contract:
*
* - `pool: 'forks'` (vmThreads / threads fail under Bun on WSL2)
* - `coverage.provider: 'istanbul'` with 100/100/100/100 thresholds
* - `setupFiles` accepted from opts (default `[]`)
*
* @throws {TestViteError} with `TEST_VITE.INVALID_OPTIONS` on bad input.
*/
export function createVitestConfig(opts: VitestConfigOptions = {}): UserConfig {
const parsed = VitestConfigOptionsSchema.safeParse(opts);
if (!parsed.success) {
throw new TestViteError(
TEST_VITE_CODES.INVALID_OPTIONS,
"createVitestConfig: invalid options",
{ details: { issues: parsed.error.issues } }
);
}
const o = parsed.data;
const config: UserConfig = {
test: {
globals: false,
environment: o.environment,
pool: "forks",
setupFiles: o.setupFiles,
include: o.include,
exclude: o.exclude,
fileParallelism: o.fileParallelism,
testTimeout: o.testTimeout,
hookTimeout: o.hookTimeout,
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: o.coverageInclude,
exclude: o.coverageExclude,
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
};
recordConfigBuilt("vitest");
return config;
}

View File

@@ -0,0 +1,44 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkg = JSON.parse(
readFileSync(resolve(__dirname, "..", "package.json"), "utf8")
) as {
exports: Record<
string,
{ import?: { types?: string; default?: string } } | string
>;
};
const FEATURE_SUBPATHS = [
"./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",
];
describe("package.json publishes every feature subpath", () => {
it("package.json publishes every feature subpath", {
timeout: 5000,
}, () => {
for (const key of FEATURE_SUBPATHS) {
const entry = pkg.exports[key];
expect(entry).toBeDefined();
expect(typeof entry).toBe("object");
const obj = entry as {
import?: { types?: string; default?: string };
};
expect(obj.import?.types).toBeDefined();
expect(obj.import?.default).toBeDefined();
}
});
});

View File

@@ -0,0 +1,40 @@
/**
* Test setup for @suaveplan/test-vite.
*
* Registers a real in-memory OpenTelemetry MeterProvider BEFORE source
* modules import so the global meter resolved on first use binds to it,
* and provides a `webcrypto`-backed `globalThis.crypto` for plain Node runs.
*/
import { webcrypto } from "node:crypto";
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,61 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/devtools-tooling-test-vite",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"vite-library-config/index": "src/vite-library-config/index.ts",
"vite-app-config/index": "src/vite-app-config/index.ts",
"vitest-config/index": "src/vitest-config/index.ts",
"vitest-browser-config/index":
"src/vitest-browser-config/index.ts",
"browser-environment/index": "src/browser-environment/index.ts",
"tree-shake-validator/index":
"src/tree-shake-validator/index.ts",
"bundle-size-snapshot/index":
"src/bundle-size-snapshot/index.ts",
"port-allocator/index": "src/port-allocator/index.ts",
"hmr-tester/index": "src/hmr-tester/index.ts",
"matchers/index": "src/matchers/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"vite",
"vite-plugin-dts",
"vitest",
"vitest/config",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/types/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
hookTimeout: 5000,
},
});