Initial Commit
This commit is contained in:
@@ -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: [],
|
||||
};
|
||||
}
|
||||
15
packages/devtools/tooling/sandbox/src/telemetry/index.ts
Normal file
15
packages/devtools/tooling/sandbox/src/telemetry/index.ts
Normal 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";
|
||||
60
packages/devtools/tooling/sandbox/src/telemetry/telemetry.md
Normal file
60
packages/devtools/tooling/sandbox/src/telemetry/telemetry.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
76
packages/devtools/tooling/sandbox/src/telemetry/telemetry.ts
Normal file
76
packages/devtools/tooling/sandbox/src/telemetry/telemetry.ts
Normal 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 });
|
||||
}
|
||||
16
packages/devtools/tooling/sandbox/src/types/index.ts
Normal file
16
packages/devtools/tooling/sandbox/src/types/index.ts
Normal 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";
|
||||
124
packages/devtools/tooling/sandbox/src/types/sandbox.types.ts
Normal file
124
packages/devtools/tooling/sandbox/src/types/sandbox.types.ts
Normal 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;
|
||||
}
|
||||
1
packages/devtools/tooling/sandbox/src/vm-engine/index.ts
Normal file
1
packages/devtools/tooling/sandbox/src/vm-engine/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VmEngine } from "./vm-engine.js";
|
||||
86
packages/devtools/tooling/sandbox/src/vm-engine/vm-engine.md
Normal file
86
packages/devtools/tooling/sandbox/src/vm-engine/vm-engine.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
192
packages/devtools/tooling/sandbox/src/vm-engine/vm-engine.ts
Normal file
192
packages/devtools/tooling/sandbox/src/vm-engine/vm-engine.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkerEngine } from "./worker-engine.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
12
packages/devtools/tooling/sandbox/tsconfig.lib.json
Normal file
12
packages/devtools/tooling/sandbox/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
59
packages/devtools/tooling/sandbox/vite.config.ts
Normal file
59
packages/devtools/tooling/sandbox/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/devtools/tooling/sandbox/vitest.config.ts
Normal file
32
packages/devtools/tooling/sandbox/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
65
packages/devtools/tooling/test-vite/README.md
Normal file
65
packages/devtools/tooling/test-vite/README.md
Normal 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
|
||||
|
||||
131
packages/devtools/tooling/test-vite/package.json
Normal file
131
packages/devtools/tooling/test-vite/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BrowserEnvironmentOptions } from "./browser-environment.js";
|
||||
export { setupBrowserEnvironment } from "./browser-environment.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { SnapshotBundleSizeOptions } from "./bundle-size-snapshot.js";
|
||||
export { snapshotBundleSize } from "./bundle-size-snapshot.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { TestViteCode } from "./error-codes.js";
|
||||
export { TEST_VITE_CODES, TestViteError } from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
108
packages/devtools/tooling/test-vite/src/hmr-tester/hmr-tester.ts
Normal file
108
packages/devtools/tooling/test-vite/src/hmr-tester/hmr-tester.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { WithHmrOptions } from "./hmr-tester.js";
|
||||
export { withHMR } from "./hmr-tester.js";
|
||||
52
packages/devtools/tooling/test-vite/src/index.ts
Normal file
52
packages/devtools/tooling/test-vite/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { TestViteMatchers } from "./matchers.js";
|
||||
export { createMatchers } from "./matchers.js";
|
||||
61
packages/devtools/tooling/test-vite/src/matchers/matchers.md
Normal file
61
packages/devtools/tooling/test-vite/src/matchers/matchers.md
Normal 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.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
106
packages/devtools/tooling/test-vite/src/matchers/matchers.ts
Normal file
106
packages/devtools/tooling/test-vite/src/matchers/matchers.ts
Normal 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" };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { PortAllocatorOptions } from "./port-allocator.js";
|
||||
export { createPortAllocator } from "./port-allocator.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
16
packages/devtools/tooling/test-vite/src/telemetry/index.ts
Normal file
16
packages/devtools/tooling/test-vite/src/telemetry/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { VerifyTreeShakeOptions } from "./tree-shake-validator.js";
|
||||
export { verifyTreeShake } from "./tree-shake-validator.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ViteAppConfigOptions } from "./vite-app-config.js";
|
||||
export { createViteAppConfig } from "./vite-app-config.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ViteLibraryConfigOptions } from "./vite-library-config.js";
|
||||
export { createViteLibraryConfig } from "./vite-library-config.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { VitestBrowserConfigOptions } from "./vitest-browser-config.js";
|
||||
export { createVitestBrowserConfig } from "./vitest-browser-config.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { VitestConfigOptions } from "./vitest-config.js";
|
||||
export { createVitestConfig } from "./vitest-config.js";
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
44
packages/devtools/tooling/test-vite/tests/exports.test.ts
Normal file
44
packages/devtools/tooling/test-vite/tests/exports.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
40
packages/devtools/tooling/test-vite/tests/setup/polyfills.ts
Normal file
40
packages/devtools/tooling/test-vite/tests/setup/polyfills.ts
Normal 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;
|
||||
}
|
||||
12
packages/devtools/tooling/test-vite/tsconfig.lib.json
Normal file
12
packages/devtools/tooling/test-vite/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
61
packages/devtools/tooling/test-vite/vite.config.ts
Normal file
61
packages/devtools/tooling/test-vite/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/devtools/tooling/test-vite/vitest.config.ts
Normal file
33
packages/devtools/tooling/test-vite/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
134
packages/foundation/README.md
Normal file
134
packages/foundation/README.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# `foundation`
|
||||
|
||||
Foundational primitives — types, errors, configuration, data structures, and cross-cutting utilities.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Subcategories
|
||||
|
||||
9 subcategories, 62 packages.
|
||||
|
||||
### `barcode`
|
||||
|
||||
1-D barcode generation and parsing.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/barcode`](./barcode/README.md) | Runtime-agnostic 1D barcode generation for seven symbologies — Code 128B, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, and ITF. Outputs inline SVG strings and binary… |
|
||||
| [`@suaveplan/qrcode`](./qrcode/README.md) | QR code generation producing SVG, pixel matrix, terminal (Unicode blocks), and data URL representations. Implements QR code versions 1-10, error correction lev… |
|
||||
|
||||
### `bitpacker`
|
||||
|
||||
Bit-level binary packing, schemas, compression, and streaming codecs.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/bitpacker`](./bitpacker/README.md) | Core bit-level binary packing library. Provides `BitWriter`, `BitReader`, `BitPacker`, and a pluggable strategy system for schema-driven serialization. |
|
||||
| [`@suaveplan/bitpacker-compressed`](./bitpacker-compressed/README.md) | Compression pipeline integration for bitpacker. |
|
||||
| [`@suaveplan/bitpacker-crypto`](./bitpacker-crypto/README.md) | Bit-packer-aware crypto primitives. The shared interfaces (`Authority`, `DomainTag`, `AuthorityVerifier`, `AeadOps`) live in `@suaveplan/types`; the canonical… |
|
||||
| [`@suaveplan/bitpacker-delta`](./bitpacker-delta/README.md) | Delta encoding utilities for efficient repeated state transmission. Detects changes between successive values and encodes differences rather than full values. |
|
||||
| [`@suaveplan/bitpacker-game`](./bitpacker-game/README.md) | Game state utilities: quantization, prediction, interpolation, and client-server reconciliation. |
|
||||
| [`@suaveplan/bitpacker-network`](./bitpacker-network/README.md) | Network simulation utilities for testing bitpacker under realistic network conditions. |
|
||||
| [`@suaveplan/bitpacker-schema`](./bitpacker-schema/README.md) | Schema registry and validation for bitpacker. |
|
||||
| [`@suaveplan/bitpacker-strategies`](./bitpacker-strategies/README.md) | Built-in packing strategies for `@suaveplan/bitpacker`. Provides strategies for numbers, strings, booleans, objects, arrays, enums, optional fields, oneOf unio… |
|
||||
| [`@suaveplan/bitpacker-streaming`](./bitpacker-streaming/README.md) | Streaming APIs for bitpacker including chunked packing/unpacking and true TransformStream support with framing. |
|
||||
| [`@suaveplan/bitpacker-validation`](./bitpacker-validation/README.md) | Safety limits and validation reports for bitpacker schemas. |
|
||||
|
||||
### `core`
|
||||
|
||||
Core foundation — types, errors, configuration, schema registry, and utilities.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/config`](./config/README.md) | Layered configuration management for Suaveplan. Resolves an ordered set of typed layers (defaults, file, object, env, CLI) in priority order, deep-merges the r… |
|
||||
| [`@suaveplan/env`](./env/README.md) | Type-safe, schema-first environment variable loading for Node.js and edge runtimes. Define a schema of typed field builders, point it at your sources, and rece… |
|
||||
| [`@suaveplan/error`](./error/README.md) | Foundation error substrate for Suaveplan. Every non-testing `@suaveplan/*` package extends this library's `SuaveplanError` base class and uses its stable code… |
|
||||
| [`@suaveplan/schema-registry`](./schema-registry/README.md) | Versioned schema registry with forward/backward/full compatibility checking for evolving data contracts. Register schemas by subject name, enforce evolution ru… |
|
||||
| [`@suaveplan/types`](./types/README.md) | Types-only foundation package. Zero runtime code, zero dependencies, zero bundle bytes. Provides the canonical shared interfaces that allow every `@suaveplan/*… |
|
||||
| [`@suaveplan/typescript-config`](./typescript-config/README.md) | Shared TypeScript configuration presets for Suaveplan. This package ships composable JSON presets consumed via `"extends"` across every workspace package — no… |
|
||||
| [`@suaveplan/utils`](./utils/README.md) | Zero-external-dependency utility library for Suaveplan. Covers debug helpers, deep object manipulation, platform detection, IEEE 754 float packing, pseudo-rand… |
|
||||
|
||||
### `data`
|
||||
|
||||
Data manipulation, masking, deduplication, diffing, and byte utilities.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/bytes`](./bytes/README.md) | Low-level byte utilities for Suaveplan: lossless hex/base64/UTF-8 encoding, `Uint8Array` manipulation, and UUID conversion. No native addons; depends only on p… |
|
||||
| [`@suaveplan/clipboard`](./clipboard/README.md) | Enterprise Clipboard API abstraction for browser-tier consumers. Wraps the async Clipboard API with a textarea + `execCommand` fallback, a paste-event read fal… |
|
||||
| [`@suaveplan/data-masking`](./data-masking/README.md) | PII/sensitive data masking for objects and strings with field-level rules, nested traversal, eight masking strategies, and OpenTelemetry instrumentation. |
|
||||
| [`@suaveplan/dedup`](./dedup/README.md) | Entity deduplication engine for detecting and merging duplicate records in any data set. Given a list of objects with varying representations of the same real-… |
|
||||
| [`@suaveplan/diff`](./diff/README.md) | Zero-dependency RFC 6902 JSON Patch engine for computing, applying, and inverting structural differences between JSON documents. The `diff(a, b)` function prod… |
|
||||
| [`@suaveplan/json`](./json/README.md) | Safe, enterprise-grade JSON utilities with prototype-pollution protection, circular-reference detection, and OpenTelemetry instrumentation. |
|
||||
| [`@suaveplan/media`](./media/README.md) | Universal media detection, metadata extraction, and constraint validation. Combines a pluggable magic-byte `SignatureRegistry` with the MIME database from… |
|
||||
| [`@suaveplan/mime`](./mime/README.md) | Structured MIME-type detection, parsing, and registry for the Suaveplan platform. Provides RFC 7231 Content-Type parsing, magic-byte file signature detection,… |
|
||||
| [`@suaveplan/packer`](./packer/README.md) | Binary packing/unpacking utilities. Two modes: |
|
||||
| [`@suaveplan/packer-browser`](./packer-browser/README.md) | Browser-tier runtime for `@suaveplan/packer` archives. Ships a Service Worker interceptor, a `PackedResourceManager` (LRU + Blob-URL bookkeeping + ref-count +… |
|
||||
| [`@suaveplan/pagination`](./pagination/README.md) | Universal pagination primitives for cursor-based, offset-based, and keyset-based (seek) pagination strategies. The package covers the full pagination surface:… |
|
||||
| [`@suaveplan/pwa`](./pwa/README.md) | Enterprise browser-tier PWA primitives for Suaveplan applications. |
|
||||
| [`@suaveplan/react-packer`](./react-packer/README.md) | React 19 companion for `@suaveplan/packer-browser`. Ships `usePackedResource`, `usePackedImage`, `usePackedAudio`, `usePackedVideo`, and `usePackedFont` hooks… |
|
||||
| [`@suaveplan/serialize`](./serialize/README.md) | Round-trip-preserving structured serialization for JavaScript's rich type system. The standard `JSON.stringify`/`JSON.parse` pair silently drops `Date` objects… |
|
||||
| [`@suaveplan/templates`](./templates/README.md) | A plugin-based string template engine for TypeScript. Resolves `{variable}` and `{variable:transform}` tokens in strings using a priority-sorted, regex-scan pi… |
|
||||
| [`@suaveplan/validation`](./validation/README.md) | Runtime schema validation with a Zod-compatible API. This package is a self-contained parse engine — it does not import Zod or any other validation library — s… |
|
||||
| [`@suaveplan/web-workers`](./web-workers/README.md) | Enterprise browser-tier runtime for managing Web Workers, SharedWorkers, and fixed-size worker pools with typed RPC, AbortSignal-aware cancellation, transfer-l… |
|
||||
|
||||
### `etl`
|
||||
|
||||
Extract-Transform-Load pipeline engine with typed stages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/etl`](./etl/README.md) | Extract-Transform-Load pipeline engine with typed stages, error handling, observable progress, and OTel instrumentation. |
|
||||
|
||||
### `feature-flags`
|
||||
|
||||
Feature-flag evaluation engine with percentage and attribute targeting.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/feature-flags`](./feature-flags/README.md) | Provider-agnostic feature flag evaluation engine for controlling feature rollout without code deploys. The package ships a `FlagManager` high-level façade, a `… |
|
||||
|
||||
### `flow`
|
||||
|
||||
Finite state machines, CRDTs, idempotency, and CBOR serialization.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cbor`](./cbor/README.md) | RFC 8949 (CBOR) encoder + decoder, RFC 8742 (CBOR Sequences), pull-style streaming decoder (`AsyncIterable<Uint8Array>` / `ReadableStream<Uint8Array>`), RFC 90… |
|
||||
| [`@suaveplan/crdt`](./crdt/README.md) | Conflict-free Replicated Data Types (CRDTs) for distributed systems. Provides eight composable CRDT primitives plus a vector-clock and a sync manager that coor… |
|
||||
| [`@suaveplan/fsm`](./fsm/README.md) | Typed, generic finite state machine (FSM) for TypeScript. Ships a synchronous-by-default interpreter with guard predicates, ordered transition actions, context… |
|
||||
| [`@suaveplan/idempotency`](./idempotency/README.md) | Idempotency key guard and deduplication for async operations. The package provides a complete `pending → completed \| failed` state machine, three store backend… |
|
||||
| [`@suaveplan/job-scheduler`](./job-scheduler/README.md) | Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OpenTelemetry observability. Built on `@suaveplan/… |
|
||||
| [`@suaveplan/queue`](./queue/README.md) | Enterprise-grade queue management: memory/priority/delayed queues, concurrent workers with retry and dead-letter forwarding, job state machines, and OTel instr… |
|
||||
| [`@suaveplan/queue-distribution`](./queue-distribution/README.md) | Worker-pool distribution layer: load-balancing strategies, worker pool management, manager with retry and health checks, in-process `LocalWorker`. Transport-ag… |
|
||||
| [`@suaveplan/queue-distribution-http`](./queue-distribution-http/README.md) | HTTP transport plugin for `@suaveplan/queue-distribution`. Ships `HttpWorkerCommunicator` (a fetch-backed `WorkerCommunicator` with a Zod-validated wire envelo… |
|
||||
| [`@suaveplan/queue-distribution-worker-thread`](./queue-distribution-worker-thread/README.md) | Web-Worker / Node `worker_threads` transport plugin for `@suaveplan/queue-distribution`. Ships `WorkerThreadCommunicator` (a `ThreadWorkerLike`-based message b… |
|
||||
| [`@suaveplan/rate-limiter`](./rate-limiter/README.md) | Enterprise rate limiter with three algorithms, rule-based routing, tenant multipliers, pluggable storage, and OTel instrumentation. |
|
||||
| [`@suaveplan/retry`](./retry/README.md) | Retry strategies and circuit-breaker for async operations. The package provides constant, linear, and exponential backoff with four jitter modes (full, equal,… |
|
||||
| [`@suaveplan/rules`](./rules/README.md) | Universal rule engine + validation system. Comprehensive `RuleEngine` with async evaluation, depth limit, AbortSignal, plugin registration; fluent `RuleBu… |
|
||||
| [`@suaveplan/rules-hono`](./rules-hono/README.md) | Hono middleware wrapping `@suaveplan/rules`. Six middleware factories — `ruleMiddleware`, `validationMiddleware`, `accessControl`, `responseValidation`, `rateL… |
|
||||
| [`@suaveplan/streams`](./streams/README.md) | WHATWG-Streams-based primitives: backpressure-first transforms, zero-copy `Uint8Array` operations, `AbortSignal` integration end-to-end, structured errors, OTe… |
|
||||
| [`@suaveplan/streams-chunk-queue`](./streams-chunk-queue/README.md) | Universal out-of-order chunk reconstitution. Accepts chunks for a file in any order and exposes a sequential `ReadableStream<Uint8Array>` consumer surface… |
|
||||
| [`@suaveplan/streams-metadata`](./streams-metadata/README.md) | Universal stream transforms that detect MIME type and extract EXIF/IPTC/XMP image metadata from buffered `Uint8Array` payloads. Three composable transform… |
|
||||
| [`@suaveplan/undo`](./undo/README.md) | Enterprise-grade undo/redo engine with branching history, async commands, history compression, selective undo, and command merging. Built for collaborative edi… |
|
||||
| [`@suaveplan/workflow`](./workflow/README.md) | Workflow and pipeline orchestration: sequential and parallel step execution, conditional branching with guard functions, retry strategies, timeouts, saga-patte… |
|
||||
|
||||
### `random`
|
||||
|
||||
Pseudo-random generators, noise functions, and deterministic sequences.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/noise`](./noise/README.md) | Seeded spatial noise fields for procedural content generation. Provides six field types — Perlin, Simplex (2D/3D/4D), Value, Worley/cellular (F1/F2/F2-F1 modes… |
|
||||
| [`@suaveplan/pseudo-random`](./pseudo-random/README.md) | Deterministic, seeded pseudo-random number generators (PRNGs) conforming to the `Rng` interface from `@suaveplan/types`. Ships four algorithms — Mulberry32, SF… |
|
||||
| [`@suaveplan/random`](./random/README.md) | High-level cryptographically secure randomness for application use. This is the everyday random package — it wraps the CSPRNG with ergonomic helpers for every… |
|
||||
|
||||
### `timing`
|
||||
|
||||
Cron, stopwatches, throttling, debouncing, and scheduling.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cron`](./cron/README.md) | Universal cron expression parser, next-fire calculator with timezone + DST awareness, and an opt-in lightweight in-process scheduler. Zero external cron d… |
|
||||
| [`@suaveplan/datetime`](./datetime/README.md) | Timezone-aware date/time utilities covering the most common application-layer date operations that the native `Date` API leaves unaddressed. The package provid… |
|
||||
| [`@suaveplan/timing`](./timing/README.md) | Timing and scheduling utilities for application-layer time management. The package covers measurement (stopwatch with lap recording), rate control (throttle an… |
|
||||
<!-- end:auto-docs -->
|
||||
3
packages/foundation/_intro.md
Normal file
3
packages/foundation/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `foundation`
|
||||
|
||||
Foundational primitives — types, errors, configuration, data structures, and cross-cutting utilities.
|
||||
14
packages/foundation/barcode/README.md
Normal file
14
packages/foundation/barcode/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# `barcode`
|
||||
|
||||
1-D barcode generation and parsing.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `barcode`
|
||||
|
||||
2 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/barcode`](./barcode/README.md) | Runtime-agnostic 1D barcode generation for seven symbologies — Code 128B, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, and ITF. Outputs inline SVG strings and binary… |
|
||||
| [`@suaveplan/qrcode`](./qrcode/README.md) | QR code generation producing SVG, pixel matrix, terminal (Unicode blocks), and data URL representations. Implements QR code versions 1-10, error correction lev… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/foundation/barcode/_intro.md
Normal file
1
packages/foundation/barcode/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
1-D barcode generation and parsing.
|
||||
132
packages/foundation/barcode/barcode/README.md
Normal file
132
packages/foundation/barcode/barcode/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# @suaveplan/barcode - Barcode generation and parsing for common 1D symbologies: Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, ITF. Outputs SVG strings and pixel arrays.
|
||||
|
||||
## Summary
|
||||
|
||||
Runtime-agnostic 1D barcode generation for seven symbologies — Code 128B, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, and ITF. Outputs inline SVG strings and binary bar-pattern arrays with no canvas, no DOM, and no Node.js built-ins required, making it safe to use in Bun, Deno, edge runtimes, and browsers alike. Includes check-digit calculation, barcode validation, and human-readable text rendering. Use this package to generate product labels, shipping labels (ITF/GTIN-14), retail barcodes (EAN/UPC), and document barcodes (Code 128). For 2D barcodes (QR codes) use `@suaveplan/qrcode`. This package does not support barcode scanning/decoding from images — it is generation-only.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [encoder](./src/core/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [encoder](./src/core/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [barcode-error](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [code128](./src/symbologies/code128.md) — Implements Code 128 Subset B encoding for the full printable ASCII range (characters 32–127). This module produces a bi…
|
||||
- [code39](./src/symbologies/code39.md) — Implements Code 39 barcode encoding for the standard 43-character set: digits 0–9, uppercase letters A–Z, and the speci…
|
||||
- [ean13](./src/symbologies/ean13.md) — Implements EAN-13 (International Article Number, 13-digit variant) barcode encoding following the ISO/IEC 15420 and GS1…
|
||||
- [ean8](./src/symbologies/ean8.md) — Implements EAN-8 barcode encoding for small retail packages where space constraints make EAN-13 impractical. EAN-8 enco…
|
||||
- [itf](./src/symbologies/itf.md) — Implements ITF (Interleaved 2 of 5) barcode encoding for numeric-only data. ITF is widely used in logistics, shipping,…
|
||||
- [upca](./src/symbologies/upca.md) — Implements UPC-A barcode encoding and validation. UPC-A is the primary barcode format used in North American retail, en…
|
||||
- [upce](./src/symbologies/upce.md) — Implements UPC-E barcode encoding for the compressed 6-digit UPC symbology used on small retail packages where space fo…
|
||||
<!-- end:auto-docs --> - [encoder](./src/core/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [barcode-error](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [code128](./src/symbologies/code128.md) — Implements Code 128 Subset B encoding for the full printable ASCII range (characters 32–127). This module produces a bi…
|
||||
- [code39](./src/symbologies/code39.md) — Implements Code 39 barcode encoding for the standard 43-character set: digits 0–9, uppercase letters A–Z, and the speci…
|
||||
- [ean13](./src/symbologies/ean13.md) — Implements EAN-13 (International Article Number, 13-digit variant) barcode encoding following the ISO/IEC 15420 and GS1…
|
||||
- [ean8](./src/symbologies/ean8.md) — Implements EAN-8 barcode encoding for small retail packages where space constraints make EAN-13 impractical. EAN-8 enco…
|
||||
- [itf](./src/symbologies/itf.md) — Implements ITF (Interleaved 2 of 5) barcode encoding for numeric-only data. ITF is widely used in logistics, shipping,…
|
||||
- [upca](./src/symbologies/upca.md) — Implements UPC-A barcode encoding and validation. UPC-A is the primary barcode format used in North American retail, en…
|
||||
- [upce](./src/symbologies/upce.md) — Implements UPC-E barcode encoding for the compressed 6-digit UPC symbology used on small retail packages where space fo…
|
||||
- [encoder](./src/core/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [barcode-error](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [code128](./src/symbologies/code128.md) — Implements Code 128 Subset B encoding for the full printable ASCII range (characters 32–127). This module produces a bi…
|
||||
- [code39](./src/symbologies/code39.md) — Implements Code 39 barcode encoding for the standard 43-character set: digits 0–9, uppercase letters A–Z, and the speci…
|
||||
- [ean13](./src/symbologies/ean13.md) — Implements EAN-13 (International Article Number, 13-digit variant) barcode encoding following the ISO/IEC 15420 and GS1…
|
||||
- [ean8](./src/symbologies/ean8.md) — Implements EAN-8 barcode encoding for small retail packages where space constraints make EAN-13 impractical. EAN-8 enco…
|
||||
- [itf](./src/symbologies/itf.md) — Implements ITF (Interleaved 2 of 5) barcode encoding for numeric-only data. ITF is widely used in logistics, shipping,…
|
||||
- [upca](./src/symbologies/upca.md) — Implements UPC-A barcode encoding and validation. UPC-A is the primary barcode format used in North American retail, en…
|
||||
- [upce](./src/symbologies/upce.md) — Implements UPC-E barcode encoding for the compressed 6-digit UPC symbology used on small retail packages where space fo…
|
||||
- [encoder](./src/core/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [barcode-error](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [code128](./src/symbologies/code128.md) — Implements Code 128 Subset B encoding for the full printable ASCII range (characters 32–127). This module produces a bi…
|
||||
- [code39](./src/symbologies/code39.md) — Implements Code 39 barcode encoding for the standard 43-character set: digits 0–9, uppercase letters A–Z, and the speci…
|
||||
- [ean13](./src/symbologies/ean13.md) — Implements EAN-13 (International Article Number, 13-digit variant) barcode encoding following the ISO/IEC 15420 and GS1…
|
||||
- [ean8](./src/symbologies/ean8.md) — Implements EAN-8 barcode encoding for small retail packages where space constraints make EAN-13 impractical. EAN-8 enco…
|
||||
- [itf](./src/symbologies/itf.md) — Implements ITF (Interleaved 2 of 5) barcode encoding for numeric-only data. ITF is widely used in logistics, shipping,…
|
||||
- [upca](./src/symbologies/upca.md) — Implements UPC-A barcode encoding and validation. UPC-A is the primary barcode format used in North American retail, en…
|
||||
- [upce](./src/symbologies/upce.md) — Implements UPC-E barcode encoding for the compressed 6-digit UPC symbology used on small retail packages where space fo…
|
||||
- [encoder](./src/core/encoder/encoder.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [barcode-error](./src/errors/barcode-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [code128](./src/symbologies/code128.md) — Implements Code 128 Subset B encoding for the full printable ASCII range (characters 32–127). This module produces a bi…
|
||||
- [code39](./src/symbologies/code39.md) — Implements Code 39 barcode encoding for the standard 43-character set: digits 0–9, uppercase letters A–Z, and the speci…
|
||||
- [ean13](./src/symbologies/ean13.md) — Implements EAN-13 (International Article Number, 13-digit variant) barcode encoding following the ISO/IEC 15420 and GS1…
|
||||
- [ean8](./src/symbologies/ean8.md) — Implements EAN-8 barcode encoding for small retail packages where space constraints make EAN-13 impractical. EAN-8 enco…
|
||||
- [itf](./src/symbologies/itf.md) — Implements ITF (Interleaved 2 of 5) barcode encoding for numeric-only data. ITF is widely used in logistics, shipping,…
|
||||
- [upca](./src/symbologies/upca.md) — Implements UPC-A barcode encoding and validation. UPC-A is the primary barcode format used in North American retail, en…
|
||||
- [upce](./src/symbologies/upce.md) — Implements UPC-E barcode encoding for the compressed 6-digit UPC symbology used on small retail packages where space fo…
|
||||
|
||||
## Symbologies
|
||||
|
||||
| Symbology | Key | Description |
|
||||
|-----------|-----|-------------|
|
||||
| Code 128B | `code128` | ASCII 32–127; variable length; most versatile 1D format |
|
||||
| Code 39 | `code39` | 0–9, A–Z, special chars; industrial/logistics |
|
||||
| EAN-13 | `ean13` | 13-digit retail standard (ISBN-13, ISSN-13) |
|
||||
| EAN-8 | `ean8` | 8-digit compact retail for small packages |
|
||||
| UPC-A | `upca` | 12-digit North American retail |
|
||||
| UPC-E | `upce` | 8-digit compressed UPC-A for small packages |
|
||||
| ITF | `itf` | Interleaved 2 of 5; even-digit numeric; logistics/GTIN-14 |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/barcode
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { encode } from "@suaveplan/barcode";
|
||||
|
||||
const result = encode("Hello World", "code128");
|
||||
console.log(result.svg); // <svg ...>...</svg>
|
||||
console.log(result.width); // pixel width
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `encode(data, symbology, options?): BarcodeResult`
|
||||
|
||||
Main encoding function. Dispatches to the per-symbology encoder and renders an SVG.
|
||||
|
||||
```ts
|
||||
import { encode } from "@suaveplan/barcode";
|
||||
|
||||
const result = encode("5901234123457", "ean13", {
|
||||
width: 3, // bar width multiplier (default 2)
|
||||
height: 80, // bar height in pixels (default 100)
|
||||
margin: 15, // quiet zone (default 10)
|
||||
includeText: true, // render human-readable text (default true)
|
||||
color: "#000000",
|
||||
background: "#ffffff",
|
||||
});
|
||||
```
|
||||
|
||||
### `validateBarcode(data, symbology): boolean`
|
||||
|
||||
```ts
|
||||
import { validateBarcode } from "@suaveplan/barcode";
|
||||
validateBarcode("5901234123457", "ean13"); // true
|
||||
```
|
||||
|
||||
### `calculateCheckDigit(data, symbology): string`
|
||||
|
||||
```ts
|
||||
import { calculateCheckDigit } from "@suaveplan/barcode";
|
||||
calculateCheckDigit("590123412345", "ean13"); // "7"
|
||||
```
|
||||
|
||||
### `parse(data, symbology): ParseResult`
|
||||
|
||||
```ts
|
||||
import { parse } from "@suaveplan/barcode";
|
||||
const r = parse("5901234123457", "ean13");
|
||||
// { symbology: "ean13", data: "...", valid: true, checkDigit: "7" }
|
||||
```
|
||||
|
||||
## Module Docs
|
||||
|
||||
- [`src/errors/barcode-error.md`](src/errors/barcode-error.md) — Error class and code registry
|
||||
- [`src/symbologies/code128.md`](src/symbologies/code128.md) — Code 128B encoding
|
||||
- [`src/symbologies/code39.md`](src/symbologies/code39.md) — Code 39 encoding
|
||||
- [`src/symbologies/ean13.md`](src/symbologies/ean13.md) — EAN-13 encoding
|
||||
- [`src/symbologies/ean8.md`](src/symbologies/ean8.md) — EAN-8 encoding
|
||||
- [`src/symbologies/upca.md`](src/symbologies/upca.md) — UPC-A encoding
|
||||
- [`src/symbologies/upce.md`](src/symbologies/upce.md) — UPC-E encoding
|
||||
- [`src/symbologies/itf.md`](src/symbologies/itf.md) — ITF encoding
|
||||
- [`src/core/encoder/encoder.md`](src/core/encoder/encoder.md) — Central encoder and SVG renderer
|
||||
- [`src/core/validator/validator.md`](src/core/validator/validator.md) — Validation and parsing facade
|
||||
100
packages/foundation/barcode/barcode/package.json
Normal file
100
packages/foundation/barcode/barcode/package.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "@suaveplan/barcode",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Barcode generation and parsing for common 1D symbologies: Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, ITF. Outputs SVG strings and pixel arrays.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./symbologies": {
|
||||
"import": {
|
||||
"types": "./dist/symbologies/index.d.ts",
|
||||
"default": "./dist/symbologies/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/symbologies/index.d.ts",
|
||||
"default": "./dist/symbologies/index.js"
|
||||
}
|
||||
},
|
||||
"./encoder": {
|
||||
"import": {
|
||||
"types": "./dist/core/encoder/encoder.d.ts",
|
||||
"default": "./dist/core/encoder/encoder.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/encoder/encoder.d.ts",
|
||||
"default": "./dist/core/encoder/encoder.js"
|
||||
}
|
||||
},
|
||||
"./validator": {
|
||||
"import": {
|
||||
"types": "./dist/core/validator/validator.d.ts",
|
||||
"default": "./dist/core/validator/validator.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/validator/validator.d.ts",
|
||||
"default": "./dist/core/validator/validator.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/barcode-error.d.ts",
|
||||
"default": "./dist/errors/barcode-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/barcode-error.d.ts",
|
||||
"default": "./dist/errors/barcode-error.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",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# encoder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the central barcode encoding pipeline for `@suaveplan/barcode`. It dispatches input data to the correct per-symbology encoder, then renders the resulting binary bar pattern into a complete, self-contained SVG string. The encoder is the primary entry point for most consumer use cases — callers only need `encode(data, symbology, options)`.
|
||||
|
||||
## Features
|
||||
|
||||
- `encode(data, symbology, options?)` — full pipeline: dispatch → binary encoding → SVG rendering.
|
||||
- `renderSvg(encoded, data, opts)` — exposed for testing and advanced pipelines that need SVG rendering without re-encoding.
|
||||
- Run-length encoding of consecutive bits for compact `<rect>` output.
|
||||
- XML-safe text rendering via `escapeXml`.
|
||||
- Configurable bar width, bar height, margin, text inclusion, and colours via `BarcodeOptions`.
|
||||
- Defaults: width=2, height=100, margin=10, includeText=true, color=#000000, background=#ffffff.
|
||||
- Throws `BarcodeError` with stable codes on invalid input or unsupported symbology.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { encode } from "@suaveplan/barcode/encoder";
|
||||
|
||||
const result = encode("Hello World", "code128");
|
||||
console.log(result.svg); // complete <svg>...</svg> string
|
||||
console.log(result.width); // total pixel width
|
||||
console.log(result.encoded); // binary bar pattern
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { encode, renderSvg } from "@suaveplan/barcode/encoder";
|
||||
|
||||
// Custom rendering options
|
||||
const result = encode("5901234123457", "ean13", {
|
||||
width: 3,
|
||||
height: 80,
|
||||
margin: 15,
|
||||
includeText: false,
|
||||
color: "#1a1a2e",
|
||||
background: "#e8f4f8",
|
||||
});
|
||||
|
||||
// Separate render pass with modified options
|
||||
const customSvg = renderSvg(result.encoded, result.data, {
|
||||
width: 1,
|
||||
height: 50,
|
||||
margin: 5,
|
||||
includeText: true,
|
||||
color: "#000000",
|
||||
background: "#ffffff",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `encode(data: string, symbology: BarcodeSymbology, options?: BarcodeOptions): BarcodeResult`
|
||||
|
||||
Main entry point for barcode generation.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `string` | Raw data to encode |
|
||||
| `symbology` | `BarcodeSymbology` | Target symbology (`"code128"`, `"ean13"`, etc.) |
|
||||
| `options` | `BarcodeOptions` | Optional rendering configuration |
|
||||
|
||||
Returns `BarcodeResult` with fields: `symbology`, `data`, `encoded`, `svg`, `width`, `height`.
|
||||
|
||||
**Throws** `BarcodeError(ENCODE_FAILED)` if the underlying encoder fails, or `BarcodeError(UNSUPPORTED_SYMBOLOGY)` for unknown symbology values.
|
||||
|
||||
### `renderSvg(encoded: string, data: string, opts: Required<BarcodeOptions>): string`
|
||||
|
||||
Renders a pre-computed binary string to SVG.
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `encoded` | Binary string: `'1'` = bar, `'0'` = space |
|
||||
| `data` | Human-readable label text (rendered below bars when `includeText` is true) |
|
||||
| `opts` | Fully-resolved options (no optional fields) |
|
||||
|
||||
Returns a complete `<svg>` element string suitable for embedding in HTML or writing to a file.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Run-length encoding groups consecutive identical bits before emitting `<rect>` elements, reducing the number of SVG elements for long runs (e.g. quiet zones, wide bars). Text rendering uses `escapeXml` to prevent SVG injection when the data string contains `<`, `>`, `"`, `'`, or `&`. The background rectangle is always rendered first so bar rectangles appear on top. The total height includes bar height + optional text height (14px) + two margin widths (top and bottom).
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BARCODE_CODES, BarcodeError } from "../../errors/barcode-error.js";
|
||||
import { encode, renderSvg } from "./encoder.js";
|
||||
|
||||
describe("encoder — encode", () => {
|
||||
it("returns a BarcodeResult for code128", { timeout: 30000 }, () => {
|
||||
const result = encode("Hello", "code128");
|
||||
expect(result.symbology).toBe("code128");
|
||||
expect(result.data).toBe("Hello");
|
||||
expect(typeof result.encoded).toBe("string");
|
||||
expect(/^[01]+$/.test(result.encoded)).toBe(true);
|
||||
expect(result.svg).toContain("<svg");
|
||||
expect(result.svg).toContain("</svg>");
|
||||
expect(result.width).toBeGreaterThan(0);
|
||||
expect(result.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns valid SVG for ean8", { timeout: 30000 }, () => {
|
||||
const result = encode("12345678", "ean8");
|
||||
expect(result.svg).toContain("<svg");
|
||||
expect(result.svg).toContain("<rect");
|
||||
expect(result.symbology).toBe("ean8");
|
||||
});
|
||||
|
||||
it("returns valid SVG for ean13", { timeout: 30000 }, () => {
|
||||
const result = encode("5901234123457", "ean13");
|
||||
expect(result.svg).toContain("<svg");
|
||||
expect(result.encoded.length).toBe(95);
|
||||
});
|
||||
|
||||
it("returns valid SVG for code39", { timeout: 30000 }, () => {
|
||||
const result = encode("TEST", "code39");
|
||||
expect(result.svg).toContain("<svg");
|
||||
expect(result.data).toBe("TEST");
|
||||
});
|
||||
|
||||
it("returns valid SVG for upca", { timeout: 30000 }, () => {
|
||||
const result = encode("036000291452", "upca");
|
||||
expect(result.svg).toContain("<svg");
|
||||
});
|
||||
|
||||
it("returns valid SVG for upce", { timeout: 30000 }, () => {
|
||||
const result = encode("01234565", "upce");
|
||||
expect(result.svg).toContain("<svg");
|
||||
});
|
||||
|
||||
it("returns valid SVG for itf", { timeout: 30000 }, () => {
|
||||
const result = encode("12345678", "itf");
|
||||
expect(result.svg).toContain("<svg");
|
||||
});
|
||||
|
||||
it("SVG contains rect elements", { timeout: 30000 }, () => {
|
||||
const result = encode("HELLO", "code128");
|
||||
const rectMatches = result.svg.match(/<rect/g);
|
||||
expect(rectMatches).not.toBeNull();
|
||||
expect((rectMatches ?? []).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("SVG contains text when includeText is true", { timeout: 30000 }, () => {
|
||||
const result = encode("Hello", "code128", { includeText: true });
|
||||
expect(result.svg).toContain("<text");
|
||||
expect(result.svg).toContain("Hello");
|
||||
});
|
||||
|
||||
it("SVG does not contain text when includeText is false", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const result = encode("Hello", "code128", { includeText: false });
|
||||
expect(result.svg).not.toContain("<text");
|
||||
});
|
||||
|
||||
it("respects custom width multiplier", { timeout: 30000 }, () => {
|
||||
const r1 = encode("A", "code128", { width: 1 });
|
||||
const r2 = encode("A", "code128", { width: 4 });
|
||||
expect(r2.width).toBeGreaterThan(r1.width);
|
||||
});
|
||||
|
||||
it("respects custom height", { timeout: 30000 }, () => {
|
||||
const r1 = encode("A", "code128", { height: 50 });
|
||||
const r2 = encode("A", "code128", { height: 200 });
|
||||
expect(r2.height).toBeGreaterThan(r1.height);
|
||||
});
|
||||
|
||||
it("SVG escapes XML special characters in data", { timeout: 30000 }, () => {
|
||||
const result = encode("A&B<C>D", "code128", { includeText: true });
|
||||
expect(result.svg).toContain("&");
|
||||
expect(result.svg).toContain("<");
|
||||
expect(result.svg).toContain(">");
|
||||
expect(result.svg).not.toContain("A&B"); // raw unescaped
|
||||
});
|
||||
|
||||
it("throws UNSUPPORTED_SYMBOLOGY for unknown symbology", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
// @ts-expect-error intentional bad input
|
||||
expect(() => encode("test", "unknown")).toThrow(BarcodeError);
|
||||
try {
|
||||
// @ts-expect-error intentional bad input
|
||||
encode("test", "unknown");
|
||||
} catch (e) {
|
||||
expect((e as BarcodeError).code).toBe(
|
||||
BARCODE_CODES.UNSUPPORTED_SYMBOLOGY
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encoder — renderSvg", () => {
|
||||
it("renders a simple binary pattern to SVG", { timeout: 5000 }, () => {
|
||||
const svg = renderSvg("10110", "test", {
|
||||
width: 2,
|
||||
height: 50,
|
||||
margin: 5,
|
||||
includeText: false,
|
||||
color: "#000000",
|
||||
background: "#ffffff",
|
||||
});
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("</svg>");
|
||||
expect(svg).toContain("<rect");
|
||||
});
|
||||
|
||||
it("uses custom color", { timeout: 5000 }, () => {
|
||||
const svg = renderSvg("1", "x", {
|
||||
width: 2,
|
||||
height: 50,
|
||||
margin: 5,
|
||||
includeText: false,
|
||||
color: "#ff0000",
|
||||
background: "#ffffff",
|
||||
});
|
||||
expect(svg).toContain("#ff0000");
|
||||
});
|
||||
|
||||
it("includes text element when includeText is true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const svg = renderSvg("10", "label", {
|
||||
width: 2,
|
||||
height: 50,
|
||||
margin: 5,
|
||||
includeText: true,
|
||||
color: "#000000",
|
||||
background: "#ffffff",
|
||||
});
|
||||
expect(svg).toContain("<text");
|
||||
expect(svg).toContain("label");
|
||||
});
|
||||
|
||||
it("all-zero pattern produces only background rect", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const svg = renderSvg("000", "x", {
|
||||
width: 2,
|
||||
height: 50,
|
||||
margin: 5,
|
||||
includeText: false,
|
||||
color: "#000000",
|
||||
background: "#ffffff",
|
||||
});
|
||||
// Should have bg rect but no bar rects
|
||||
const rects = svg.match(/<rect/g) ?? [];
|
||||
expect(rects.length).toBe(1); // only background
|
||||
});
|
||||
});
|
||||
165
packages/foundation/barcode/barcode/src/core/encoder/encoder.ts
Normal file
165
packages/foundation/barcode/barcode/src/core/encoder/encoder.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Central barcode encoder that dispatches to per-symbology encoders
|
||||
* and renders the resulting binary pattern to an SVG string.
|
||||
*/
|
||||
|
||||
import { BARCODE_CODES, BarcodeError } from "../../errors/barcode-error.js";
|
||||
import { encode as encodeCode39 } from "../../symbologies/code39.js";
|
||||
import { encode as encodeCode128 } from "../../symbologies/code128.js";
|
||||
import { encode as encodeEan8 } from "../../symbologies/ean8.js";
|
||||
import { encode as encodeEan13 } from "../../symbologies/ean13.js";
|
||||
import { encode as encodeItf } from "../../symbologies/itf.js";
|
||||
import { encode as encodeUpcA } from "../../symbologies/upca.js";
|
||||
import { encode as encodeUpcE } from "../../symbologies/upce.js";
|
||||
import type {
|
||||
BarcodeOptions,
|
||||
BarcodeResult,
|
||||
BarcodeSymbology,
|
||||
} from "../../types/barcode.types.js";
|
||||
|
||||
const DEFAULT_OPTIONS: Required<BarcodeOptions> = {
|
||||
width: 2,
|
||||
height: 100,
|
||||
margin: 10,
|
||||
includeText: true,
|
||||
color: "#000000",
|
||||
background: "#ffffff",
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge caller-supplied options with defaults.
|
||||
*/
|
||||
function resolveOptions(options?: BarcodeOptions): Required<BarcodeOptions> {
|
||||
return { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch data to the correct symbology encoder.
|
||||
*/
|
||||
function dispatchEncode(data: string, symbology: BarcodeSymbology): string {
|
||||
switch (symbology) {
|
||||
case "code128":
|
||||
return encodeCode128(data);
|
||||
case "code39":
|
||||
return encodeCode39(data);
|
||||
case "ean13":
|
||||
return encodeEan13(data);
|
||||
case "ean8":
|
||||
return encodeEan8(data);
|
||||
case "upca":
|
||||
return encodeUpcA(data);
|
||||
case "upce":
|
||||
return encodeUpcE(data);
|
||||
case "itf":
|
||||
return encodeItf(data);
|
||||
default: {
|
||||
const _exhaustive: never = symbology;
|
||||
throw new BarcodeError(
|
||||
`Unsupported symbology: ${String(_exhaustive)}`,
|
||||
BARCODE_CODES.UNSUPPORTED_SYMBOLOGY
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a binary bar string to SVG markup.
|
||||
*
|
||||
* Each '1' bit in `encoded` produces a filled rectangle (bar).
|
||||
* Each '0' bit is a space (background).
|
||||
* The width multiplier scales each module width.
|
||||
*/
|
||||
export function renderSvg(
|
||||
encoded: string,
|
||||
data: string,
|
||||
opts: Required<BarcodeOptions>
|
||||
): string {
|
||||
const moduleWidth = opts.width;
|
||||
const barHeight = opts.height;
|
||||
const margin = opts.margin;
|
||||
const textHeight = opts.includeText ? 14 : 0;
|
||||
const totalHeight = barHeight + textHeight + margin * 2;
|
||||
const totalWidth = encoded.length * moduleWidth + margin * 2;
|
||||
|
||||
const rects: string[] = [];
|
||||
|
||||
let x = margin;
|
||||
let i = 0;
|
||||
while (i < encoded.length) {
|
||||
const bit = encoded[i];
|
||||
// Count consecutive identical bits
|
||||
let run = 1;
|
||||
while (i + run < encoded.length && encoded[i + run] === bit) {
|
||||
run++;
|
||||
}
|
||||
if (bit === "1") {
|
||||
const w = run * moduleWidth;
|
||||
rects.push(
|
||||
`<rect x="${x}" y="${margin}" width="${w}" height="${barHeight}" fill="${opts.color}"/>`
|
||||
);
|
||||
}
|
||||
x += run * moduleWidth;
|
||||
i += run;
|
||||
}
|
||||
|
||||
const textEl = opts.includeText
|
||||
? `<text x="${totalWidth / 2}" y="${margin + barHeight + textHeight - 2}" ` +
|
||||
`text-anchor="middle" font-family="monospace" font-size="12" fill="${opts.color}">${escapeXml(data)}</text>`
|
||||
: "";
|
||||
|
||||
const bgRect = `<rect x="0" y="0" width="${totalWidth}" height="${totalHeight}" fill="${opts.background}"/>`;
|
||||
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" ` +
|
||||
`width="${totalWidth}" height="${totalHeight}" ` +
|
||||
`viewBox="0 0 ${totalWidth} ${totalHeight}">` +
|
||||
bgRect +
|
||||
rects.join("") +
|
||||
textEl +
|
||||
"</svg>"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special XML characters to prevent SVG injection.
|
||||
*/
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode `data` into the specified barcode symbology and render to SVG.
|
||||
*
|
||||
* @param data The data string to encode.
|
||||
* @param symbology The target symbology.
|
||||
* @param options Optional rendering options.
|
||||
* @returns A BarcodeResult with encoded binary string, SVG markup, and dimensions.
|
||||
* @throws BarcodeError on invalid data or unsupported symbology.
|
||||
*/
|
||||
export function encode(
|
||||
data: string,
|
||||
symbology: BarcodeSymbology,
|
||||
options?: BarcodeOptions
|
||||
): BarcodeResult {
|
||||
const opts = resolveOptions(options);
|
||||
const encoded = dispatchEncode(data, symbology);
|
||||
|
||||
const textHeight = opts.includeText ? 14 : 0;
|
||||
const totalHeight = opts.height + textHeight + opts.margin * 2;
|
||||
const totalWidth = encoded.length * opts.width + opts.margin * 2;
|
||||
const svg = renderSvg(encoded, data, opts);
|
||||
|
||||
return {
|
||||
symbology,
|
||||
data,
|
||||
encoded,
|
||||
svg,
|
||||
width: totalWidth,
|
||||
height: totalHeight,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { encode, renderSvg } from "./encoder";
|
||||
2
packages/foundation/barcode/barcode/src/core/index.ts
Normal file
2
packages/foundation/barcode/barcode/src/core/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { encode, renderSvg } from "./encoder";
|
||||
export { calculateCheckDigit, parse, validateBarcode } from "./validator";
|
||||
@@ -0,0 +1 @@
|
||||
export { calculateCheckDigit, parse, validateBarcode } from "./validator";
|
||||
@@ -0,0 +1,78 @@
|
||||
# validator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides cross-symbology barcode validation, check-digit calculation, and structured parsing for the `@suaveplan/barcode` package. While each symbology module exposes its own `validate` function, this module serves as a unified facade so callers can perform validation without needing to import individual symbology modules. It also exposes a `calculateCheckDigit` utility for EAN/UPC families and a `parse` function that returns a structured result including validity status and extracted check digit.
|
||||
|
||||
## Features
|
||||
|
||||
- `validateBarcode(data, symbology)` — dispatches to per-symbology validator, returns `boolean`.
|
||||
- `calculateCheckDigit(data, symbology)` — GS1 check digit for EAN-13, EAN-8, and UPC-A.
|
||||
- `parse(data, symbology)` — returns `ParseResult` with `valid`, `checkDigit` (where applicable).
|
||||
- Exhaustive switch guards with `never` type for compile-time completeness.
|
||||
- Throws `BarcodeError` with stable codes for unsupported symbologies or invalid input.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateBarcode, calculateCheckDigit } from "@suaveplan/barcode/validator";
|
||||
|
||||
const valid = validateBarcode("5901234123457", "ean13"); // true
|
||||
const check = calculateCheckDigit("590123412345", "ean13"); // "7"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parse } from "@suaveplan/barcode/validator";
|
||||
|
||||
const result = parse("5901234123457", "ean13");
|
||||
// {
|
||||
// symbology: "ean13",
|
||||
// data: "5901234123457",
|
||||
// valid: true,
|
||||
// checkDigit: "7"
|
||||
// }
|
||||
|
||||
const invalid = parse("5901234123458", "ean13");
|
||||
// { valid: false, checkDigit: "8" }
|
||||
|
||||
// Code 128 parse — no check digit extracted
|
||||
const code128 = parse("HELLO", "code128");
|
||||
// { symbology: "code128", data: "HELLO", valid: true }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validateBarcode(data: string, symbology: BarcodeSymbology): boolean`
|
||||
|
||||
Dispatches to the appropriate per-symbology `validate` function. Returns `true` if `data` is valid for `symbology`. Does not throw for validation failures (only throws for unsupported symbology).
|
||||
|
||||
### `calculateCheckDigit(data: string, symbology: 'ean13' | 'ean8' | 'upca'): string`
|
||||
|
||||
Computes the check digit for EAN/UPC symbologies.
|
||||
|
||||
| Parameter | Requirement |
|
||||
|-----------|-------------|
|
||||
| `data` | 12 digits for `ean13`/`upca`, 7 digits for `ean8` |
|
||||
| `symbology` | Must be `'ean13'`, `'ean8'`, or `'upca'` |
|
||||
|
||||
Returns check digit as a `string` character `'0'`–`'9'`. **Throws** `BarcodeError(INVALID_DATA)` for non-numeric input or wrong digit count.
|
||||
|
||||
### `parse(data: string, symbology: BarcodeSymbology): ParseResult`
|
||||
|
||||
Returns a structured `ParseResult`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `symbology` | `BarcodeSymbology` | The symbology used |
|
||||
| `data` | `string` | The input data string |
|
||||
| `valid` | `boolean` | Whether data is valid for the symbology |
|
||||
| `checkDigit` | `string \| undefined` | Last digit for EAN/UPC types; absent for Code 128/39/ITF |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `parse` function extracts the check digit by position convention: last digit of EAN-13 (13th), EAN-8 (8th), UPC-A (12th), and UPC-E (8th). The `checkDigit` field is only present when the data string has the expected length. Exhaustive switch statements with `never` fall-through guards ensure TypeScript enforces full coverage when new symbologies are added to the `BarcodeSymbology` union type.
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BARCODE_CODES, BarcodeError } from "../../errors/barcode-error.js";
|
||||
import { calculateCheckDigit, parse, validateBarcode } from "./validator.js";
|
||||
|
||||
describe("validator — validateBarcode", () => {
|
||||
it("validates code128 data", { timeout: 5000 }, () => {
|
||||
expect(validateBarcode("Hello World", "code128")).toBe(true);
|
||||
expect(validateBarcode("\x00", "code128")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates code39 data", { timeout: 5000 }, () => {
|
||||
expect(validateBarcode("ABC-123", "code39")).toBe(true);
|
||||
expect(validateBarcode("abc!", "code39")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates ean13 data", { timeout: 5000 }, () => {
|
||||
expect(validateBarcode("5901234123457", "ean13")).toBe(true);
|
||||
expect(validateBarcode("5901234123458", "ean13")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates ean8 data", { timeout: 5000 }, () => {
|
||||
expect(validateBarcode("96385074", "ean8")).toBe(true);
|
||||
expect(validateBarcode("96385075", "ean8")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates upca data", { timeout: 5000 }, () => {
|
||||
expect(validateBarcode("036000291452", "upca")).toBe(true);
|
||||
expect(validateBarcode("036000291453", "upca")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates upce data", { timeout: 5000 }, () => {
|
||||
expect(validateBarcode("01234565", "upce")).toBe(true);
|
||||
expect(validateBarcode("91234565", "upce")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates itf data", { timeout: 5000 }, () => {
|
||||
expect(validateBarcode("12345678", "itf")).toBe(true);
|
||||
expect(validateBarcode("1234567", "itf")).toBe(false); // odd
|
||||
});
|
||||
|
||||
it("throws UNSUPPORTED_SYMBOLOGY for unknown symbology", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// @ts-expect-error intentional
|
||||
expect(() => validateBarcode("test", "qrcode")).toThrow(BarcodeError);
|
||||
try {
|
||||
// @ts-expect-error intentional
|
||||
validateBarcode("test", "qrcode");
|
||||
} catch (e) {
|
||||
expect((e as BarcodeError).code).toBe(
|
||||
BARCODE_CODES.UNSUPPORTED_SYMBOLOGY
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validator — calculateCheckDigit", () => {
|
||||
it("computes check digit for ean13", { timeout: 5000 }, () => {
|
||||
expect(calculateCheckDigit("590123412345", "ean13")).toBe("7");
|
||||
});
|
||||
|
||||
it("computes check digit for ean8", { timeout: 5000 }, () => {
|
||||
expect(calculateCheckDigit("9638507", "ean8")).toBe("4");
|
||||
});
|
||||
|
||||
it("computes check digit for upca", { timeout: 5000 }, () => {
|
||||
expect(calculateCheckDigit("03600029145", "upca")).toBe("2");
|
||||
});
|
||||
|
||||
it("throws INVALID_DATA for non-digits", { timeout: 5000 }, () => {
|
||||
expect(() => calculateCheckDigit("ABCDEFGHIJKL", "ean13")).toThrow(
|
||||
BarcodeError
|
||||
);
|
||||
try {
|
||||
calculateCheckDigit("ABCDEFGHIJKL", "ean13");
|
||||
} catch (e) {
|
||||
expect((e as BarcodeError).code).toBe(BARCODE_CODES.INVALID_DATA);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_DATA for wrong digit count (ean13)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => calculateCheckDigit("123", "ean13")).toThrow(BarcodeError);
|
||||
try {
|
||||
calculateCheckDigit("123", "ean13");
|
||||
} catch (e) {
|
||||
expect((e as BarcodeError).code).toBe(BARCODE_CODES.INVALID_DATA);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_DATA for wrong digit count (ean8)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => calculateCheckDigit("12345678", "ean8")).toThrow(
|
||||
BarcodeError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validator — parse", () => {
|
||||
it("parses valid ean13 and returns check digit", { timeout: 5000 }, () => {
|
||||
const result = parse("5901234123457", "ean13");
|
||||
expect(result.symbology).toBe("ean13");
|
||||
expect(result.data).toBe("5901234123457");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.checkDigit).toBe("7");
|
||||
});
|
||||
|
||||
it("parses invalid ean13 and returns valid=false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parse("5901234123458", "ean13");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.checkDigit).toBe("8");
|
||||
});
|
||||
|
||||
it("parses ean8 and returns check digit", { timeout: 5000 }, () => {
|
||||
const result = parse("96385074", "ean8");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.checkDigit).toBe("4");
|
||||
});
|
||||
|
||||
it("parses upca and returns check digit", { timeout: 5000 }, () => {
|
||||
const result = parse("036000291452", "upca");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.checkDigit).toBe("2");
|
||||
});
|
||||
|
||||
it("parses upce and returns check digit", { timeout: 5000 }, () => {
|
||||
const result = parse("01234565", "upce");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.checkDigit).toBe("5");
|
||||
});
|
||||
|
||||
it("parses code128 without check digit field", { timeout: 5000 }, () => {
|
||||
const result = parse("HELLO", "code128");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.checkDigit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses code39 without check digit field", { timeout: 5000 }, () => {
|
||||
const result = parse("ABC", "code39");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.checkDigit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses itf without check digit field", { timeout: 5000 }, () => {
|
||||
const result = parse("1234", "itf");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.checkDigit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws UNSUPPORTED_SYMBOLOGY for unknown symbology", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// @ts-expect-error intentional
|
||||
expect(() => parse("test", "pdf417")).toThrow(BarcodeError);
|
||||
try {
|
||||
// @ts-expect-error intentional
|
||||
parse("test", "pdf417");
|
||||
} catch (e) {
|
||||
expect((e as BarcodeError).code).toBe(
|
||||
BARCODE_CODES.UNSUPPORTED_SYMBOLOGY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses ean13 with non-digit input — no checkDigit field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// invalid format: non-digit input fails the regex guard → checkDigit not set
|
||||
const result = parse("ABCDEFGHIJKLM", "ean13");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.checkDigit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses ean8 with non-digit input — no checkDigit field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parse("ABCDEFGH", "ean8");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.checkDigit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses upca with non-digit input — no checkDigit field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parse("ABCDEFGHIJKL", "upca");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.checkDigit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses upce with non-digit input — no checkDigit field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = parse("ABCDEFGH", "upce");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.checkDigit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Barcode validation and parsing utilities.
|
||||
* Provides cross-symbology validation, check-digit calculation, and
|
||||
* binary-pattern decoding back to human-readable data.
|
||||
*/
|
||||
|
||||
import { BARCODE_CODES, BarcodeError } from "../../errors/barcode-error.js";
|
||||
import { validate as validateCode39 } from "../../symbologies/code39.js";
|
||||
import { validate as validateCode128 } from "../../symbologies/code128.js";
|
||||
import { validate as validateEan8 } from "../../symbologies/ean8.js";
|
||||
import {
|
||||
computeCheckDigit,
|
||||
validate as validateEan13,
|
||||
} from "../../symbologies/ean13.js";
|
||||
import { validate as validateItf } from "../../symbologies/itf.js";
|
||||
import { validate as validateUpcA } from "../../symbologies/upca.js";
|
||||
import { validate as validateUpcE } from "../../symbologies/upce.js";
|
||||
import type {
|
||||
BarcodeSymbology,
|
||||
ParseResult,
|
||||
} from "../../types/barcode.types.js";
|
||||
|
||||
/**
|
||||
* Validate a data string against a given symbology.
|
||||
*
|
||||
* @param data The barcode data string.
|
||||
* @param symbology The symbology to validate against.
|
||||
* @returns `true` if valid for the symbology, `false` otherwise.
|
||||
*/
|
||||
export function validateBarcode(
|
||||
data: string,
|
||||
symbology: BarcodeSymbology
|
||||
): boolean {
|
||||
switch (symbology) {
|
||||
case "code128":
|
||||
return validateCode128(data);
|
||||
case "code39":
|
||||
return validateCode39(data);
|
||||
case "ean13":
|
||||
return validateEan13(data);
|
||||
case "ean8":
|
||||
return validateEan8(data);
|
||||
case "upca":
|
||||
return validateUpcA(data);
|
||||
case "upce":
|
||||
return validateUpcE(data);
|
||||
case "itf":
|
||||
return validateItf(data);
|
||||
default: {
|
||||
const _exhaustive: never = symbology;
|
||||
throw new BarcodeError(
|
||||
`Unsupported symbology: ${String(_exhaustive)}`,
|
||||
BARCODE_CODES.UNSUPPORTED_SYMBOLOGY
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the check digit for EAN/UPC symbologies.
|
||||
*
|
||||
* @param data 12-digit (EAN-13/UPC-A) or 7-digit (EAN-8) string without check digit.
|
||||
* @param symbology One of `'ean13'`, `'ean8'`, or `'upca'`.
|
||||
* @returns The check digit as a single character string ('0'–'9').
|
||||
* @throws BarcodeError for invalid input or unsupported symbology.
|
||||
*/
|
||||
export function calculateCheckDigit(
|
||||
data: string,
|
||||
symbology: "ean13" | "ean8" | "upca"
|
||||
): string {
|
||||
if (!/^\d+$/.test(data)) {
|
||||
throw new BarcodeError(
|
||||
`Check digit calculation requires numeric input. Got: ${JSON.stringify(data)}`,
|
||||
BARCODE_CODES.INVALID_DATA
|
||||
);
|
||||
}
|
||||
|
||||
// ean8: 7 digits without check; ean13/upca: 12 or 11 digits without check
|
||||
const expectedLength =
|
||||
symbology === "ean8" ? 7 : symbology === "upca" ? 11 : 12;
|
||||
|
||||
if (data.length !== expectedLength) {
|
||||
throw new BarcodeError(
|
||||
`${symbology.toUpperCase()} check digit calculation requires ${expectedLength} digits. Got ${data.length}.`,
|
||||
BARCODE_CODES.INVALID_DATA
|
||||
);
|
||||
}
|
||||
|
||||
// For UPC-A, prepend '0' to align with EAN-13 weight table (positions start with weight 1).
|
||||
const input = symbology === "upca" ? `0${data}` : data;
|
||||
const digits = input.split("").map(Number);
|
||||
const check = computeCheckDigit(digits);
|
||||
return String(check);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a barcode data string and return a structured ParseResult.
|
||||
* For EAN/UPC symbologies, validates the check digit and extracts it.
|
||||
*
|
||||
* @param data The human-readable barcode data string.
|
||||
* @param symbology The symbology to parse against.
|
||||
* @returns ParseResult with validity status and optional check digit.
|
||||
*/
|
||||
export function parse(data: string, symbology: BarcodeSymbology): ParseResult {
|
||||
const valid = validateBarcode(data, symbology);
|
||||
|
||||
let checkDigit: string | undefined;
|
||||
|
||||
switch (symbology) {
|
||||
case "ean13":
|
||||
if (/^\d{13}$/.test(data)) {
|
||||
checkDigit = data[12]!;
|
||||
}
|
||||
break;
|
||||
case "ean8":
|
||||
if (/^\d{8}$/.test(data)) {
|
||||
checkDigit = data[7]!;
|
||||
}
|
||||
break;
|
||||
case "upca":
|
||||
if (/^\d{12}$/.test(data)) {
|
||||
checkDigit = data[11]!;
|
||||
}
|
||||
break;
|
||||
case "upce":
|
||||
if (/^\d{8}$/.test(data)) {
|
||||
checkDigit = data[7]!;
|
||||
}
|
||||
break;
|
||||
case "code128":
|
||||
case "code39":
|
||||
case "itf":
|
||||
// No explicit check digit extracted for these symbologies
|
||||
break;
|
||||
// No default needed: validateBarcode() above already throws for unknown symbologies
|
||||
}
|
||||
|
||||
return {
|
||||
symbology,
|
||||
data,
|
||||
valid,
|
||||
...(checkDigit !== undefined ? { checkDigit } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# barcode-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the structured error class and stable error-code registry used throughout the `@suaveplan/barcode` package. As a leaf dependency with no `@suaveplan/error` runtime dependency, this module is fully self-contained while preserving the same structural contract (named `code` field, typed constants) expected by consumers of the wider Suaveplan error ecosystem.
|
||||
|
||||
## Features
|
||||
|
||||
- `BARCODE_CODES` — an `as const` object of stable string codes, suitable for `switch`/`if` guards without importing magic numbers.
|
||||
- `BarcodeCode` — union type derived from `BARCODE_CODES` so callers get exhaustive checking.
|
||||
- `BarcodeError extends Error` — standard JS error subclass with `name = "BarcodeError"` and a typed `code` field; fully serialisable across `structuredClone`, `JSON.stringify`, and Worker boundaries.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BarcodeError, BARCODE_CODES } from "@suaveplan/barcode/errors";
|
||||
|
||||
throw new BarcodeError(
|
||||
"EAN-13 requires exactly 13 digits",
|
||||
BARCODE_CODES.INVALID_DATA,
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BarcodeError, BARCODE_CODES, type BarcodeCode } from "@suaveplan/barcode/errors";
|
||||
|
||||
function handleBarcodeError(err: unknown): void {
|
||||
if (err instanceof BarcodeError) {
|
||||
switch (err.code) {
|
||||
case BARCODE_CODES.INVALID_DATA:
|
||||
console.error("Bad input:", err.message);
|
||||
break;
|
||||
case BARCODE_CODES.UNSUPPORTED_SYMBOLOGY:
|
||||
console.error("Unknown symbology:", err.message);
|
||||
break;
|
||||
case BARCODE_CODES.ENCODE_FAILED:
|
||||
console.error("Encoding failed:", err.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BARCODE_CODES`
|
||||
|
||||
`const` object with three keys:
|
||||
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| `INVALID_DATA` | `"BARCODE.INVALID_DATA"` |
|
||||
| `UNSUPPORTED_SYMBOLOGY` | `"BARCODE.UNSUPPORTED_SYMBOLOGY"` |
|
||||
| `ENCODE_FAILED` | `"BARCODE.ENCODE_FAILED"` |
|
||||
|
||||
### `BarcodeCode`
|
||||
|
||||
Derived union type: `"BARCODE.INVALID_DATA" | "BARCODE.UNSUPPORTED_SYMBOLOGY" | "BARCODE.ENCODE_FAILED"`.
|
||||
|
||||
### `BarcodeError`
|
||||
|
||||
```ts
|
||||
class BarcodeError extends Error {
|
||||
readonly code: BarcodeCode;
|
||||
constructor(message: string, code: BarcodeCode);
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `"BarcodeError"` | Set in constructor; identifies error type. |
|
||||
| `code` | `BarcodeCode` | Stable machine-readable code for programmatic handling. |
|
||||
| `message` | `string` | Human-readable description of the problem. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module is deliberately framework-free and has zero runtime imports. The `as const` assertion on `BARCODE_CODES` gives TypeScript narrowing without any extra overhead. Extending the native `Error` class means `instanceof BarcodeError` checks work across realm boundaries (Worker ↔ main thread) as long as the same class reference is used. The `code` field is marked `readonly` to prevent accidental mutation after construction.
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BARCODE_CODES, BarcodeError } from "./barcode-error.js";
|
||||
|
||||
describe("BarcodeError", () => {
|
||||
it("sets name to BarcodeError", { timeout: 5000 }, () => {
|
||||
const err = new BarcodeError("test", BARCODE_CODES.INVALID_DATA);
|
||||
expect(err.name).toBe("BarcodeError");
|
||||
});
|
||||
|
||||
it("sets message correctly", { timeout: 5000 }, () => {
|
||||
const err = new BarcodeError(
|
||||
"Something went wrong",
|
||||
BARCODE_CODES.ENCODE_FAILED
|
||||
);
|
||||
expect(err.message).toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("sets code correctly", { timeout: 5000 }, () => {
|
||||
const err = new BarcodeError(
|
||||
"msg",
|
||||
BARCODE_CODES.UNSUPPORTED_SYMBOLOGY
|
||||
);
|
||||
expect(err.code).toBe("BARCODE.UNSUPPORTED_SYMBOLOGY");
|
||||
});
|
||||
|
||||
it("is instanceof Error", { timeout: 5000 }, () => {
|
||||
const err = new BarcodeError("test", BARCODE_CODES.INVALID_DATA);
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it("is instanceof BarcodeError", { timeout: 5000 }, () => {
|
||||
const err = new BarcodeError("test", BARCODE_CODES.INVALID_DATA);
|
||||
expect(err instanceof BarcodeError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BARCODE_CODES", () => {
|
||||
it("has INVALID_DATA code", { timeout: 5000 }, () => {
|
||||
expect(BARCODE_CODES.INVALID_DATA).toBe("BARCODE.INVALID_DATA");
|
||||
});
|
||||
|
||||
it("has UNSUPPORTED_SYMBOLOGY code", { timeout: 5000 }, () => {
|
||||
expect(BARCODE_CODES.UNSUPPORTED_SYMBOLOGY).toBe(
|
||||
"BARCODE.UNSUPPORTED_SYMBOLOGY"
|
||||
);
|
||||
});
|
||||
|
||||
it("has ENCODE_FAILED code", { timeout: 5000 }, () => {
|
||||
expect(BARCODE_CODES.ENCODE_FAILED).toBe("BARCODE.ENCODE_FAILED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Stable error-code registry and BarcodeError class for @suaveplan/barcode.
|
||||
* As a leaf package with no @suaveplan/error dependency, errors are
|
||||
* self-contained but follow the same structural contract.
|
||||
*/
|
||||
|
||||
/** Stable error codes used throughout the barcode package. */
|
||||
export const BARCODE_CODES = {
|
||||
INVALID_DATA: "BARCODE.INVALID_DATA",
|
||||
UNSUPPORTED_SYMBOLOGY: "BARCODE.UNSUPPORTED_SYMBOLOGY",
|
||||
ENCODE_FAILED: "BARCODE.ENCODE_FAILED",
|
||||
} as const;
|
||||
|
||||
/** Union type of all valid barcode error codes. */
|
||||
export type BarcodeCode = (typeof BARCODE_CODES)[keyof typeof BARCODE_CODES];
|
||||
|
||||
/**
|
||||
* Structured error class for barcode operations.
|
||||
* Carries a stable `code` for programmatic error handling in addition
|
||||
* to the human-readable `message`.
|
||||
*/
|
||||
export class BarcodeError extends Error {
|
||||
readonly code: BarcodeCode;
|
||||
|
||||
constructor(message: string, code: BarcodeCode) {
|
||||
super(message);
|
||||
this.name = "BarcodeError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
1
packages/foundation/barcode/barcode/src/errors/index.ts
Normal file
1
packages/foundation/barcode/barcode/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BARCODE_CODES, type BarcodeCode, BarcodeError } from "./barcode-error";
|
||||
44
packages/foundation/barcode/barcode/src/index.ts
Normal file
44
packages/foundation/barcode/barcode/src/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @suaveplan/barcode — Barcode generation and parsing.
|
||||
*
|
||||
* Supports Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, and ITF symbologies.
|
||||
* Runtime-agnostic: outputs SVG strings and binary patterns with no DOM/canvas dependency.
|
||||
*/
|
||||
|
||||
// Core encoder
|
||||
export { encode, renderSvg } from "./core/encoder/encoder.js";
|
||||
// Core validator
|
||||
export {
|
||||
calculateCheckDigit,
|
||||
parse,
|
||||
validateBarcode,
|
||||
} from "./core/validator/validator.js";
|
||||
export type { BarcodeCode } from "./errors/barcode-error.js";
|
||||
// Errors
|
||||
export { BARCODE_CODES, BarcodeError } from "./errors/barcode-error.js";
|
||||
// Symbology-level exports
|
||||
export {
|
||||
checkValueCode128,
|
||||
computeCheckDigit,
|
||||
encodeCode39,
|
||||
encodeCode128,
|
||||
encodeEan8,
|
||||
encodeEan13,
|
||||
encodeItf,
|
||||
encodeUpcA,
|
||||
encodeUpcE,
|
||||
validateCode39,
|
||||
validateCode128,
|
||||
validateEan8,
|
||||
validateEan13,
|
||||
validateItf,
|
||||
validateUpcA,
|
||||
validateUpcE,
|
||||
} from "./symbologies/index.js";
|
||||
// Types
|
||||
export type {
|
||||
BarcodeOptions,
|
||||
BarcodeResult,
|
||||
BarcodeSymbology,
|
||||
ParseResult,
|
||||
} from "./types/barcode.types.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# code128
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements Code 128 Subset B encoding for the full printable ASCII range (characters 32–127). This module produces a binary bar-pattern string that faithfully represents the ISO/IEC 15417 standard: start symbol, per-character 11-bit patterns from the Code 128B table, mod-103 check symbol, and the 13-bit stop symbol. It is the most versatile 1D symbology in common use and handles arbitrary printable text without requiring special character-set switching.
|
||||
|
||||
## Features
|
||||
|
||||
- Full 96-entry Code 128B character table (ASCII 32–127).
|
||||
- `encode(data)` — returns a binary `'0'/'1'` string ready for SVG rendering.
|
||||
- `validate(data)` — pre-flight check before encoding, returns `boolean`.
|
||||
- `checkValue(data)` — computes the raw numeric check value (0–102) independently.
|
||||
- Throws `BarcodeError` with stable codes on invalid input or encoding failure.
|
||||
- Zero external dependencies; pure computation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { encode, validate } from "@suaveplan/barcode/symbologies";
|
||||
|
||||
if (validate("Hello-123")) {
|
||||
const bits = encode("Hello-123");
|
||||
console.log(bits.startsWith("11010010000")); // true — Code B start symbol
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { encode, checkValue } from "@suaveplan/barcode/symbologies";
|
||||
|
||||
// Compute check digit independently for audit purposes
|
||||
const cv = checkValue("ABC");
|
||||
console.log(`Check value: ${cv}`); // deterministic numeric 0–102
|
||||
|
||||
// Encode and inspect structure
|
||||
const bits = encode("ABC");
|
||||
const startSymbol = bits.slice(0, 11); // "11010010000" (START B)
|
||||
const stopSymbol = bits.slice(-13); // "1100011101011" (STOP)
|
||||
console.log(startSymbol, stopSymbol);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validate(data: string): boolean`
|
||||
|
||||
Returns `true` if every character in `data` has a code point in the range 32–127 (inclusive) and `data.length > 0`. Returns `false` otherwise. Does not throw.
|
||||
|
||||
### `encode(data: string): string`
|
||||
|
||||
Encodes `data` into a binary string using Code 128 Subset B.
|
||||
|
||||
| Segment | Description |
|
||||
|---------|-------------|
|
||||
| START B | `11010010000` |
|
||||
| Data chars | Each character → 11-bit table lookup |
|
||||
| Check symbol | Derived from mod-103 weighted sum |
|
||||
| STOP | `1100011101011` |
|
||||
|
||||
**Throws** `BarcodeError(BARCODE_CODES.INVALID_DATA)` if any character is outside ASCII 32–127.
|
||||
|
||||
### `checkValue(data: string): number`
|
||||
|
||||
Returns the integer check value (0–102) computed as `(START_B_VALUE + Σ tableIndex_i × (i+1)) mod 103`. Useful for verification without full encode overhead.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 108-entry table is declared as a `readonly string[]` constant. Characters in Subset B are indexed as `charCode - 32`, so ASCII space (32) → index 0, `~` (126) → index 94, DEL (127) → index 95. Indices 96–107 cover special function/control values. The check digit accumulates `(i + 1)` as the positional weight (1-based), consistent with the ISO spec. The stop symbol is a fixed 13-bit pattern not present in the main table (it has a different width) and is simply concatenated verbatim.
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BARCODE_CODES, BarcodeError } from "../errors/barcode-error.js";
|
||||
import { checkValue, encode, validate } from "./code128.js";
|
||||
|
||||
const START_B = "11010010000";
|
||||
const STOP = "1100011101011";
|
||||
|
||||
describe("code128 — validate", () => {
|
||||
it("accepts printable ASCII 32–127", { timeout: 5000 }, () => {
|
||||
expect(validate("Hello World!")).toBe(true);
|
||||
expect(validate("ABC123")).toBe(true);
|
||||
expect(validate(" ")).toBe(true); // space (32)
|
||||
});
|
||||
|
||||
it("rejects empty string", { timeout: 5000 }, () => {
|
||||
expect(validate("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects characters below ASCII 32", { timeout: 5000 }, () => {
|
||||
expect(validate("\x00")).toBe(false);
|
||||
expect(validate("\x1F")).toBe(false);
|
||||
expect(validate("ok\x00")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects characters above ASCII 127", { timeout: 5000 }, () => {
|
||||
expect(validate("caf\u00E9")).toBe(false);
|
||||
expect(validate("\u0080")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("code128 — encode", () => {
|
||||
it("encoded string starts with Code B start symbol", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bits = encode("A");
|
||||
expect(bits.startsWith(START_B)).toBe(true);
|
||||
});
|
||||
|
||||
it("encoded string ends with stop symbol", { timeout: 5000 }, () => {
|
||||
const bits = encode("A");
|
||||
expect(bits.endsWith(STOP)).toBe(true);
|
||||
});
|
||||
|
||||
it("encodes a simple ASCII string without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bits = encode("Hello");
|
||||
expect(typeof bits).toBe("string");
|
||||
expect(bits.length).toBeGreaterThan(0);
|
||||
// Only '0' and '1'
|
||||
expect(/^[01]+$/.test(bits)).toBe(true);
|
||||
});
|
||||
|
||||
it("encodes all printable ASCII without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let data = "";
|
||||
for (let i = 32; i <= 126; i++) {
|
||||
data += String.fromCharCode(i);
|
||||
}
|
||||
expect(() => encode(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws INVALID_DATA for non-printable character", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => encode("bad\x01char")).toThrow(BarcodeError);
|
||||
try {
|
||||
encode("bad\x01char");
|
||||
} catch (e) {
|
||||
expect(e instanceof BarcodeError).toBe(true);
|
||||
expect((e as BarcodeError).code).toBe(BARCODE_CODES.INVALID_DATA);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_DATA for empty string", { timeout: 5000 }, () => {
|
||||
expect(() => encode("")).toThrow(BarcodeError);
|
||||
});
|
||||
|
||||
it("encodes numeric data correctly", { timeout: 5000 }, () => {
|
||||
const bits = encode("123");
|
||||
expect(bits.startsWith(START_B)).toBe(true);
|
||||
expect(bits.endsWith(STOP)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("code128 — checkValue", () => {
|
||||
it("returns a value between 0 and 102", { timeout: 5000 }, () => {
|
||||
const cv = checkValue("TEST");
|
||||
expect(cv).toBeGreaterThanOrEqual(0);
|
||||
expect(cv).toBeLessThanOrEqual(102);
|
||||
});
|
||||
|
||||
it("returns consistent values for same input", { timeout: 5000 }, () => {
|
||||
expect(checkValue("Hello")).toBe(checkValue("Hello"));
|
||||
});
|
||||
|
||||
it("returns different values for different inputs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Not guaranteed but extremely likely for short strings
|
||||
const a = checkValue("A");
|
||||
const b = checkValue("B");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("check value is consistent with encoded check symbol", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const data = "ABC";
|
||||
const cv = checkValue(data);
|
||||
const bits = encode(data);
|
||||
// The encoded bits contain: START(11) + chars(3*11=33) + check(11) + STOP(13) = 68 bits
|
||||
// We can verify the total length: 11 + 33 + 11 + 13 = 68
|
||||
expect(bits.length).toBe(11 + 33 + 11 + 13);
|
||||
expect(cv).toBeGreaterThanOrEqual(0);
|
||||
expect(cv).toBeLessThanOrEqual(102);
|
||||
});
|
||||
});
|
||||
190
packages/foundation/barcode/barcode/src/symbologies/code128.ts
Normal file
190
packages/foundation/barcode/barcode/src/symbologies/code128.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Code 128 (Subset B) encoder and validator.
|
||||
* Implements the full Code 128B character table covering ASCII 32–127,
|
||||
* including start symbol, check-digit computation (mod-103), and stop symbol.
|
||||
*/
|
||||
|
||||
import { BARCODE_CODES, BarcodeError } from "../errors/barcode-error.js";
|
||||
|
||||
/**
|
||||
* Code 128 encoding patterns: 108 entries covering values 0–107.
|
||||
* Index 0–95 correspond to ASCII 32–127 (Subset B characters).
|
||||
* Indices 96–107 are special symbols (FNC, SHIFT, Code A/B/C, START A/B/C, STOP).
|
||||
* Each entry is an 11-bit binary string.
|
||||
*/
|
||||
const CODE128_TABLE: readonly string[] = [
|
||||
"11011001100", // 0 (SP)
|
||||
"11001101100", // 1 !
|
||||
"11001100110", // 2 "
|
||||
"10010011000", // 3 #
|
||||
"10010001100", // 4 $
|
||||
"10001001100", // 5 %
|
||||
"10011001000", // 6 &
|
||||
"10011000100", // 7 '
|
||||
"10001100100", // 8 (
|
||||
"11001001000", // 9 )
|
||||
"11001000100", // 10 *
|
||||
"11000100100", // 11 +
|
||||
"10110011100", // 12 ,
|
||||
"10011011100", // 13 -
|
||||
"10011001110", // 14 .
|
||||
"10111001100", // 15 /
|
||||
"10011101100", // 16 0
|
||||
"10011100110", // 17 1
|
||||
"11001110010", // 18 2
|
||||
"11001011100", // 19 3
|
||||
"11001001110", // 20 4
|
||||
"11011100100", // 21 5
|
||||
"11001110100", // 22 6
|
||||
"11101101110", // 23 7
|
||||
"11101001100", // 24 8
|
||||
"11100101100", // 25 9
|
||||
"11100100110", // 26 :
|
||||
"11101100100", // 27 ;
|
||||
"11100110100", // 28 <
|
||||
"11100110010", // 29 =
|
||||
"11011011000", // 30 >
|
||||
"11011000110", // 31 ?
|
||||
"11000110110", // 32 @
|
||||
"10100011000", // 33 A
|
||||
"10001011000", // 34 B
|
||||
"10001000110", // 35 C
|
||||
"10110001000", // 36 D
|
||||
"10001101000", // 37 E
|
||||
"10001100010", // 38 F
|
||||
"11010001000", // 39 G
|
||||
"11000101000", // 40 H
|
||||
"11000100010", // 41 I
|
||||
"10110111000", // 42 J
|
||||
"10110001110", // 43 K
|
||||
"10001101110", // 44 L
|
||||
"10111011000", // 45 M
|
||||
"10111000110", // 46 N
|
||||
"10001110110", // 47 O
|
||||
"11101110110", // 48 P
|
||||
"11010001110", // 49 Q
|
||||
"11000101110", // 50 R
|
||||
"11011101000", // 51 S
|
||||
"11011100010", // 52 T
|
||||
"11011101110", // 53 U
|
||||
"11101011000", // 54 V
|
||||
"11101000110", // 55 W
|
||||
"11100010110", // 56 X
|
||||
"11101101000", // 57 Y
|
||||
"11101100010", // 58 Z
|
||||
"11100011010", // 59 [
|
||||
"11101111010", // 60 backslash
|
||||
"11001000010", // 61 ]
|
||||
"11110001010", // 62 ^
|
||||
"10100110000", // 63 _
|
||||
"10100001100", // 64 `
|
||||
"10010110000", // 65 a
|
||||
"10010000110", // 66 b
|
||||
"10000101100", // 67 c
|
||||
"10000100110", // 68 d
|
||||
"10110010000", // 69 e
|
||||
"10110000100", // 70 f
|
||||
"10011010000", // 71 g
|
||||
"10011000010", // 72 h
|
||||
"10000110100", // 73 i
|
||||
"10000110010", // 74 j
|
||||
"11000010010", // 75 k
|
||||
"11001010000", // 76 l
|
||||
"11110111010", // 77 m
|
||||
"11000010100", // 78 n
|
||||
"10001111010", // 79 o
|
||||
"10100111100", // 80 p
|
||||
"10010111100", // 81 q
|
||||
"10010011110", // 82 r
|
||||
"10111100100", // 83 s
|
||||
"10011110100", // 84 t
|
||||
"10011110010", // 85 u
|
||||
"11110100100", // 86 v
|
||||
"11110010100", // 87 w
|
||||
"11110010010", // 88 x
|
||||
"11011011110", // 89 y
|
||||
"11011110110", // 90 z
|
||||
"11110110110", // 91 {
|
||||
"10101111000", // 92 |
|
||||
"10100011110", // 93 }
|
||||
"10001111100", // 94 ~
|
||||
"11010100000", // 95 DEL (127) — FNC3 / Code B value 95
|
||||
"11010010000", // 96 FNC3
|
||||
"11010000100", // 97 FNC2
|
||||
"11001010010", // 98 SHIFT
|
||||
"11110101110", // 99 Code C
|
||||
"11110101000", // 100 Code B ← also start-B value index 104 in some tables
|
||||
"10111101000", // 101 Code A
|
||||
"10111100010", // 102 FNC1
|
||||
"11101011110", // 103 START A
|
||||
"11110101110", // 104 START B — reuse index 99 pattern
|
||||
"11010110110", // 105 START C
|
||||
"11000111010", // 106 STOP (left part) — full pattern below
|
||||
];
|
||||
|
||||
/** Start B pattern (index 104 in the spec maps to value 104). */
|
||||
const START_B = "11010010000";
|
||||
/** Stop pattern appended after check bar. */
|
||||
const STOP = "1100011101011";
|
||||
/** Start-B code value for check-digit calculation. */
|
||||
const START_B_VALUE = 104;
|
||||
|
||||
/**
|
||||
* Validate that every character in `data` is in Code 128 Subset B (ASCII 32–127).
|
||||
*/
|
||||
export function validate(data: string): boolean {
|
||||
if (data.length === 0) return false;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const c = data.charCodeAt(i);
|
||||
if (c < 32 || c > 127) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode `data` using Code 128 Subset B.
|
||||
*
|
||||
* Returns a binary string of '0' and '1' characters representing bars (1) and
|
||||
* spaces (0) in left-to-right order: START B → data chars → check symbol → STOP.
|
||||
*
|
||||
* @throws BarcodeError with code INVALID_DATA if any character is outside ASCII 32–127.
|
||||
*/
|
||||
export function encode(data: string): string {
|
||||
if (!validate(data)) {
|
||||
throw new BarcodeError(
|
||||
`Code 128B data must contain only ASCII characters 32–127. Got: ${JSON.stringify(data)}`,
|
||||
BARCODE_CODES.INVALID_DATA
|
||||
);
|
||||
}
|
||||
|
||||
const parts: string[] = [START_B];
|
||||
|
||||
let checksum = START_B_VALUE;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const charCode = data.charCodeAt(i);
|
||||
const tableIndex = charCode - 32; // SP=0, !..~=1..94, DEL=95
|
||||
const pattern = CODE128_TABLE[tableIndex]!;
|
||||
parts.push(pattern);
|
||||
checksum += tableIndex * (i + 1);
|
||||
}
|
||||
|
||||
const checkValue = checksum % 103;
|
||||
const checkPattern = CODE128_TABLE[checkValue]!;
|
||||
parts.push(checkPattern);
|
||||
parts.push(STOP);
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Code 128B check digit value (0–102) for the given data string.
|
||||
* Useful for verification without performing a full encode.
|
||||
*/
|
||||
export function checkValue(data: string): number {
|
||||
let sum = START_B_VALUE;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
sum += (data.charCodeAt(i) - 32) * (i + 1);
|
||||
}
|
||||
return sum % 103;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# code39
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements Code 39 barcode encoding for the standard 43-character set: digits 0–9, uppercase letters A–Z, and the special characters space, hyphen, period, dollar, slash, plus, and percent. Code 39 is a variable-length, self-checking, discrete symbology widely used in industrial and logistics applications. This module auto-upper-cases input for ergonomic developer experience and produces a binary bar-pattern string ready for SVG rendering.
|
||||
|
||||
## Features
|
||||
|
||||
- `encode(data)` — returns a binary `'0'/'1'` string with START, payload, inter-character gaps, and STOP symbols.
|
||||
- `validate(data)` — checks every character against the legal Code 39 set; returns `boolean`.
|
||||
- Auto-upper-case normalisation during encoding.
|
||||
- Narrow inter-character gap (`'0'`) inserted between every symbol pair.
|
||||
- Throws `BarcodeError` with stable codes on invalid input.
|
||||
- Zero external dependencies.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { encode, validate } from "@suaveplan/barcode/symbologies";
|
||||
|
||||
if (validate("HELLO-123")) {
|
||||
const bits = encode("HELLO-123");
|
||||
// bits begins with Code 39 start (*) pattern
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { encode } from "@suaveplan/barcode/symbologies";
|
||||
|
||||
// Lower-case is automatically normalised to upper-case
|
||||
const bits = encode("hello world");
|
||||
// Equivalent to encode("HELLO WORLD")
|
||||
|
||||
// Inspect start/stop boundaries
|
||||
const startPattern = "100101101101";
|
||||
console.log(bits.startsWith(startPattern)); // true
|
||||
console.log(bits.endsWith(startPattern)); // true (same * symbol for stop)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validate(data: string): boolean`
|
||||
|
||||
Returns `true` if every character in `data.toUpperCase()` exists in the Code 39 character set (0–9, A–Z, space, `-`, `.`, `$`, `/`, `+`, `%`) and `data.length > 0`. The `*` start/stop character is not valid in payloads. Returns `false` without throwing.
|
||||
|
||||
### `encode(data: string): string`
|
||||
|
||||
Encodes `data` into a binary string.
|
||||
|
||||
| Segment | Description |
|
||||
|---------|-------------|
|
||||
| START | `*` symbol pattern |
|
||||
| Gap | Narrow `'0'` inter-character gap |
|
||||
| Data | Each character → 12-bit lookup + narrow gap |
|
||||
| STOP | `*` symbol pattern (no trailing gap) |
|
||||
|
||||
Each character produces a 12-bit pattern (9 element bits + 3 implicit bar/space transitions from narrow/wide encoding). **Throws** `BarcodeError(BARCODE_CODES.INVALID_DATA)` if any character is not in the allowed set.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Code 39 patterns in this implementation use a compact 12-character binary representation where narrow elements are `'0'` and wide elements are `'1'`. Pattern ordering follows the standard bar-space-bar-space-bar-space-bar-space-bar sequence. Inter-character gaps are appended after each symbol including START, but not after STOP. Input is normalised to upper case before validation so lowercase ASCII letters map correctly.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user