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

110 lines
7.8 KiB
Markdown

## ADDED Requirements
### Requirement: Package layout and dependency policy
The `@suaveplan/testing-load` package SHALL live at `packages/testing/testing-load/`. Its `dependencies`, `peerDependencies`, and `devDependencies` MUST NOT list any `@suaveplan/*` workspace package other than `@suaveplan/types`. External deps SHALL be referenced via catalog entries (no bare version strings). The package SHALL declare `"sideEffects": false` and SHALL provide a subpath export per feature module per `openspec/AGENTS.md` §6.
#### Scenario: Workspace deps are limited to @suaveplan/types
- **WHEN** `bunx turbo ls --filter=@suaveplan/testing-load...` is run
- **THEN** the only `@suaveplan/*` package in the forward closure MUST be `@suaveplan/types`
#### Scenario: No bare versions
- **WHEN** the package's `package.json` `dependencies` and `devDependencies` are scanned
- **THEN** every external dep MUST resolve to a `catalog:<key>` reference AND no bare semver string MUST appear
### Requirement: Public API — defineLoadScenario, runLoadScenario, assertThresholds
The package SHALL export `defineLoadScenario(spec)` returning a frozen `LoadScenario`, `runLoadScenario(scenario, opts?)` returning `Promise<{ summary: LoadSummary; raw: unknown }>`, and `assertThresholds(summary)` returning `void` (throws on violation). Inputs SHALL be Zod-validated at the boundary; outputs SHALL conform to the `LoadSummary` shape regardless of which driver produced them. Every public function SHALL accept an optional `{ signal?: AbortSignal }` and SHALL respect cancellation at bounded checkpoints.
#### Scenario: defineLoadScenario validates input
- **WHEN** `defineLoadScenario({ name: "x", target: "not-a-url", vus: -1, duration: "30s" })` is called
- **THEN** it MUST throw `TestingLoadError` with `code === "TESTING_LOAD.CONFIG_INVALID"` AND `context.issues` MUST list the offending fields
#### Scenario: runLoadScenario honours AbortSignal
- **WHEN** `runLoadScenario(scenario, { signal })` is awaited and `signal.abort()` is called mid-run
- **THEN** the promise MUST reject with `TestingLoadError` where `code === "TESTING_LOAD.ABORTED"` AND `cause === signal.reason`
#### Scenario: assertThresholds enumerates failed predicates
- **WHEN** a `LoadSummary` reports `http_req_duration{p(95)} = 750` against threshold `p(95)<500`
- **THEN** `assertThresholds(summary)` MUST throw `TestingLoadError` with `code === "TESTING_LOAD.THRESHOLD_VIOLATION"` AND `context.failed` MUST list the predicate `"http_req_duration: p(95)<500 (observed 750)"`
### Requirement: Driver pluggability — k6 primary, autocannon fallback
The package SHALL expose a `LoadDriver` interface and SHALL ship two implementations: a k6 driver invoking `grafana/k6` via testcontainers, and an autocannon driver running in-process via the `autocannon` npm package. `runLoadScenario` SHALL default to the k6 driver when reachable and SHALL fall back to autocannon when k6 is unavailable. Both drivers SHALL normalise their output into the same `LoadSummary` shape so callers and `assertThresholds` are driver-agnostic.
#### Scenario: k6 selected when available
- **WHEN** `runLoadScenario(scenario)` is called without an explicit driver AND a reachable Docker daemon is detected AND the `grafana/k6` image can be pulled
- **THEN** the k6 driver MUST be selected AND the resulting `summary.driverName` MUST equal `"k6"`
#### Scenario: autocannon fallback when k6 unavailable
- **WHEN** `runLoadScenario(scenario)` is called without an explicit driver AND no Docker daemon is reachable
- **THEN** the autocannon driver MUST be selected AND the resulting `summary.driverName` MUST equal `"autocannon"`
#### Scenario: Both drivers produce LoadSummary
- **WHEN** the same `LoadScenario` is executed once via the k6 driver and once via the autocannon driver
- **THEN** both runs MUST return a `LoadSummary` with the same set of metric keys (e.g. `http_req_duration`, `http_req_failed`, `iteration_duration`) AND `assertThresholds` MUST evaluate identically against either summary
### Requirement: Default thresholds and per-scenario overrides
The package SHALL apply default thresholds when the caller omits the `thresholds` field on a `LoadScenario`: `http_req_duration{p(95)} < 500ms`, `http_req_failed < 1%` (rate < 0.01), `iteration_duration{p(99)} < 2000ms`. Caller-supplied thresholds SHALL merge on top of (not replace) the defaults; an explicit `thresholds: {}` SHALL retain the defaults; callers SHALL be able to override a single key without restating the rest.
#### Scenario: Defaults applied when omitted
- **WHEN** `defineLoadScenario({ name: "x", target: "http://t/", vus: 1, duration: "30s" })` is called and the resulting scenario is inspected
- **THEN** the resolved `thresholds` MUST contain `http_req_duration: ["p(95)<500"]` AND `http_req_failed: ["rate<0.01"]` AND `iteration_duration: ["p(99)<2000"]`
#### Scenario: Single-key override merges with defaults
- **WHEN** `defineLoadScenario({ ..., thresholds: { http_req_duration: ["p(95)<200"] } })` is called
- **THEN** the resolved scenario's `http_req_duration` MUST equal `["p(95)<200"]` AND the other default keys MUST remain present unchanged
### Requirement: Run-mode presets — smoke, load, stress, spike
The package SHALL export named run-mode presets composable with `defineLoadScenario`: `smoke` (1 vu, 30s), `load` (10 vu, 5m), `stress` (50 vu, 10m), `spike` (stages from 0 to 100 vu over 30s). Presets SHALL be plain objects spreadable into `defineLoadScenario`'s spec argument; callers SHALL be able to override any preset field on a per-scenario basis.
#### Scenario: smoke preset values
- **WHEN** the `smoke` preset is inspected
- **THEN** `vus === 1` AND `duration === "30s"` AND `stages` MUST be undefined
#### Scenario: spike preset values
- **WHEN** the `spike` preset is inspected
- **THEN** `stages` MUST describe a ramp from `0` to `100` virtual users over `30s` AND the total scenario duration MUST equal the sum of stage durations
### Requirement: Structured errors with TESTING_LOAD.* codes
The package SHALL throw only subclasses of `TestingLoadError extends Error` with stable codes prefixed `TESTING_LOAD.*`. Errors MUST carry `{ code, cause?, context? }` fields. The package MUST NOT throw plain `Error` or `TypeError` from any public code path. Defined codes include `TESTING_LOAD.CONFIG_INVALID`, `TESTING_LOAD.DRIVER_FAILED`, `TESTING_LOAD.DRIVER_UNAVAILABLE`, `TESTING_LOAD.THRESHOLD_VIOLATION`, and `TESTING_LOAD.ABORTED`.
#### Scenario: Driver failure produces coded error
- **WHEN** the k6 driver's container exits non-zero
- **THEN** the driver MUST throw `TestingLoadError` with `code === "TESTING_LOAD.DRIVER_FAILED"` AND `context.driverName === "k6"` AND `context.exitCode` MUST equal the observed exit code
#### Scenario: Driver unavailable surfaces a typed error
- **WHEN** `runLoadScenario` is called with `driver: "k6"` explicitly AND no Docker daemon is reachable
- **THEN** the call MUST throw `TestingLoadError` with `code === "TESTING_LOAD.DRIVER_UNAVAILABLE"` AND `context.driverName === "k6"`
### Requirement: 100% Istanbul coverage and self-testability
The package SHALL meet 100% Istanbul statement / branch / function / line coverage. The k6 driver SHALL accept injectable `exec` and `fetch` shims so the package's own test suite runs without a Docker daemon. The autocannon driver SHALL accept an injectable autocannon factory so the package's own test suite runs without performing real network requests.
#### Scenario: Coverage gate at 100%
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/testing-load` runs
- **THEN** statement, branch, function, and line coverage MUST each be 100%
#### Scenario: Tests pass without Docker
- **WHEN** the package's test suite is invoked on a host with no Docker daemon and no network access
- **THEN** every test MUST pass AND no test MUST attempt a real `docker` invocation or a real outbound network request