@specific.dev/spectest 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,1601 @@
1
+ // Spectest SDK. The user's single `spectest/index.ts` calls
2
+ // `defineEnvironment({ name, services })` once, defines test cases via
3
+ // the returned `env.test(...)`, and default-exports `env.project([...])`.
4
+ // The daemon loads this file on boot and the control plane talks to it
5
+ // over HTTP.
6
+
7
+ import { strict as nodeAssert } from "node:assert";
8
+
9
+ import { recordAssertion, safeSerialize } from "./recorder.js";
10
+ import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
11
+
12
+ // Provenance-wrapper types: what tracked ops (ctx.fetch, db queries,
13
+ // browser.evaluate) resolve to, and the `.unwrap()` escape hatch.
14
+ export type {
15
+ Carrier,
16
+ Wrapped,
17
+ WrappedObject,
18
+ WrappedArray,
19
+ WrappedResponse,
20
+ Provenanced,
21
+ SpectestFetch,
22
+ Unwrap,
23
+ } from "./inspect.js";
24
+ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
25
+
26
+ // `field` — a provenance-preserving, null-safe selector: a `null`/`undefined`
27
+ // leaf read off a wrapped op result is raw and untagged, so an `expect(...)` on
28
+ // it renders detached from its source op; `field` tags from the container so the
29
+ // assertion still nests. (Base64-decoded values lose provenance the same way;
30
+ // that case is the `.base64Decoded()` provenance transform on `expect(...)`.)
31
+ // To recover a raw value, call `.unwrap()` on it — spectest op results are
32
+ // always wrapped (in every context), so the method is always there; there is no
33
+ // `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
34
+ export { field } from "./inspect.js";
35
+
36
+ export type {
37
+ Browser,
38
+ BrowserOptions,
39
+ ScreenshotFormat,
40
+ ScreenshotOptions,
41
+ } from "./browser.js";
42
+
43
+ import type { Browser, BrowserOptions } from "./browser.js";
44
+
45
+ export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
46
+
47
+ import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
48
+
49
+ // Low-level ingress primitives + the framework lowering that the friendly
50
+ // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
51
+ // `ingress.ts`.
52
+ export {
53
+ certificate,
54
+ dnsName,
55
+ proxy,
56
+ provides,
57
+ lowerIngress,
58
+ isWildcard,
59
+ SELF_SERVICE_TOKEN,
60
+ } from "./ingress.js";
61
+ export type {
62
+ CertificateDecl,
63
+ DnsDecl,
64
+ ProxyDecl,
65
+ IngressDecl,
66
+ DnsTarget,
67
+ LoweredIngress,
68
+ } from "./ingress.js";
69
+ import type { DnsTarget } from "./ingress.js";
70
+
71
+ // ──────────────────────────────────────────────────────────────────────────
72
+ // Environment configuration
73
+ // ──────────────────────────────────────────────────────────────────────────
74
+
75
+ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
76
+ /** Human-friendly name for the environment, e.g. "my-app". */
77
+ name: string;
78
+ /**
79
+ * Services that make up the environment, keyed by service name. The key
80
+ * is the container name, the DNS hostname on `spectest-net`, and the
81
+ * string used in `dependsOn` references.
82
+ */
83
+ services: S;
84
+ /** Sandbox timeout in seconds (default 1h). */
85
+ timeoutSecs?: number;
86
+ }
87
+
88
+ /**
89
+ * The shape that ends up in the JSON config the control plane and Rust
90
+ * mirror care about. `ServiceDefinition` is the same thing with an extra
91
+ * non-serialisable `client` factory tacked on; `JSON.stringify` drops
92
+ * functions, so the wire format is `ServiceConfig`.
93
+ */
94
+ export interface ServiceConfig {
95
+ /** Image definition: registry pull or inline build. */
96
+ image: ServiceImage;
97
+ /**
98
+ * Shell command to run (sh -c). Defaults to the image's CMD. NB: this
99
+ * **replaces the image's entrypoint** with `/bin/sh -c` — an
100
+ * init-wrapped image (postgres's `docker-entrypoint.sh`) skips its
101
+ * initialization. Use {@link args} to keep the entrypoint.
102
+ */
103
+ command?: string;
104
+ /**
105
+ * Arguments appended after the image name (`docker run <image>
106
+ * <args…>`) — a CMD override that **keeps the image's entrypoint**.
107
+ * E.g. `args: ["postgres", "-c", "wal_level=logical"]` still runs
108
+ * postgres's `docker-entrypoint.sh` initialization. Mutually
109
+ * exclusive with {@link command}.
110
+ */
111
+ args?: string[];
112
+ env?: Record<string, string>;
113
+ /**
114
+ * Ports the container listens on. Advisory only — surfaced in
115
+ * `spectest list` output. Peer services reach each other by `<service>:<port>`
116
+ * without any port declaration.
117
+ */
118
+ ports?: number[];
119
+ /**
120
+ * Extra DNS names this service answers to inside the environment.
121
+ * Each entry must be a fully-qualified, multi-label hostname (e.g.
122
+ * `api.stripe.com`). Both peer containers (via Docker's embedded DNS)
123
+ * and code on the VM host (via spectest-resolver) resolve these names
124
+ * to the service's IP on `spectest-net`. Useful for mocking external
125
+ * APIs — point an SDK at `http://api.stripe.com` and a service of
126
+ * yours answers directly (no proxy). For HTTPS, use {@link tls}
127
+ * instead — those names terminate TLS in the daemon and reverse-proxy
128
+ * to the service's HTTP port.
129
+ *
130
+ * The `.internal` TLD is reserved: every service automatically
131
+ * answers to `<name>.internal` in addition to its bare `<name>`, and
132
+ * user-supplied hostnames may not end in `.internal`.
133
+ */
134
+ hostnames?: string[];
135
+ /**
136
+ * Expose this service over HTTPS via a TLS-terminating reverse proxy
137
+ * hosted in the spectest-daemon. Each entry maps a fully-qualified
138
+ * hostname to the HTTP port the service listens on inside its
139
+ * container; the daemon binds the hostname on `:443` (SNI-multiplexed,
140
+ * with a leaf cert signed by the in-VM root CA) and on `:80`, and
141
+ * proxies the request to `http://<service>:<port>`. WebSocket
142
+ * upgrades are forwarded.
143
+ *
144
+ * The in-VM root CA is already trusted by Chromium (`ctx.browser()`)
145
+ * and by service-container runtimes (Node, Python, Go, etc.) via
146
+ * the env-var bundle, so `https://<hostname>/` Just Works from
147
+ * tests and from peer services. Hostname rules match {@link hostnames}:
148
+ * multi-label, lowercase, no `.internal` suffix, no collision with
149
+ * services, other service TLS hostnames, or fakes.
150
+ */
151
+ tls?: ServiceTls[];
152
+ /** Bind-mounted volumes for state that survives snapshot/fork. */
153
+ volumes?: VolumeMount[];
154
+ /**
155
+ * Files seeded into the container's filesystem **before it starts**.
156
+ * Each entry's `content` is written to a VM-host staging path and
157
+ * bind-mounted (read-only) at `path` inside the container. Unlike a
158
+ * `setup` hook — which runs after the container is up — `files` is the
159
+ * way to inject configuration a process reads at boot, e.g. k3s's
160
+ * `/etc/rancher/k3s/registries.yaml`, which must exist before
161
+ * `k3s server` starts.
162
+ */
163
+ files?: FileMount[];
164
+ /** Other services (keys in the services map) that must be ready first. */
165
+ dependsOn?: string[];
166
+ readyCheck?: ReadyCheck;
167
+ /** Container workdir override. */
168
+ workdir?: string;
169
+ /**
170
+ * Run the container with `--privileged`. Required by workloads that
171
+ * embed their own container runtime (e.g. k3s) and need full access
172
+ * to the host kernel surface. Off by default.
173
+ */
174
+ privileged?: boolean;
175
+ /**
176
+ * Tmpfs mounts (one `--tmpfs <path>` per entry). Required by some
177
+ * workloads — k3s wants `/run` and `/var/run` writable and
178
+ * non-persistent. Snapshots/forks preserve tmpfs contents along with
179
+ * the rest of process memory.
180
+ */
181
+ tmpfs?: string[];
182
+ /**
183
+ * Cgroup namespace mode — `"host"` or `"private"`. Omit for docker's
184
+ * default. k3s needs `"host"` so its embedded containerd can manage
185
+ * cgroups for the pods it schedules.
186
+ */
187
+ cgroupns?: string;
188
+ }
189
+
190
+ /**
191
+ * Same shape as `ServiceConfig` plus an optional `helpers` factory that
192
+ * opens a namespace under `ctx.svc.<name>` inside tests. The factory
193
+ * returns a record — each key becomes `ctx.svc.<name>.<key>`. Components
194
+ * like `postgres(...)` use this to expose a pre-wired SQL pool at
195
+ * `ctx.svc.db.client`; nothing stops a component from exposing several
196
+ * helpers under one service (e.g. `client`, `admin`, `truncate()`).
197
+ */
198
+ export interface ServiceDefinition<
199
+ // `any` (not `unknown`) so closed-shape interfaces — like
200
+ // `PostgresHelpers` — are assignable. The constraint is only here to
201
+ // signal intent ("helpers is a record of named conveniences");
202
+ // `ServiceHandlesFor<S>` infers the helpers' real type at use sites.
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ H extends Record<string, any> = Record<string, never>,
205
+ > extends ServiceConfig {
206
+ /**
207
+ * Build the helpers exposed at `ctx.svc.<name>.<key>`. Called by the
208
+ * daemon the first time a test touches this service; the result is
209
+ * cached for the lifetime of the daemon (it survives snapshot/fork
210
+ * along with the rest of daemon memory). `name` is the key the user
211
+ * chose in the services map and matches the in-VM DNS name.
212
+ */
213
+ helpers?: (args: { name: string }) => H | Promise<H>;
214
+ /**
215
+ * One-shot setup after the container's `readyCheck` passes, before
216
+ * dependents start and before any test runs. Awaited in-line with
217
+ * bootstrap, so anything it produces (an ingress controller deployed
218
+ * into k3s, a schema applied to a database) is part of the warm-template
219
+ * snapshot and never re-runs on warm starts.
220
+ *
221
+ * `helpers` is the same record the `helpers` factory returns — building
222
+ * it is cached, so `setup` and tests share one instance. If the service
223
+ * doesn't declare `helpers`, the field is the empty object.
224
+ */
225
+ setup?: (args: { name: string; helpers: H }) => void | Promise<void>;
226
+ }
227
+
228
+ /** A services map — what users pass to `environment.services`. Entries
229
+ * can be plain `ServiceConfig` literals or `ServiceDefinition`s that
230
+ * carry a `helpers` factory (e.g. what `postgres(...)` returns). */
231
+ export type ServicesMap = Record<string, ServiceConfig>;
232
+
233
+ /** Awaited return type of a service's `helpers` factory, or `never` if
234
+ * the service doesn't ship one. */
235
+ type HelpersOf<D> = D extends {
236
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
237
+ helpers: (...args: any) => infer R;
238
+ }
239
+ ? Awaited<R>
240
+ : never;
241
+
242
+ /**
243
+ * Per-service handles derived from a concrete services map. Only
244
+ * services that ship a `helpers` factory appear here; `ctx.svc.<name>`
245
+ * is exactly the record the factory returned (e.g. `{ client: SqlClient }`
246
+ * for `postgres(...)`). Services without helpers don't show up at all,
247
+ * so plain `services: { api: { image: ... } }` adds no noise.
248
+ */
249
+ export type ServiceHandlesFor<S extends ServicesMap> = {
250
+ [K in keyof S as HelpersOf<S[K]> extends never ? never : K]: HelpersOf<S[K]>;
251
+ };
252
+
253
+ /** Loose, runtime-friendly shape of `ctx.svc` for code that doesn't
254
+ * know the concrete services map (the daemon, generic helpers). The
255
+ * typed `ctx.svc` in test bodies is `ServiceHandlesFor<S>`. */
256
+ export type ServiceHandles = Record<string, Record<string, unknown>>;
257
+
258
+ /**
259
+ * One TLS-terminated hostname for a service. The daemon binds the
260
+ * hostname on `:443` (with a leaf cert signed by the in-VM root CA)
261
+ * and `:80`, and reverse-proxies each request to `http://<service>:<port>`
262
+ * inside the docker network. WebSocket upgrades are bridged.
263
+ */
264
+ export interface ServiceTls {
265
+ /** Fully-qualified hostname clients use (e.g. `app.test`). Must be
266
+ * multi-label, lowercase, not under the reserved `.internal` TLD,
267
+ * and unique across all services and fakes in this environment. */
268
+ hostname: string;
269
+ /** HTTP port the service listens on inside its container. The
270
+ * daemon forwards proxied requests here over `spectest-net`. */
271
+ port: number;
272
+ }
273
+
274
+ export type ServiceImage =
275
+ | { type: "registry"; reference: string }
276
+ | {
277
+ type: "dockerfile";
278
+ /**
279
+ * Dockerfile contents, written verbatim into the build context. The
280
+ * build context is the project root (where `spectest/` lives), so any
281
+ * `COPY` / `ADD` references resolve relative to that directory.
282
+ */
283
+ content: string;
284
+ /** Extra glob patterns to exclude from the build context. */
285
+ exclude?: string[];
286
+ };
287
+
288
+ export interface VolumeMount {
289
+ /**
290
+ * Host path. Relative paths resolve under
291
+ * `.spectest/volumes/<service>/`. Defaults to a path derived from `target`.
292
+ */
293
+ source?: string;
294
+ /** Container path. */
295
+ target: string;
296
+ readOnly?: boolean;
297
+ /**
298
+ * Cache volume: the backing dir lives outside the per-env state tree and
299
+ * survives a delta-restore teardown (which recreates every container,
300
+ * volume, and the daemon for fresh-state semantics). Reserve this for
301
+ * content-addressed data whose presence is purely an accelerator — an
302
+ * image/layer store, a package cache — never for app state: anything in
303
+ * a cache volume is visible to the "fresh" environment.
304
+ */
305
+ cache?: boolean;
306
+ }
307
+
308
+ export interface FileMount {
309
+ /** Absolute path inside the container where the file is mounted. */
310
+ path: string;
311
+ /**
312
+ * File contents. The literal token `{{SPECTEST_SERVICE}}` is expanded
313
+ * to the owning service's name (its services-map key) before the file
314
+ * is written — handy for self-referential config like a registry host
315
+ * of `<key>.internal`, where a component can't know the key in advance.
316
+ */
317
+ content: string;
318
+ /**
319
+ * Optional octal mode string (e.g. `"0644"`) applied to the staged
320
+ * file before it's bind-mounted. Defaults to the writer's umask.
321
+ */
322
+ mode?: string;
323
+ }
324
+
325
+ export type ReadyCheck =
326
+ | { type: "tcp"; port: number; timeoutSecs?: number }
327
+ | { type: "http"; port: number; path?: string; timeoutSecs?: number }
328
+ /**
329
+ * Run a shell command inside the container; exit 0 = ready. Used when
330
+ * the readiness signal isn't reachable via plain TCP/HTTP from outside
331
+ * (k3s API server uses mTLS, so the natural probe is `kubectl get
332
+ * --raw=/readyz` from inside).
333
+ */
334
+ | { type: "exec"; command: string; timeoutSecs?: number };
335
+
336
+ // ──────────────────────────────────────────────────────────────────────────
337
+ // Runtime services — containers started *during* a test/eval/setup or from
338
+ // inside a fake handler, rather than declared up-front in `services`.
339
+ // ──────────────────────────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Spec for a service started at runtime via {@link TestContext.startService}
343
+ * (or the `ctx` handed to a fake). It's a normal {@link ServiceConfig} plus a
344
+ * required `name`, minus the two fields that only make sense at boot:
345
+ *
346
+ * - `tls` — runtime services are reached *directly* by their own IP on
347
+ * `spectest-net` (like a real machine on a network), not through the
348
+ * daemon's HTTP ingress, so there's no proxy/cert to configure. Map a
349
+ * friendly DNS name onto one with {@link TestContext.dnsName}.
350
+ * - `dependsOn` — there is no boot DAG at runtime; the caller orders
351
+ * `startService` calls itself with `await`.
352
+ *
353
+ * The container joins `spectest-net` with its own IP and is resolvable by
354
+ * `name` (single-label, via the resolver / docker embedded DNS) and by any
355
+ * `hostnames` (extra `--network-alias`es). Like everything else in the VM it
356
+ * is captured by the per-test post-state snapshot, so a `dependsOn` child
357
+ * inherits the live container while siblings never see it.
358
+ */
359
+ export interface RuntimeServiceSpec extends Omit<ServiceConfig, "tls" | "dependsOn"> {
360
+ /**
361
+ * Container name and primary DNS name on `spectest-net`. Must be unique
362
+ * within the current fork — generate a fresh one per provisioned instance
363
+ * (e.g. `db-${crypto.randomUUID().slice(0, 8)}`).
364
+ */
365
+ name: string;
366
+ }
367
+
368
+ /** What {@link TestContext.startService} resolves to once the container is up
369
+ * and its `readyCheck` (if any) has passed. */
370
+ export interface RuntimeServiceHandle {
371
+ /** The container/DNS name — the spec's `name`. */
372
+ name: string;
373
+ /** The container's IP on `spectest-net`. Reachable directly from peer
374
+ * containers and from VM-host/test code. */
375
+ ip: string;
376
+ }
377
+
378
+ /**
379
+ * The slice of the environment a fake can mutate at runtime — the same
380
+ * primitives {@link TestContext} exposes to tests. Passed as the third
381
+ * argument to a fake's `handler` and as `ctx` to its `helpers` factory, so
382
+ * a fake (e.g. a database-provider control plane) can provision real
383
+ * backing services on demand and wire up DNS for them, exactly as a test
384
+ * would.
385
+ */
386
+ export interface FakeContext {
387
+ /** Start a real container on `spectest-net` at runtime. See
388
+ * {@link RuntimeServiceSpec}. */
389
+ startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
390
+ /** Stop and remove a runtime service started earlier (no-op if gone). */
391
+ stopService(name: string): Promise<void>;
392
+ /** Map a DNS name onto a service IP (or the daemon ingress). See
393
+ * {@link TestContext.dnsName}. */
394
+ dnsName(hostname: string, target: DnsTarget): Promise<void>;
395
+ }
396
+
397
+ function validateEnvironmentConfig<S extends ServicesMap>(
398
+ config: EnvironmentConfig<S>,
399
+ ): void {
400
+ const entries = Object.entries(config.services);
401
+ const serviceNames = new Set(entries.map(([n]) => n));
402
+ const claimedBy = new Map<string, string>();
403
+ const claimBy = (h: string, name: string, kind: "hostname" | "tls"): void => {
404
+ const prior = claimedBy.get(h);
405
+ if (prior !== undefined && prior !== name) {
406
+ throw new Error(
407
+ `hostname ${JSON.stringify(h)} is claimed by both "${prior}" and "${name}"`,
408
+ );
409
+ }
410
+ if (serviceNames.has(h)) {
411
+ throw new Error(
412
+ `${kind} ${JSON.stringify(h)} collides with service name "${h}"`,
413
+ );
414
+ }
415
+ claimedBy.set(h, name);
416
+ };
417
+ for (const [name, svc] of entries) {
418
+ if (name.length === 0) {
419
+ throw new Error(
420
+ "service name (services map key) must be a non-empty string",
421
+ );
422
+ }
423
+ for (const dep of svc.dependsOn ?? []) {
424
+ if (!serviceNames.has(dep)) {
425
+ throw new Error(
426
+ `service "${name}" dependsOn "${dep}" which is not a service in this environment`,
427
+ );
428
+ }
429
+ }
430
+ for (const raw of svc.hostnames ?? []) {
431
+ const h = raw.toLowerCase();
432
+ if (!HOSTNAME_RE.test(h)) {
433
+ throw new Error(
434
+ `service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name (e.g. "api.stripe.com")`,
435
+ );
436
+ }
437
+ if (h === "internal" || h.endsWith(".internal")) {
438
+ throw new Error(
439
+ `service "${name}" declares hostname ${JSON.stringify(raw)} — the ".internal" TLD is reserved; every service already answers to "<name>.internal" automatically`,
440
+ );
441
+ }
442
+ claimBy(h, name, "hostname");
443
+ }
444
+ for (const entry of svc.tls ?? []) {
445
+ if (!entry || typeof entry.hostname !== "string" || typeof entry.port !== "number") {
446
+ throw new Error(
447
+ `service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`,
448
+ );
449
+ }
450
+ const h = entry.hostname.toLowerCase();
451
+ if (!HOSTNAME_RE.test(h)) {
452
+ throw new Error(
453
+ `service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name (e.g. "app.test")`,
454
+ );
455
+ }
456
+ if (h === "internal" || h.endsWith(".internal")) {
457
+ throw new Error(
458
+ `service "${name}" tls hostname ${JSON.stringify(entry.hostname)} ends in reserved ".internal" TLD`,
459
+ );
460
+ }
461
+ if (!Number.isInteger(entry.port) || entry.port <= 0 || entry.port > 65535) {
462
+ throw new Error(
463
+ `service "${name}" tls hostname ${JSON.stringify(entry.hostname)} has invalid port ${entry.port}`,
464
+ );
465
+ }
466
+ claimBy(h, name, "tls");
467
+ }
468
+ }
469
+ }
470
+
471
+ // Multi-label hostname: at least one dot, each label 1–63 chars of
472
+ // [a-z0-9-], no leading/trailing hyphen.
473
+ const HOSTNAME_RE =
474
+ /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
475
+
476
+ // ──────────────────────────────────────────────────────────────────────────
477
+ // Test framework
478
+ // ──────────────────────────────────────────────────────────────────────────
479
+
480
+ /**
481
+ * A single test step. Created via `test(...)` or `createTest(services)`.
482
+ * Tests are referenced (not named by string) when one test depends on
483
+ * another — keeps refactors and type-checking honest.
484
+ *
485
+ * `T` is the return type of the test body; it surfaces as `ctx.parent`
486
+ * in children that `dependsOn` this case. `S` is the project's services
487
+ * map (threaded through `defineEnvironment(...).test`) so `ctx.svc.<key>`
488
+ * is strongly typed.
489
+ */
490
+ export interface TestCase<
491
+ T = unknown,
492
+ S extends ServicesMap = ServicesMap,
493
+ F extends FakesMap = FakesMap,
494
+ > {
495
+ /** Stable id (slug of name). Used in MCP responses and CLI flags. */
496
+ readonly id: string;
497
+ readonly name: string;
498
+ /** Parent test, if any. Single-parent for now. */
499
+ readonly dependsOn?: TestCase<unknown, S, F>;
500
+ /** Override the default per-test timeout (default 60s). */
501
+ readonly timeoutMs?: number;
502
+ /** @internal — the body that the in-sandbox daemon invokes. */
503
+ readonly run: TestFn<T, unknown, S, F>;
504
+ }
505
+
506
+ export interface TestOpts<
507
+ P = undefined,
508
+ S extends ServicesMap = ServicesMap,
509
+ F extends FakesMap = FakesMap,
510
+ > {
511
+ dependsOn?: TestCase<P, S, F>;
512
+ timeoutMs?: number;
513
+ }
514
+
515
+ export type TestFn<
516
+ T = void,
517
+ P = undefined,
518
+ S extends ServicesMap = ServicesMap,
519
+ F extends FakesMap = FakesMap,
520
+ > = (ctx: TestContext<P, S, F>) => T | Promise<T>;
521
+
522
+ /**
523
+ * Context object passed to every test function.
524
+ *
525
+ * `P` is the return type of the parent test (the one named in
526
+ * `dependsOn`); for root tests it's `undefined`. `S` is the project's
527
+ * services map (threaded through `defineEnvironment(...).test`) so
528
+ * `ctx.svc` only includes services with a `client` factory, each typed
529
+ * as the factory's output (e.g. a Bun SQL pool for `postgres(...)`).
530
+ */
531
+ export interface TestContext<
532
+ P = undefined,
533
+ S extends ServicesMap = ServicesMap,
534
+ F extends FakesMap = FakesMap,
535
+ > {
536
+ /**
537
+ * Instrumented `fetch`, exposed on `ctx`. Each call is recorded on the
538
+ * test timeline and resolves to a {@link WrappedResponse}: reads carry
539
+ * provenance so `expect(res.status)` / `expect(await res.json())` nest
540
+ * under the HTTP call. Because the status-line accessors are
541
+ * {@link Carrier}s, a raw `res.status === 200` is a *type error* — use
542
+ * `res.status.unwrap()` / `res.unwrap().status`, or assert via `expect`.
543
+ *
544
+ * (The plain global `fetch` is wrapped the same way at runtime but keeps
545
+ * the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
546
+ * results.)
547
+ */
548
+ fetch: SpectestFetch;
549
+ /** Run `sh -lc <command>` inside a service container. The result is
550
+ * {@link Wrapped}, so `res.stdout` is a `Carrier<string>` — assert via
551
+ * `expect(res.stdout)` or recover the raw string with `res.stdout.unwrap()`.
552
+ * (In `setup`/`eval` the result is wrapped too, just without a timeline
553
+ * link, so `.unwrap()` works there the same way.)
554
+ *
555
+ * The full run is also captured as an asciicast and replayed in the
556
+ * web UI — one recording per call, with output timestamped as it
557
+ * streamed, so slow or animated CLI output can be watched rather
558
+ * than read as a final blob. Unlike `terminal` there is no PTY: the
559
+ * program sees plain pipes (`isatty` false), so stdout/stderr stay
560
+ * byte-identical to what `exec` always returned and TTY-gated
561
+ * spinners/colour won't be emitted — reach for `terminal` when the
562
+ * CLI needs to believe it's on a TTY. */
563
+ exec(service: string, command: string): Promise<Wrapped<ExecResult>>;
564
+ /**
565
+ * Run a command inside a service container under a PTY and record the
566
+ * full terminal session as an asciicast for replay in the web UI.
567
+ * Unlike `exec`, the program sees a TTY (`isatty(1)` true, colour
568
+ * codes preserved, line-buffered), so the byte stream may differ from
569
+ * what `exec` returns. Reach for `terminal` when you're driving a CLI
570
+ * and want a recording; reach for `exec` for plain piped output.
571
+ *
572
+ * One-shot convenience: this is a thin wrapper over `openTerminal` —
573
+ * spawn `command`, wait for exit, close, return the captured output
574
+ * and exit code. Reach for `openTerminal` when you want to keep the
575
+ * session alive across multiple sends or `waitFor` predicates (e.g.
576
+ * driving an interactive REPL or waiting on a loading spinner).
577
+ */
578
+ terminal(
579
+ service: string,
580
+ command: string,
581
+ opts?: TerminalOpts,
582
+ ): Promise<Wrapped<TerminalResult>>;
583
+ /**
584
+ * Open a long-lived interactive terminal in a service container.
585
+ * Returns a `Terminal` you can `send` keystrokes to, poll the
586
+ * rendered screen with `waitFor`, and `close` when done. Every byte
587
+ * the PTY emits is captured as an asciicast and replayed in the web
588
+ * UI the same way a one-shot `terminal(...)` is.
589
+ *
590
+ * Terminals are NOT auto-closed when the test ends. A docker exec
591
+ * subprocess is cheap to keep alive and Freestyle captures it
592
+ * cleanly in the snapshot along with the rest of the container, so
593
+ * leaking the handle past test end is fine. Call `.close()`
594
+ * explicitly if you want a `close` step in the timeline; otherwise
595
+ * `await term.exited` (after the program self-terminates) is the
596
+ * natural way to assert on the exit code.
597
+ */
598
+ openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
599
+ /**
600
+ * Open a headless browser. Backed by Chromium-over-CDP inside the VM.
601
+ * Every view is auto-closed when the test finishes; call `.close()` to
602
+ * release earlier if you're opening many.
603
+ */
604
+ browser(opts?: BrowserOptions): Promise<Browser>;
605
+ /** The test's display name. */
606
+ readonly testName: string;
607
+ /**
608
+ * Value returned by the parent test. Carried across the fork in the
609
+ * daemon's own memory (Freestyle's snapshot is memory + filesystem), so
610
+ * any JS value works — including Maps, Sets, class instances, and live
611
+ * connections that survive the fork. `undefined` for root tests or when
612
+ * the parent returned nothing.
613
+ */
614
+ readonly parent: P;
615
+ /**
616
+ * Per-service helper namespaces, keyed by service name. Only services
617
+ * whose definition ships a `helpers` factory appear here; the value at
618
+ * `ctx.svc.<name>` is exactly the record that factory returned. For a
619
+ * `postgres(...)` service that ships `{ client }`, tests do
620
+ * `await ctx.svc.db.client\`SELECT 1\``.
621
+ */
622
+ readonly svc: ServiceHandlesFor<S>;
623
+ /**
624
+ * Per-fake helper namespaces, keyed by fake name. Each is the record
625
+ * of functions the fake's `helpers` factory returned (or `{ state }`
626
+ * when it ships none — tests never touch a fake's private state
627
+ * directly). Those functions read/mutate the fake's state internally;
628
+ * the state itself is in-process and lives across the fork along with
629
+ * the rest of daemon memory, so calls in a child test see the fork's
630
+ * own copy as mutated by its ancestors. Every helper call is recorded
631
+ * as a step and its return value tracked, so assertions on it nest
632
+ * under the call in the timeline.
633
+ *
634
+ * Strongly typed against the project's fakes map when fakes are
635
+ * declared in `defineEnvironment({ ..., fakes })`: `ctx.fakes.stripe`
636
+ * is exactly the helpers record `defineFake`'s `helpers` factory
637
+ * returned — no cast. (Falls back to a loose record only when the
638
+ * environment declares no fakes.)
639
+ */
640
+ readonly fakes: FakeHandlesFor<F>;
641
+ /**
642
+ * Poll a predicate until it returns a truthy value, then return that
643
+ * value. Records one `wait` event for the whole loop (with attempt
644
+ * count, total duration, and the description) instead of one event
645
+ * per probe — useful for "wait until pod Running"-style checks where
646
+ * the intermediate states are noise.
647
+ *
648
+ * - `null`, `undefined`, or `false` from `fn` mean "not yet" — wait
649
+ * `intervalMs` and try again.
650
+ * - Anything else is the success value and is returned, tagged with
651
+ * the wait event's seq. Downstream `expect(...)` on it links to
652
+ * the wait (one logical step), not to N suppressed HTTP calls.
653
+ * - Throws from `fn` propagate out immediately; the wait event is
654
+ * still recorded (with `error` set) so the timeline reflects the
655
+ * abort.
656
+ * - Defaults: `timeoutMs = 30_000`, `intervalMs = 1_000`.
657
+ *
658
+ * Side-effect calls inside `fn` (fetch, ctx.svc.* helpers, etc.)
659
+ * don't show up on the event log — the recorder is paused for the
660
+ * duration. Use `ctx.poll` for read-only observation, not for
661
+ * stateful work you want recorded.
662
+ */
663
+ poll<T>(
664
+ description: string,
665
+ fn: () => T | null | undefined | false | Promise<T | null | undefined | false>,
666
+ opts?: { timeoutMs?: number; intervalMs?: number },
667
+ ): Promise<Wrapped<T>>;
668
+ /**
669
+ * Register a DNS name at runtime so the rest of this test (and anything
670
+ * downstream of it) can reach it. `{ ingress: true }` points the name at
671
+ * the daemon (a fake / TLS proxy); `{ service }` points it at a
672
+ * container's live IP; a `*.suffix` wildcard (e.g. `"*.example.com"`)
673
+ * routes a whole domain — the natural fit for k3s Ingress hosts a test
674
+ * applies on the fly.
675
+ *
676
+ * Answered by spectest-resolver, so it works for VM-host/test code,
677
+ * `ctx.browser()`, and peer containers (Docker forwards unknown names to
678
+ * the host resolver). It does NOT land in any container's `/etc/hosts`.
679
+ * The registration mutates in-daemon state, so it's isolated to this
680
+ * test's fork — like fake state.
681
+ *
682
+ * ```ts
683
+ * await ctx.svc.k8s.apply(ingressFor("foo.example.com"));
684
+ * await ctx.dnsName("foo.example.com", { service: "k8s" });
685
+ * const res = await ctx.fetch("http://foo.example.com");
686
+ * ```
687
+ */
688
+ dnsName(hostname: string, target: DnsTarget): Promise<void>;
689
+ /**
690
+ * Start a real container on `spectest-net` at runtime — a peer machine
691
+ * with its own IP, reachable like any boot service. Returns once the
692
+ * container is up and its `readyCheck` (if any) has passed.
693
+ *
694
+ * The new container is part of this test's post-state snapshot, so a
695
+ * `dependsOn` child inherits it (same PID, same data) while siblings,
696
+ * which fork from the parent's earlier snapshot, never see it — the same
697
+ * isolation fake `state` and {@link dnsName} get. Reach it by `name`
698
+ * (single-label, via the resolver) or by any `hostnames` you pass; map a
699
+ * multi-label name onto it with `ctx.dnsName(host, { service: name })`.
700
+ *
701
+ * The image is pulled on first use (fast through the host cache). See
702
+ * {@link RuntimeServiceSpec}.
703
+ *
704
+ * ```ts
705
+ * const { name } = await ctx.startService({
706
+ * name: `db-${crypto.randomUUID().slice(0, 8)}`,
707
+ * image: { type: "registry", reference: "postgres:16-alpine" },
708
+ * env: { POSTGRES_PASSWORD: "secret" },
709
+ * readyCheck: { type: "exec", command: "pg_isready -h 127.0.0.1 -p 5432" },
710
+ * });
711
+ * const sql = new Bun.SQL(`postgres://postgres:secret@${name}:5432/postgres`);
712
+ * ```
713
+ */
714
+ startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
715
+ /** Stop and remove a runtime service started via {@link startService}
716
+ * (no-op if it's already gone). */
717
+ stopService(name: string): Promise<void>;
718
+ }
719
+
720
+ export interface ExecResult {
721
+ stdout: string;
722
+ stderr: string;
723
+ exitCode: number;
724
+ }
725
+
726
+ export interface TestSuite<
727
+ S extends ServicesMap = ServicesMap,
728
+ F extends FakesMap = FakesMap,
729
+ > {
730
+ tests: TestCase<unknown, S, F>[];
731
+ }
732
+
733
+ // ──────────────────────────────────────────────────────────────────────────
734
+ // Fakes — in-daemon HTTP servers that masquerade as external APIs.
735
+ // ──────────────────────────────────────────────────────────────────────────
736
+
737
+ /**
738
+ * A fake server hosted in the spectest-daemon. Use this to stand in for
739
+ * external HTTP APIs (auth providers, payment gateways, …) that you can't
740
+ * call directly from the hermetic test VM.
741
+ *
742
+ * Each fake declares one or more `hostnames` it answers to. The daemon
743
+ * binds an HTTP listener per unique `port` on 0.0.0.0 (the bridge gateway
744
+ * is reachable from every service container), and `spectest-resolver`
745
+ * answers DNS for those hostnames with the bridge gateway IP. Containers
746
+ * doing `fetch("http://api.stripe.com/v1/charges")` route into the daemon,
747
+ * which dispatches to the matching fake by Host header.
748
+ *
749
+ * Internal state lives in plain JS memory — it forks with the rest of the
750
+ * snapshot, so per-test forks start from a known baseline and each fork
751
+ * has its own copy. State is private to the fake; expose it for assertions
752
+ * via `helpers` functions that tests call as `ctx.fakes.<name>.<fn>(...)`.
753
+ *
754
+ * HTTPS works out of the box. Every fake is auto-bound on :443 with a
755
+ * leaf cert signed by the in-VM root CA (SANs = `hostnames`), and
756
+ * every service container trusts that CA via bind-mount + system-trust
757
+ * layer — point your app at `https://api.stripe.com` and it Just Works.
758
+ * HTTP also stays bound on `port` (default 80) for back-compat.
759
+ */
760
+ export interface FakeDefinition<
761
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
762
+ S = any,
763
+ H extends Record<string, unknown> = Record<string, never>,
764
+ > {
765
+ /** Stable name. Used as the key in `ctx.fakes` and in logs/UI. */
766
+ name: string;
767
+ /**
768
+ * Fully-qualified hostnames the fake answers to (e.g.
769
+ * `"api.stripe.com"`). Same rules as service `hostnames`: multi-label,
770
+ * lowercase, no `.internal` suffix, no collisions with services or
771
+ * other fakes.
772
+ */
773
+ hostnames: string[];
774
+ /** TCP port the fake listens on. Default `80`. */
775
+ port?: number;
776
+ /**
777
+ * Build the fake's initial state. Called once when the project loads.
778
+ * The returned value lives in daemon memory for the rest of the
779
+ * environment's life — it survives `bootstrap`, the warm-template
780
+ * snapshot, and every per-test fork (each fork sees its own copy).
781
+ */
782
+ state?: () => S | Promise<S>;
783
+ /**
784
+ * HTTP handler. Receives a standard `Request` (Bun-native), the fake's
785
+ * mutable `state`, and a {@link FakeContext} `ctx` for mutating the
786
+ * environment at runtime (e.g. `ctx.startService(...)` to provision a
787
+ * real backing instance, `ctx.dnsName(...)` to name it). Return any
788
+ * `Response`. Thrown errors surface as 500s. The request URL is the
789
+ * absolute URL the client used — useful for routing on the path.
790
+ */
791
+ handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
792
+ /**
793
+ * Build the helpers exposed at `ctx.fakes.<name>` — a record of
794
+ * **functions** that read or mutate the fake's `state` (received here)
795
+ * via closure. Called the first time a test touches the fake; result
796
+ * is cached for the daemon's life. Omit it and tests see `{}`: state
797
+ * stays private, reachable only through the functions you expose
798
+ * (e.g. `{ lastCharge(), declineSource(src) }`). Don't expose raw
799
+ * `state` or use getters — return copies/derived values from functions
800
+ * instead.
801
+ *
802
+ * Every call is tracked in the test timeline: it records a `fake` step
803
+ * and the return value is tagged so a later `expect(...)` on it nests
804
+ * under that step in the UI (same provenance as `fetch`/db results).
805
+ *
806
+ * Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
807
+ * helper can provision/teardown runtime services just like the handler.
808
+ */
809
+ helpers?: (args: { name: string; state: S; ctx: FakeContext }) => H | Promise<H>;
810
+ /**
811
+ * Internal. Platform secret references this fake needs at *record* time
812
+ * (set by {@link replayFake}). The daemon reports the union of these to
813
+ * the control plane, which resolves each via its `SecretResolver` and
814
+ * pushes the values on the eval path only — they never enter project
815
+ * files, the config hash, or a cassette. Not part of the authoring
816
+ * surface; `JSON.stringify` ignores it (fakes never serialize to config).
817
+ */
818
+ secretRefs?: string[];
819
+ }
820
+
821
+ /**
822
+ * Define a fake. `defineFake({ ... })` is a thin wrapper that pins the
823
+ * generic `S` and `H` so `helpers`/`handler` see the inferred state type
824
+ * without the caller having to spell it out twice.
825
+ */
826
+ export function defineFake<
827
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
828
+ S = any,
829
+ H extends Record<string, unknown> = Record<string, never>,
830
+ >(opts: FakeDefinition<S, H>): FakeDefinition<S, H> {
831
+ if (!opts.name || opts.name.length === 0) {
832
+ throw new Error("defineFake: `name` is required");
833
+ }
834
+ if (!Array.isArray(opts.hostnames) || opts.hostnames.length === 0) {
835
+ throw new Error(`defineFake(${opts.name}): at least one hostname is required`);
836
+ }
837
+ for (const h of opts.hostnames) {
838
+ const lower = h.toLowerCase();
839
+ if (!HOSTNAME_RE.test(lower)) {
840
+ throw new Error(
841
+ `defineFake(${opts.name}): invalid hostname ${JSON.stringify(h)} — must be a multi-label DNS name (e.g. "api.stripe.com")`,
842
+ );
843
+ }
844
+ if (lower === "internal" || lower.endsWith(".internal")) {
845
+ throw new Error(
846
+ `defineFake(${opts.name}): hostname ${JSON.stringify(h)} ends in reserved ".internal" TLD`,
847
+ );
848
+ }
849
+ }
850
+ if (typeof opts.handler !== "function") {
851
+ throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
852
+ }
853
+ return opts;
854
+ }
855
+
856
+ /** Map of fake-name -> FakeDefinition, keyed by stable name. `any` for
857
+ * both generic args (not the bare `FakeDefinition` default of
858
+ * `<any, Record<string, never>>`) so a concrete fake that ships real
859
+ * `helpers` — whose `helpers`/`handler` function types would otherwise be
860
+ * invariant-incompatible with the narrower default — is still assignable.
861
+ * The precise per-fake helper types are recovered by `FakeHandlesFor<F>`
862
+ * at use sites. */
863
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
864
+ export type FakesMap = Record<string, FakeDefinition<any, any>>;
865
+
866
+ /** Rewrite a helpers record so each function's result is inspect-wrapped
867
+ * ({@link Wrapped}) — mirroring the runtime, where `trackFakeHelpers` wraps
868
+ * every helper return value for assertion provenance. Without this the raw
869
+ * return type (e.g. `Charge[]`) reaches `expect`, which the `Provenanced`
870
+ * gate rejects. Handles sync and async helpers; non-function members (and
871
+ * `void` side-effect helpers) pass through untouched. Mirrors `Tagged<T>` in
872
+ * `components/k3s.ts`, extended to cover synchronous returns. */
873
+ type WrappedHelpers<H> = {
874
+ [K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R>
875
+ ? (...args: A) => Promise<Wrapped<R>>
876
+ : H[K] extends (...args: infer A) => infer R
877
+ ? (...args: A) => [R] extends [void] ? void : Wrapped<R>
878
+ : H[K];
879
+ };
880
+
881
+ /** Awaited return type of a fake's `helpers` factory (with each result
882
+ * inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
883
+ * default) when the user didn't ship one. */
884
+ type FakeHelpersOf<F> = F extends FakeDefinition<infer S, infer H>
885
+ ? [H] extends [Record<string, never>]
886
+ ? { state: S }
887
+ : WrappedHelpers<H>
888
+ : never;
889
+
890
+ /** Per-fake handles derived from a concrete fakes map; what tests see at
891
+ * `ctx.fakes`. For a concrete map (fakes declared in `defineEnvironment`)
892
+ * each key is the precise helpers record. When `F` is the loose default
893
+ * `FakesMap` (no fakes declared, or generic daemon-side code), this
894
+ * collapses to a permissive record so untyped access still compiles. */
895
+ export type FakeHandlesFor<F extends FakesMap> = string extends keyof F
896
+ ? Record<string, Record<string, unknown>>
897
+ : { [K in keyof F]: FakeHelpersOf<F[K]> };
898
+
899
+ /**
900
+ * A `test(...)` function bound to a concrete services map (and fakes map).
901
+ * Returned by `defineEnvironment(...).test`; gives tests fully-typed access
902
+ * to `ctx.svc.<key>` and `ctx.fakes.<key>` without per-call casts.
903
+ */
904
+ export interface TypedTest<
905
+ S extends ServicesMap,
906
+ F extends FakesMap = FakesMap,
907
+ > {
908
+ <T = void>(name: string, fn: TestFn<T, undefined, S, F>): TestCase<T, S, F>;
909
+ <T = void, P = undefined>(
910
+ name: string,
911
+ opts: TestOpts<P, S, F>,
912
+ fn: TestFn<T, P, S, F>,
913
+ ): TestCase<T, S, F>;
914
+ }
915
+
916
+ // ──────────────────────────────────────────────────────────────────────────
917
+ // Project (the unified default export)
918
+ // ──────────────────────────────────────────────────────────────────────────
919
+
920
+ /**
921
+ * A complete project definition: an environment, an optional one-shot
922
+ * setup hook, and an optional test suite. `spectest/index.ts`
923
+ * default-exports one of these via `env.project([...tests])` or
924
+ * `env.project({ setup, tests })`.
925
+ */
926
+ export interface Project<
927
+ S extends ServicesMap = ServicesMap,
928
+ F extends FakesMap = FakesMap,
929
+ > {
930
+ environment: EnvironmentConfig<S>;
931
+ /**
932
+ * Fake servers hosted in the spectest-daemon (see {@link defineFake}).
933
+ * Keyed by stable fake name; the key becomes the slot in
934
+ * `ctx.fakes.<key>` from test code. Populated from the `fakes` declared
935
+ * in `defineEnvironment(...)`.
936
+ */
937
+ fakes?: F;
938
+ /**
939
+ * Project-level setup. Runs once, after every service is Ready and
940
+ * its per-service `setup` has completed, before the warm-template
941
+ * snapshot. Use this for state that's part of "the environment as
942
+ * the tests expect to find it" — seed data in a database, an initial
943
+ * Deployment in k3s, etc. Like service-level setup, the result is
944
+ * captured by every snapshot taken from that point, so warm restore
945
+ * and per-test forks inherit it without re-running.
946
+ *
947
+ * The ctx here is a slimmer cousin of TestContext: no recorder, no
948
+ * timeout, no browser/terminal — setup is not a test and doesn't
949
+ * appear in the timeline.
950
+ */
951
+ setup?: ProjectSetupFn<S, F>;
952
+ tests?: TestSuite<S, F>;
953
+ }
954
+
955
+ /**
956
+ * Slim context handed to the project-level `setup` hook. Same shape as
957
+ * `TestContext` but pared down — no testName, no parent, no recording
958
+ * surfaces (browser, terminal, poll). If you need polling inside setup,
959
+ * write a plain loop: setup runs outside the test timeline so there's
960
+ * nothing to record into.
961
+ */
962
+ export interface ProjectSetupContext<
963
+ S extends ServicesMap = ServicesMap,
964
+ F extends FakesMap = FakesMap,
965
+ > {
966
+ fetch: SpectestFetch;
967
+ exec(service: string, command: string): Promise<Wrapped<ExecResult>>;
968
+ readonly svc: ServiceHandlesFor<S>;
969
+ /** Same surface tests see — fakes are already up by setup time. Typed
970
+ * against the declared fakes map (see {@link TestContext.fakes}). */
971
+ readonly fakes: FakeHandlesFor<F>;
972
+ /**
973
+ * Register a DNS name (see {@link TestContext.dnsName}). Useful here to
974
+ * wire a wildcard like `"*.example.com"` to a k3s cluster once, before
975
+ * any test runs — it's captured into the warm-template snapshot, so every
976
+ * test inherits the route without re-registering.
977
+ */
978
+ dnsName(hostname: string, target: DnsTarget): Promise<void>;
979
+ /**
980
+ * Start a runtime service (see {@link TestContext.startService}). Started
981
+ * here, it's captured into the warm-template snapshot and inherited by
982
+ * every test — use it for backing instances that should exist before any
983
+ * test runs but aren't worth a boot-time `services` entry.
984
+ */
985
+ startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
986
+ /** Stop a runtime service (see {@link TestContext.stopService}). */
987
+ stopService(name: string): Promise<void>;
988
+ }
989
+
990
+ export type ProjectSetupFn<
991
+ S extends ServicesMap = ServicesMap,
992
+ F extends FakesMap = FakesMap,
993
+ > = (ctx: ProjectSetupContext<S, F>) => void | Promise<void>;
994
+
995
+ /**
996
+ * A defined environment. Returned by `defineEnvironment(...)` and used to
997
+ * (a) build typed tests against this environment's services and (b)
998
+ * bundle those tests into the project's default export.
999
+ *
1000
+ * ```ts
1001
+ * const env = defineEnvironment({
1002
+ * name: "todos",
1003
+ * services: {
1004
+ * db: postgres({ database: "todos", user: "todos", password: "todos" }),
1005
+ * },
1006
+ * });
1007
+ *
1008
+ * const createTodo = env.test("create todo", async (ctx) => {
1009
+ * // ctx.svc.db.client is the Bun SQL pool (the helper postgres ships).
1010
+ * await ctx.svc.db.client`SELECT 1`;
1011
+ * return { id: 1 };
1012
+ * });
1013
+ *
1014
+ * const markDone = env.test(
1015
+ * "mark done",
1016
+ * { dependsOn: createTodo },
1017
+ * async (ctx) => {
1018
+ * // ctx.parent is { id: number }
1019
+ * await ctx.svc.db.client`UPDATE todos SET done = TRUE WHERE id = ${ctx.parent.id}`;
1020
+ * },
1021
+ * );
1022
+ *
1023
+ * export default env.project([createTodo, markDone]);
1024
+ * ```
1025
+ */
1026
+ export interface ProjectOpts<
1027
+ S extends ServicesMap = ServicesMap,
1028
+ F extends FakesMap = FakesMap,
1029
+ > {
1030
+ /** Optional project-level setup. See `Project.setup`. */
1031
+ setup?: ProjectSetupFn<S, F>;
1032
+ /** Test cases for this project's suite. */
1033
+ tests?: TestCase<unknown, S, F>[];
1034
+ }
1035
+
1036
+ /**
1037
+ * Input to {@link defineEnvironment}: the environment config plus an
1038
+ * optional `fakes` map. `fakes` is declared here (rather than on
1039
+ * `project(...)`) so the fakes' types are bound when `env.test(...)`
1040
+ * creates a test — that's what makes `ctx.fakes.<key>` strongly typed.
1041
+ * `fakes` is stripped from `config` before it's stored/serialised, so the
1042
+ * wire `EnvironmentConfig` the Rust control plane sees never carries it.
1043
+ */
1044
+ export interface EnvironmentInput<
1045
+ S extends ServicesMap,
1046
+ F extends FakesMap = FakesMap,
1047
+ > extends EnvironmentConfig<S> {
1048
+ /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
1049
+ * shows up as `ctx.fakes.<key>` in tests, typed as that fake's helpers
1050
+ * record. */
1051
+ fakes?: F;
1052
+ }
1053
+
1054
+ export interface DefinedEnvironment<
1055
+ S extends ServicesMap,
1056
+ F extends FakesMap = FakesMap,
1057
+ > {
1058
+ /** The validated environment config. Plain data, JSON-serialisable —
1059
+ * the in-VM daemon ships this to the control plane on `/load`. */
1060
+ readonly config: EnvironmentConfig<S>;
1061
+ /** Define a test against this environment. `ctx.svc.<key>` and
1062
+ * `ctx.fakes.<key>` are strongly typed against the services and fakes
1063
+ * maps. */
1064
+ readonly test: TypedTest<S, F>;
1065
+ /** Bundle this environment with a test suite into the project default
1066
+ * export. Pass tests as a plain array for the common case, or an
1067
+ * options bag to attach project-level `setup`. (Fakes are declared on
1068
+ * `defineEnvironment`, not here.) */
1069
+ project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
1070
+ project(opts: ProjectOpts<S, F>): Project<S, F>;
1071
+ }
1072
+
1073
+ /**
1074
+ * Define an environment and get back a builder you can hang tests off.
1075
+ * The builder's `.test(...)` returns test cases typed against the
1076
+ * environment's services (and any declared `fakes`), and `.project([...])`
1077
+ * produces the file's default export.
1078
+ */
1079
+ export function defineEnvironment<
1080
+ S extends ServicesMap,
1081
+ F extends FakesMap = FakesMap,
1082
+ >(input: EnvironmentInput<S, F>): DefinedEnvironment<S, F> {
1083
+ // Split fakes off the wire config; only { name, services, timeoutSecs }
1084
+ // is stored as `config` and shipped to the control plane.
1085
+ const { fakes, ...config } = input;
1086
+ validateEnvironmentConfig(config);
1087
+ if (fakes) validateFakes(config, fakes);
1088
+
1089
+ // Every test created via this env's `.test(...)` is registered here. A
1090
+ // split-layout project keeps its tests in `spectest/tests/**` — each file
1091
+ // imports this `env` (for types + `dependsOn` refs) and calls `env.test`,
1092
+ // which appends to `registry`. The daemon imports those files and then
1093
+ // reads the suite back off the default-exported Project's lazy `tests`
1094
+ // getter (installed in `project()` below). Keeping the env definition
1095
+ // itself free of test bodies is what lets the warm-template cache key
1096
+ // ignore test edits — see `project_content_hash` in the control plane.
1097
+ const registry: TestCase<unknown, S, F>[] = [];
1098
+ const test = ((
1099
+ name: string,
1100
+ optsOrFn: TestOpts<unknown, S, F> | TestFn<unknown, undefined, S, F>,
1101
+ maybeFn?: TestFn<unknown, unknown, S, F>,
1102
+ ): TestCase<unknown, S, F> => {
1103
+ const tc = (
1104
+ buildTestCase as unknown as (
1105
+ n: string,
1106
+ o: typeof optsOrFn,
1107
+ f?: typeof maybeFn,
1108
+ ) => TestCase<unknown, S, F>
1109
+ )(name, optsOrFn, maybeFn);
1110
+ registry.push(tc);
1111
+ return tc;
1112
+ }) as unknown as TypedTest<S, F>;
1113
+
1114
+ function project(
1115
+ arg?: TestCase<unknown, S, F>[] | ProjectOpts<S, F>,
1116
+ ): Project<S, F> {
1117
+ const opts: ProjectOpts<S, F> = Array.isArray(arg)
1118
+ ? { tests: arg }
1119
+ : (arg ?? {});
1120
+ const proj: Project<S, F> = { environment: config };
1121
+ if (opts.setup) proj.setup = opts.setup;
1122
+ if (fakes) proj.fakes = fakes;
1123
+ if (opts.tests && opts.tests.length > 0) {
1124
+ // Explicit suite (single-file / legacy layout): the tests are listed
1125
+ // right here, so freeze them now.
1126
+ proj.tests = validateSuite({ tests: opts.tests });
1127
+ } else {
1128
+ // Split layout: tests are defined in separate files and collected from
1129
+ // the registry as those files are imported. Expose them lazily so the
1130
+ // daemon sees whatever has registered by the time it reads `.tests`
1131
+ // (i.e. after it has imported `spectest/tests/**` on `/load-tests`).
1132
+ Object.defineProperty(proj, "tests", {
1133
+ enumerable: true,
1134
+ configurable: true,
1135
+ get(): TestSuite<S, F> | undefined {
1136
+ return registry.length > 0
1137
+ ? validateSuite({ tests: [...registry] })
1138
+ : undefined;
1139
+ },
1140
+ });
1141
+ }
1142
+ return proj;
1143
+ }
1144
+ return {
1145
+ config,
1146
+ test,
1147
+ project,
1148
+ };
1149
+ }
1150
+
1151
+ /**
1152
+ * Cross-check fakes against the environment's services: no duplicate
1153
+ * hostnames, no collisions with service names or service hostnames, and
1154
+ * each fake's name is unique (the user passed a map, so JS guarantees
1155
+ * the second condition — but we re-check defensively for the case where
1156
+ * an object literal is built programmatically).
1157
+ */
1158
+ function validateFakes(env: EnvironmentConfig, fakes: FakesMap): void {
1159
+ const claimedByService = new Map<string, string>();
1160
+ for (const [svcName, svc] of Object.entries(env.services)) {
1161
+ claimedByService.set(svcName.toLowerCase(), svcName);
1162
+ claimedByService.set(`${svcName.toLowerCase()}.internal`, svcName);
1163
+ for (const h of svc.hostnames ?? []) {
1164
+ claimedByService.set(h.toLowerCase(), svcName);
1165
+ }
1166
+ for (const entry of svc.tls ?? []) {
1167
+ claimedByService.set(entry.hostname.toLowerCase(), svcName);
1168
+ }
1169
+ }
1170
+ const claimedByFake = new Map<string, string>();
1171
+ for (const [key, fake] of Object.entries(fakes)) {
1172
+ if (!fake || typeof fake !== "object" || typeof fake.handler !== "function") {
1173
+ throw new Error(
1174
+ `fake ${JSON.stringify(key)} is not a FakeDefinition — pass the result of defineFake({...})`,
1175
+ );
1176
+ }
1177
+ for (const raw of fake.hostnames) {
1178
+ const h = raw.toLowerCase();
1179
+ const owningSvc = claimedByService.get(h);
1180
+ if (owningSvc !== undefined) {
1181
+ throw new Error(
1182
+ `fake ${JSON.stringify(key)} hostname ${JSON.stringify(raw)} collides with service ${JSON.stringify(owningSvc)}`,
1183
+ );
1184
+ }
1185
+ const owningFake = claimedByFake.get(h);
1186
+ if (owningFake !== undefined && owningFake !== key) {
1187
+ throw new Error(
1188
+ `hostname ${JSON.stringify(raw)} is claimed by both fake ${JSON.stringify(owningFake)} and fake ${JSON.stringify(key)}`,
1189
+ );
1190
+ }
1191
+ claimedByFake.set(h, key);
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ // `buildTestCase` is the runtime implementation behind every typed
1197
+ // `env.test(...)`. The DefinedEnvironment exposes it cast to TypedTest<S>
1198
+ // so callers see the strongly-typed overloads while the body stays
1199
+ // generic — TestFn is contravariant in `ctx`, so a single implementation
1200
+ // satisfies every TypedTest<S>.
1201
+ function buildTestCase(
1202
+ name: string,
1203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1204
+ optsOrFn: TestOpts<any> | TestFn<unknown, any>,
1205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1206
+ maybeFn?: TestFn<unknown, any>,
1207
+ ): TestCase<unknown> {
1208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1209
+ const [opts, fn]: [TestOpts<any>, TestFn<unknown, any>] =
1210
+ typeof optsOrFn === "function" ? [{}, optsOrFn] : [optsOrFn, maybeFn!];
1211
+ if (typeof fn !== "function") {
1212
+ throw new TypeError(
1213
+ `test(${JSON.stringify(name)}): missing test function`,
1214
+ );
1215
+ }
1216
+ return {
1217
+ id: slugify(name),
1218
+ name,
1219
+ dependsOn: opts.dependsOn,
1220
+ timeoutMs: opts.timeoutMs,
1221
+ run: fn,
1222
+ };
1223
+ }
1224
+
1225
+ function validateSuite<
1226
+ S extends ServicesMap,
1227
+ F extends FakesMap,
1228
+ >(suite: TestSuite<S, F>): TestSuite<S, F> {
1229
+ const seenIds = new Map<string, string>();
1230
+ for (const t of suite.tests) {
1231
+ const prior = seenIds.get(t.id);
1232
+ if (prior !== undefined) {
1233
+ throw new Error(
1234
+ `duplicate test id "${t.id}" (from ${JSON.stringify(
1235
+ prior,
1236
+ )} and ${JSON.stringify(t.name)}) — rename one`,
1237
+ );
1238
+ }
1239
+ seenIds.set(t.id, t.name);
1240
+ }
1241
+ // Validate that each dependsOn ref is in the suite. Catches typos /
1242
+ // imports forgotten in the tests array.
1243
+ for (const t of suite.tests) {
1244
+ if (t.dependsOn && !suite.tests.includes(t.dependsOn)) {
1245
+ throw new Error(
1246
+ `test "${t.name}" depends on "${t.dependsOn.name}" which is not in the suite`,
1247
+ );
1248
+ }
1249
+ }
1250
+ return suite;
1251
+ }
1252
+
1253
+ function slugify(name: string): string {
1254
+ const slug = name
1255
+ .toLowerCase()
1256
+ .replace(/[^a-z0-9]+/g, "-")
1257
+ .replace(/^-+|-+$/g, "");
1258
+ if (!slug) {
1259
+ throw new Error(`cannot derive id from test name ${JSON.stringify(name)}`);
1260
+ }
1261
+ return slug;
1262
+ }
1263
+
1264
+ // ──────────────────────────────────────────────────────────────────────────
1265
+ // Assertions
1266
+ // ──────────────────────────────────────────────────────────────────────────
1267
+
1268
+ export class ExpectationError extends Error {
1269
+ constructor(message: string) {
1270
+ super(message);
1271
+ this.name = "ExpectationError";
1272
+ }
1273
+ }
1274
+
1275
+ interface Matchers {
1276
+ toBe(expected: unknown): void;
1277
+ toEqual(expected: unknown): void;
1278
+ toBeTruthy(): void;
1279
+ toBeFalsy(): void;
1280
+ toBeGreaterThan(n: number): void;
1281
+ toBeLessThan(n: number): void;
1282
+ toContain(expected: unknown): void;
1283
+ toMatch(re: RegExp): void;
1284
+ toHaveLength(n: number): void;
1285
+ }
1286
+
1287
+ /**
1288
+ * Provenance-preserving transforms. Each returns a fresh {@link Expectation}
1289
+ * bound to the *decoded* value but still carrying the originating op's tag (its
1290
+ * path extended by a marker like `<base64>`), so the eventual assertion nests
1291
+ * under the source read in the timeline. This is what lets a value that must be
1292
+ * decoded through a plain function — `Buffer.from(x,"base64").toString()`,
1293
+ * `decodeURIComponent(x)` — be asserted on with the *full* matcher vocabulary
1294
+ * (`toHaveLength`, `toContain`, `toMatch`, …) without dropping to `expectRaw`,
1295
+ * which severs the link. Transforms chain (`.base64Decoded().urlDecoded()`),
1296
+ * and the matcher you finish with may be negated (`.base64Decoded().not.…`).
1297
+ *
1298
+ * They supersede the old `toBeBase64Of` matcher, which was equality-only:
1299
+ * `expect(secret.data?.X).base64Decoded().toBe("plain")` is the direct
1300
+ * replacement, and `.toHaveLength(32)` / `.toContain("redis://")` / `.toMatch(re)`
1301
+ * are the cases it couldn't express.
1302
+ */
1303
+ interface Transforms {
1304
+ /** Interpret the value as a base64 string and decode it (UTF-8), mirroring
1305
+ * `Buffer.from(x, "base64").toString()`. Throws-as-failed-assertion if the
1306
+ * value isn't a string. */
1307
+ base64Decoded(): Expectation;
1308
+ /** URL-decode the value, mirroring `decodeURIComponent(x)`. Chains after
1309
+ * `base64Decoded()` for base64-then-URL-encoded values. */
1310
+ urlDecoded(): Expectation;
1311
+ /**
1312
+ * Generic escape hatch: apply an arbitrary `fn` to the raw value and assert on
1313
+ * the result, keeping provenance. `label` is a plain word (`"json"`,
1314
+ * `"decompressed"`) appended to the op path so the timeline shows what was
1315
+ * derived; the UI brackets it (`<json>`) to mark it as a derived step, so do
1316
+ * **not** include the brackets yourself. Sugar like {@link base64Decoded} is
1317
+ * built on this. If `fn` throws, the next assertion records as a failure
1318
+ * describing the transform error.
1319
+ */
1320
+ transform(label: string, fn: (value: unknown) => unknown): Expectation;
1321
+ }
1322
+
1323
+ export interface Expectation extends Matchers, Transforms {
1324
+ not: Matchers;
1325
+ }
1326
+
1327
+ export function expect(actual: Provenanced): Expectation {
1328
+ // The parameter is typed to the {@link Provenanced} family so a raw value
1329
+ // (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* — every
1330
+ // assertion that reaches the timeline this way carries a provenance link back
1331
+ // to the op that produced it. Assert on a genuinely raw value with
1332
+ // `expectRaw(message, value)` instead.
1333
+ //
1334
+ // A `null`/`undefined` read off a wrapped op result reaches here untagged
1335
+ // (a symbol can't ride on nullish). `adoptNullishTag` recovers the tag from
1336
+ // the proxy's most-recent nullish-leaf note and mints a tagged holder, so
1337
+ // `expect(dep.status.readyReplicas).toBeFalsy()` nests under its op just like
1338
+ // a non-nullish read. Done once here (not in `buildMatchers`) so the `.not`
1339
+ // re-pass reuses the same tagged holder instead of re-consuming the note.
1340
+ return buildMatchers(adoptNullishTag(actual), false);
1341
+ }
1342
+
1343
+ /**
1344
+ * Assert on a value with **no provenance** — a computed number, a raw
1345
+ * WebSocket frame, anything that didn't flow from a recorded op. `message` is
1346
+ * required and reads as the natural follow-on to "assert …" (e.g.
1347
+ * `expectRaw("id matches the generated value", id)`); it renders as the
1348
+ * assertion's label in the CLI/dashboard ("ASSERT <message>") since a raw
1349
+ * assertion has no op to nest under. Prefer `expect(...)` whenever the value
1350
+ * carries provenance — only reach for this when the type gate would (rightly)
1351
+ * reject the value.
1352
+ */
1353
+ export function expectRaw(message: string, actual: unknown): Expectation {
1354
+ // Force the raw form so no stray tag is read even if a wrapped value is
1355
+ // passed: an `expectRaw` assertion is *deliberately* unlinked, rendered at
1356
+ // top level under its own message rather than nested beneath an op.
1357
+ return buildMatchers(readRaw(actual), false, message);
1358
+ }
1359
+
1360
+ function buildMatchers(wrapped: unknown, negated: boolean, message?: string): Expectation {
1361
+ // If `wrapped` is an inspect-tagged value (from fetch / db / browser),
1362
+ // pull its origin metadata and run matchers against the raw underlying
1363
+ // value. Plain `expect(value)` is unaffected. Tag + raw are read once here
1364
+ // and threaded explicitly into `buildCore`, so `.not` and transforms re-pass
1365
+ // them directly instead of re-reading the wrapper (and re-consuming any
1366
+ // adopted nullish note).
1367
+ return buildCore(readRaw(wrapped), readTag(wrapped), negated, message);
1368
+ }
1369
+
1370
+ /**
1371
+ * The matcher/transform factory, working off an already-unwrapped `actual` and
1372
+ * an explicit `tag`. `pendingError`, when set, marks a transform that failed
1373
+ * upstream: every matcher then records a failed assertion carrying that error
1374
+ * (irrespective of negation) and throws, and further transforms propagate it.
1375
+ */
1376
+ function buildCore(
1377
+ actual: unknown,
1378
+ tag: OpTag | undefined,
1379
+ negated: boolean,
1380
+ message?: string,
1381
+ pendingError?: string,
1382
+ ): Expectation {
1383
+ // Wraps a matcher: it computes the raw condition, the failure message,
1384
+ // records the assertion event, then throws iff the result is a failure.
1385
+ // `expectedFor` lets matchers record their expected value where it
1386
+ // makes sense (toBe / toEqual / toContain / toMatch) while matchers
1387
+ // like toBeTruthy leave it unset.
1388
+ const run = (
1389
+ matcher: string,
1390
+ cond: boolean,
1391
+ msg: string,
1392
+ expectedFor?: unknown,
1393
+ opts?: { actual?: unknown; pathSuffix?: string },
1394
+ ): void => {
1395
+ // A failed upstream transform short-circuits every matcher to a failure —
1396
+ // there is no meaningful value to match, and negation can't rescue a value
1397
+ // that never decoded — so we record `pendingError` and throw regardless of
1398
+ // `cond`/`negated`.
1399
+ if (pendingError !== undefined) {
1400
+ recordAssertion({
1401
+ matcher,
1402
+ negated,
1403
+ passed: false,
1404
+ actual: safeSerialize(actual),
1405
+ expected: expectedFor === undefined ? undefined : safeSerialize(expectedFor),
1406
+ error: pendingError,
1407
+ message,
1408
+ sourceSeq: tag?.sourceSeq,
1409
+ path: tag ? [...tag.path] : undefined,
1410
+ });
1411
+ throw new ExpectationError(pendingError);
1412
+ }
1413
+ const passed = cond !== negated;
1414
+ recordAssertion({
1415
+ matcher,
1416
+ negated,
1417
+ passed,
1418
+ actual: safeSerialize(opts && "actual" in opts ? opts.actual : actual),
1419
+ expected: expectedFor === undefined ? undefined : safeSerialize(expectedFor),
1420
+ error: passed ? undefined : msg,
1421
+ message,
1422
+ sourceSeq: tag?.sourceSeq,
1423
+ path: tag
1424
+ ? [...tag.path, ...(opts?.pathSuffix ? [opts.pathSuffix] : [])]
1425
+ : undefined,
1426
+ });
1427
+ if (!passed) throw new ExpectationError(msg);
1428
+ };
1429
+ // Apply `fn` to the raw value and rebuild against the result, extending the
1430
+ // op path by a derived-step marker so the decoded value still nests under the
1431
+ // source read. `label` is a plain word ("base64", "json"); the `<…>` marker
1432
+ // syntax that distinguishes a transform from a real property read in the
1433
+ // timeline is added here, so it lives in one place and never leaks into the
1434
+ // API surface — the same convention `inspect.ts` uses for array methods
1435
+ // (`<find>`, `<map>`). A throw becomes a `pendingError` on the returned
1436
+ // Expectation rather than escaping — a malformed decode renders as a failed
1437
+ // assertion in the timeline, not an unhandled exception. Propagates an
1438
+ // existing `pendingError` untouched.
1439
+ const applyTransform = (label: string, fn: (value: unknown) => unknown): Expectation => {
1440
+ const marker = `<${label}>`;
1441
+ const nextTag: OpTag | undefined = tag
1442
+ ? { sourceSeq: tag.sourceSeq, path: [...tag.path, marker] }
1443
+ : undefined;
1444
+ if (pendingError !== undefined) {
1445
+ return buildCore(undefined, nextTag, false, message, pendingError);
1446
+ }
1447
+ try {
1448
+ return buildCore(fn(actual), nextTag, false, message);
1449
+ } catch (err) {
1450
+ const detail = err instanceof Error ? err.message : String(err);
1451
+ return buildCore(undefined, nextTag, false, message, `${marker}: ${detail}`);
1452
+ }
1453
+ };
1454
+ return {
1455
+ toBe(expected) {
1456
+ const exp = readRaw(expected);
1457
+ run(
1458
+ "toBe",
1459
+ Object.is(actual, exp),
1460
+ `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}`,
1461
+ exp,
1462
+ );
1463
+ },
1464
+ toEqual(expected) {
1465
+ const exp = readRaw(expected);
1466
+ let equal = true;
1467
+ try {
1468
+ nodeAssert.deepStrictEqual(actual, exp);
1469
+ } catch {
1470
+ equal = false;
1471
+ }
1472
+ run(
1473
+ "toEqual",
1474
+ equal,
1475
+ `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}`,
1476
+ exp,
1477
+ );
1478
+ },
1479
+ toBeTruthy() {
1480
+ run(
1481
+ "toBeTruthy",
1482
+ !!actual,
1483
+ `expected ${fmt(actual)}${negated ? " not" : ""} to be truthy`,
1484
+ );
1485
+ },
1486
+ toBeFalsy() {
1487
+ run(
1488
+ "toBeFalsy",
1489
+ !actual,
1490
+ `expected ${fmt(actual)}${negated ? " not" : ""} to be falsy`,
1491
+ );
1492
+ },
1493
+ toBeGreaterThan(n) {
1494
+ run(
1495
+ "toBeGreaterThan",
1496
+ typeof actual === "number" && actual > n,
1497
+ `expected ${fmt(actual)}${negated ? " not" : ""} to be > ${n}`,
1498
+ n,
1499
+ );
1500
+ },
1501
+ toBeLessThan(n) {
1502
+ run(
1503
+ "toBeLessThan",
1504
+ typeof actual === "number" && actual < n,
1505
+ `expected ${fmt(actual)}${negated ? " not" : ""} to be < ${n}`,
1506
+ n,
1507
+ );
1508
+ },
1509
+ toContain(expected) {
1510
+ const exp = readRaw(expected);
1511
+ let contained = false;
1512
+ if (typeof actual === "string" && typeof exp === "string") {
1513
+ contained = actual.includes(exp);
1514
+ } else if (Array.isArray(actual)) {
1515
+ contained = actual.some((v) => {
1516
+ try {
1517
+ nodeAssert.deepStrictEqual(v, exp);
1518
+ return true;
1519
+ } catch {
1520
+ return false;
1521
+ }
1522
+ });
1523
+ }
1524
+ run(
1525
+ "toContain",
1526
+ contained,
1527
+ `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}`,
1528
+ exp,
1529
+ );
1530
+ },
1531
+ toMatch(re) {
1532
+ run(
1533
+ "toMatch",
1534
+ typeof actual === "string" && re.test(actual),
1535
+ `expected ${fmt(actual)}${negated ? " not" : ""} to match ${re}`,
1536
+ String(re),
1537
+ );
1538
+ },
1539
+ toHaveLength(n) {
1540
+ // Provenance-preserving count assertion. A wrapped array's `.length`
1541
+ // is deliberately raw (so `rows.length === 1` works), which means
1542
+ // `expect(rows.length)` can't link back to the originating op —
1543
+ // pass the container itself instead: `expect(rows).toHaveLength(1)`
1544
+ // keeps the tag, and we read the length off the raw value here.
1545
+ const len =
1546
+ typeof actual === "string" || Array.isArray(actual)
1547
+ ? actual.length
1548
+ : actual !== null &&
1549
+ typeof actual === "object" &&
1550
+ typeof (actual as { length?: unknown }).length === "number"
1551
+ ? ((actual as { length: number }).length as number)
1552
+ : undefined;
1553
+ run(
1554
+ "toHaveLength",
1555
+ len === n,
1556
+ `expected ${fmt(actual)}${negated ? " not" : ""} to have length ${n}` +
1557
+ (len === undefined ? " (value has no length)" : ` (got ${len})`),
1558
+ n,
1559
+ { actual: len, pathSuffix: "length" },
1560
+ );
1561
+ },
1562
+ transform(label, fn) {
1563
+ return applyTransform(label, fn);
1564
+ },
1565
+ base64Decoded() {
1566
+ return applyTransform("base64", (v) => {
1567
+ if (typeof v !== "string") {
1568
+ throw new Error(`base64Decoded expects a string, got ${typeof v}`);
1569
+ }
1570
+ return Buffer.from(v, "base64").toString();
1571
+ });
1572
+ },
1573
+ urlDecoded() {
1574
+ return applyTransform("urldecode", (v) => {
1575
+ if (typeof v !== "string") {
1576
+ throw new Error(`urlDecoded expects a string, got ${typeof v}`);
1577
+ }
1578
+ return decodeURIComponent(v);
1579
+ });
1580
+ },
1581
+ get not(): Matchers {
1582
+ // Re-pass the already-unwrapped value, tag and message so the tag and raw
1583
+ // label are preserved for the negated branch's AssertionEvent.
1584
+ return buildCore(actual, tag, !negated, message, pendingError);
1585
+ },
1586
+ } as Expectation;
1587
+ }
1588
+
1589
+ function fmt(v: unknown): string {
1590
+ if (typeof v === "string") return JSON.stringify(v);
1591
+ if (typeof v === "bigint") return `${v}n`;
1592
+ if (v === undefined) return "undefined";
1593
+ try {
1594
+ return JSON.stringify(v);
1595
+ } catch {
1596
+ return String(v);
1597
+ }
1598
+ }
1599
+
1600
+ /** `node:assert/strict` re-exported for users who prefer Node's built-in API. */
1601
+ export const assert = nodeAssert;