Files
suaveplan/openspec/changes/enterprise-testing-integration/design.md
2026-05-27 23:29:53 +01:00

4.8 KiB

Context

The 2026-04-24 wave (umbrella design at /mnt/s/web/genesis/docs/superpowers/specs/2026-04-24-enterprise-testbed-design.md, plans at /mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testing-integration-{core,plugins}.md and /mnt/s/web/genesis/docs/superpowers/plans/2026-04-24-testbed-pages-and-e2e.md) shipped the integration substrate without a normative spec. The 2026-04-25 audit (/mnt/s/web/genesis/docs/superpowers/specs/2026-04-25-test-suite-audit.md) is the driver for this change.

This change is normative-only. No source files are touched; the contract for the seven new packages and the four new e2e modules is fixed.

Decisions

D1: ServiceFixture<TConfig> is the universal fixture contract

Every plugin package implements one class satisfying ServiceFixture<TConfig extends Record<string, unknown>>:

interface ServiceFixture<TConfig> {
    readonly name: string;
    readonly config: TConfig;
    readonly dependsOn?: readonly string[];
    start(opts: { signal: AbortSignal }): Promise<void>;
    stop(opts: { signal: AbortSignal }): Promise<void>;
    isHealthy(): Promise<boolean>;
    env(): Record<string, string>;
}

InferConfig<F> extracts TConfig. The interface lives in @suaveplan/testing-integration/fixture/service-fixture and is re-exported from the package barrel.

D2: TestEnvironment lifecycle — topological start, reverse-topological stop

TestEnvironment accepts a Record<string, ServiceFixture<…>> plus TestEnvironmentOptions { signal?, clock?, rng? }. It computes a topological order from dependsOn, parallelises starts within each layer, and stops in reverse order. A fixture that never started is never stopped. Stop continues draining remaining fixtures even when one stop fails (errors are aggregated). A started flag prevents double-start.

D3: HealthPoller contract

Default intervalMs: 500, timeoutMs: 120000. Honours AbortSignal. On deadline elapsed without a true from isHealthy(), throws INTEGRATION.HEALTH_TIMEOUT with context.elapsedMs and context.fixtureName. Optional exponential backoff cap. Uses an injectable sleep for testability.

D4: Env-bridge passthrough

Every plugin SHALL pass through ALL of its declared env vars to the worker pool. When the env-bridge env var(s) are set on the parent process, the fixture skips container start and uses the existing service. Each plugin's spec enumerates its env-var contract.

D5: createIntegrationSetup() + Vitest global-setup bridge

createIntegrationSetup(fixtures) registers fixtures in a module-level registry that global-setup.ts reads at Vitest startup. It returns the globalSetup array consumers drop into vitest.config.ts. On partial start failure, it provide()s __suaveplan_integration_failed__ (renamed from the plans' __integration_failed__), and skipIfUnavailable("name") reads it via inject() to skip — never fail — affected tests.

D6: Deterministic injection propagation

TestEnvironmentOptions { clock?: Clock; rng?: Rng; signal?: AbortSignal } is forwarded to every fixture whose config carries acceptsContext: true. Fixtures that opt in receive the same Clock/Rng/signal as the rest of the test runtime — preserving the determinism contract from openspec/specs/testing/spec.md.

D7: Error-code namespace per plugin

Stable codes follow INTEGRATION.<PLUGIN>.<REASON>: e.g. INTEGRATION.SURREALDB.START_FAILED, INTEGRATION.GARAGE_S3.LAYOUT_APPLY_FAILED. Cross-cutting codes are unprefixed: INTEGRATION.HEALTH_TIMEOUT, INTEGRATION.LEAK_DETECTED, INTEGRATION.QUARANTINED.

D8: Leak detection + flake quarantine

After teardownAll(), TestEnvironment SHALL assert no active handles attributable to its fixtures remain (warn + counter increment if found). markFlaky({ retries, reason }) wraps a test with bounded retries and emits testing.integration.flake.count; quarantined tests are listed in quarantine.json at the package root.

D9: testcontainers as peer dep, types-only @suaveplan/types

@suaveplan/testing-integration depends on @suaveplan/types only (the documented exception in CLAUDE.md rule 8) and testcontainers as a peer. Plugins depend on @suaveplan/testing-integration and testcontainers.

Risks / Trade-offs

  • Inject-key rename (__integration_failed__ → __suaveplan_integration_failed__) is a one-line code change but normalises namespace collisions; documented as a one-shot mechanical sweep.
  • dependsOn topo-sort adds complexity vs flat parallel start; mitigated by leaving the field optional with a default of [] (current behaviour preserved).
  • Image policy — this change pins by name + minor version (e.g. surrealdb/surrealdb:v3.0.4). Pinning by sha256 digest is deferred to a follow-up operational change so this normative change stays minimal.