@specific.dev/spectest 0.26.0 → 0.28.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.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +172 -0
  12. package/dist/components/k3s.js +1124 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4611 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1328 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +527 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1819
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
package/src/index.ts DELETED
@@ -1,2350 +0,0 @@
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. (A decoded value loses provenance the same way — that
30
- // case is the `.transform(label, fn)` method every wrapped value carries, which
31
- // runs the still-tagged value through `fn` and re-wraps the result.)
32
- // To recover a raw value, call `.unwrap()` on it — spectest op results are
33
- // always wrapped (in every context), so the method is always there; there is no
34
- // `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
35
- export { field } from "./inspect.js";
36
-
37
- // Instrumented client primitives — drop-in replacements for Bun's native
38
- // clients that record each operation on the test event log and return their
39
- // results inspect-wrapped, so `expect(...)` on a result links back to the op
40
- // in the timeline (same provenance mechanism as the wrapped `fetch`). Reach
41
- // for these over the raw `Bun.*` clients in service `helpers` and tests so
42
- // assertions stay tracked. See `sql.ts` / `redis.ts` / `s3.ts`.
43
- export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
44
- export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
45
- export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
46
-
47
- export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
48
-
49
- import type { Browser, BrowserOptions } from "./browser.js";
50
-
51
- // Playwright-native locators — the select-then-act surface shared by
52
- // `ctx.browser()` and `ctx.mobile()`. `Locator` mirrors playwright-core's
53
- // Locator (getBy*/filter/first/nth/click/fill/textContent/…, STRICT mode).
54
- export type {
55
- Locator,
56
- GetByRoleOptions,
57
- GetByTextOptions,
58
- FilterOptions,
59
- ClickOptions,
60
- BoundingBox,
61
- } from "./locator.js";
62
- import type { Locator } from "./locator.js";
63
- import { isLocator, getLocatorProbe, DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
64
-
65
- // Mobile (Expo / React Native Web) surface: a phone-emulated session driven
66
- // with the same locators + touch gestures, replayed inside a phone bezel.
67
- // Opened with `ctx.mobile(ctx.svc.app)` where the app is registered via the
68
- // `expo()` component.
69
- export type { Mobile, MobileApp } from "./mobile.js";
70
- import type { Mobile, MobileApp } from "./mobile.js";
71
-
72
- export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
73
-
74
- import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
75
-
76
- // Low-level ingress primitives + the framework lowering that the friendly
77
- // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
78
- // `ingress.ts`.
79
- export {
80
- certificate,
81
- dnsName,
82
- proxy,
83
- provides,
84
- lowerIngress,
85
- isWildcard,
86
- SELF_SERVICE_TOKEN,
87
- } from "./ingress.js";
88
- export type {
89
- CertificateDecl,
90
- DnsDecl,
91
- ProxyDecl,
92
- IngressDecl,
93
- DnsTarget,
94
- LoweredIngress,
95
- } from "./ingress.js";
96
- import type { DnsTarget } from "./ingress.js";
97
-
98
- // ──────────────────────────────────────────────────────────────────────────
99
- // Environment configuration
100
- // ──────────────────────────────────────────────────────────────────────────
101
-
102
- export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
103
- /** Human-friendly name for the environment, e.g. "my-app". */
104
- name: string;
105
- /**
106
- * Services that make up the environment, keyed by service name. The key
107
- * is the container name, the DNS hostname on `spectest-net`, and the
108
- * string used in `dependsOn` references.
109
- */
110
- services: S;
111
- /** Sandbox timeout in seconds (default 1h). */
112
- timeoutSecs?: number;
113
- }
114
-
115
- /**
116
- * The shape that ends up in the JSON config the control plane and Rust
117
- * mirror care about. `ServiceDefinition` is the same thing with an extra
118
- * non-serialisable `client` factory tacked on; `JSON.stringify` drops
119
- * functions, so the wire format is `ServiceConfig`.
120
- */
121
- export interface ServiceConfig {
122
- /** Image definition: registry pull or inline build. */
123
- image: ServiceImage;
124
- /**
125
- * Shell command to run (sh -c). Defaults to the image's CMD. NB: this
126
- * **replaces the image's entrypoint** with `/bin/sh -c` — an
127
- * init-wrapped image (postgres's `docker-entrypoint.sh`) skips its
128
- * initialization. Use {@link args} to keep the entrypoint.
129
- */
130
- command?: string;
131
- /**
132
- * Arguments appended after the image name (`docker run <image>
133
- * <args…>`) — a CMD override that **keeps the image's entrypoint**.
134
- * E.g. `args: ["postgres", "-c", "wal_level=logical"]` still runs
135
- * postgres's `docker-entrypoint.sh` initialization. Mutually
136
- * exclusive with {@link command}.
137
- */
138
- args?: readonly string[];
139
- env?: Record<string, string>;
140
- /**
141
- * Ports the container listens on. Advisory only — surfaced in
142
- * `spectest list` output. Peer services reach each other by `<service>:<port>`
143
- * without any port declaration.
144
- */
145
- ports?: readonly number[];
146
- /**
147
- * Extra DNS names this service answers to inside the environment.
148
- * Each entry must be a fully-qualified, multi-label hostname (e.g.
149
- * `api.stripe.com`). Both peer containers (via Docker's embedded DNS)
150
- * and code on the VM host (via spectest-resolver) resolve these names
151
- * to the service's IP on `spectest-net`. Useful for mocking external
152
- * APIs — point an SDK at `http://api.stripe.com` and a service of
153
- * yours answers directly (no proxy). For HTTPS, use {@link tls}
154
- * instead — those names terminate TLS in the daemon and reverse-proxy
155
- * to the service's HTTP port.
156
- *
157
- * The `.internal` TLD is reserved: every service automatically
158
- * answers to `<name>.internal` in addition to its bare `<name>`, and
159
- * user-supplied hostnames may not end in `.internal`.
160
- */
161
- hostnames?: readonly string[];
162
- /**
163
- * Expose this service over HTTPS via a TLS-terminating reverse proxy
164
- * hosted in the spectest-daemon. Each entry maps a fully-qualified
165
- * hostname to the HTTP port the service listens on inside its
166
- * container; the daemon binds the hostname on `:443` (SNI-multiplexed,
167
- * with a leaf cert signed by the in-VM root CA) and on `:80`, and
168
- * proxies the request to `http://<service>:<port>`. WebSocket
169
- * upgrades are forwarded.
170
- *
171
- * The in-VM root CA is already trusted by Chromium (`ctx.browser()`)
172
- * and by service-container runtimes (Node, Python, Go, etc.) via
173
- * the env-var bundle, so `https://<hostname>/` Just Works from
174
- * tests and from peer services. Hostname rules match {@link hostnames}:
175
- * multi-label, lowercase, no `.internal` suffix, no collision with
176
- * services, other service TLS hostnames, or fakes.
177
- */
178
- tls?: readonly ServiceTls[];
179
- /** Bind-mounted volumes for state that survives snapshot/fork. */
180
- volumes?: readonly VolumeMount[];
181
- /**
182
- * Files seeded into the container's filesystem **before it starts**.
183
- * Each entry's `content` is written to a VM-host staging path and
184
- * bind-mounted (read-only) at `path` inside the container. Unlike a
185
- * `setup` hook — which runs after the container is up — `files` is the
186
- * way to inject configuration a process reads at boot, e.g. k3s's
187
- * `/etc/rancher/k3s/registries.yaml`, which must exist before
188
- * `k3s server` starts.
189
- */
190
- files?: readonly FileMount[];
191
- /** Other services (keys in the services map) that must be ready first. */
192
- dependsOn?: readonly string[];
193
- readyCheck?: ReadyCheck;
194
- /** Container workdir override. */
195
- workdir?: string;
196
- /**
197
- * Run the container with `--privileged`. Required by workloads that
198
- * embed their own container runtime (e.g. k3s) and need full access
199
- * to the host kernel surface. Off by default.
200
- */
201
- privileged?: boolean;
202
- /**
203
- * Tmpfs mounts (one `--tmpfs <path>` per entry). Required by some
204
- * workloads — k3s wants `/run` and `/var/run` writable and
205
- * non-persistent. Snapshots/forks preserve tmpfs contents along with
206
- * the rest of process memory.
207
- */
208
- tmpfs?: readonly string[];
209
- /**
210
- * Cgroup namespace mode — `"host"` or `"private"`. Omit for docker's
211
- * default. k3s needs `"host"` so its embedded containerd can manage
212
- * cgroups for the pods it schedules.
213
- */
214
- cgroupns?: string;
215
- }
216
-
217
- /**
218
- * The slice of the in-VM world a component's `setup` / `helpers` hooks can
219
- * reach — beyond what the service definition itself declares. Handed to
220
- * both hooks (see {@link ServiceSetupContext} / {@link ServiceHelpersContext})
221
- * so components stop hand-rolling `child_process` docker execs and stop
222
- * hard-coding control-plane paths like `/workspace`.
223
- */
224
- export interface ComponentContext {
225
- /**
226
- * Absolute path of the extracted project root inside the VM — the
227
- * directory the user's repo lands in, with `spectest/` directly under
228
- * it. Use this (never a hard-coded path) to locate project files a
229
- * component consumes, e.g. `supabase/migrations/**`.
230
- */
231
- projectRoot: string;
232
- /** Read a project file as UTF-8. Relative paths resolve against
233
- * {@link projectRoot}; absolute paths are read as-is. */
234
- readProjectFile(path: string): Promise<string>;
235
- /**
236
- * Run a command inside a service container (a raw `docker exec` — not
237
- * recorded on any test timeline, result is plain, not
238
- * provenance-wrapped). Pass an **array** for exact argv with no shell
239
- * (`["psql", "-f", "-"]`), or a **string** to run via `sh -lc`.
240
- * `opts.stdin` is piped to the process — the natural way to feed a SQL
241
- * file to `psql -f -` or a manifest to `kubectl apply -f -`.
242
- *
243
- * Never throws on non-zero exit — inspect `exitCode` yourself.
244
- */
245
- exec(
246
- service: string,
247
- command: string | string[],
248
- opts?: ComponentExecOpts,
249
- ): Promise<ExecResult>;
250
- }
251
-
252
- /** Options for {@link ComponentContext.exec}. */
253
- export interface ComponentExecOpts {
254
- /** Piped to the command's stdin, then closed. */
255
- stdin?: string;
256
- /** Working directory inside the container (`docker exec -w`). */
257
- cwd?: string;
258
- /** Kill the exec after this long. Default 120_000. */
259
- timeoutMs?: number;
260
- }
261
-
262
- /** What a service's `setup` hook receives. */
263
- export interface ServiceSetupContext<
264
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
- H extends Record<string, any> = Record<string, never>,
266
- > extends ComponentContext {
267
- /** The service's key in the services map (container + DNS name). */
268
- name: string;
269
- /** The record the service's `helpers` factory returned (cached — setup
270
- * and tests share one instance), or `{}` when it ships none. */
271
- helpers: H;
272
- }
273
-
274
- /** What a service's `helpers` factory receives. */
275
- export interface ServiceHelpersContext extends ComponentContext {
276
- /** The service's key in the services map (container + DNS name). */
277
- name: string;
278
- }
279
-
280
- /**
281
- * Same shape as `ServiceConfig` plus an optional `helpers` factory that
282
- * opens a namespace under `ctx.svc.<name>` inside tests. The factory
283
- * returns a record — each key becomes `ctx.svc.<name>.<key>`. Components
284
- * like `postgres(...)` use this to expose a pre-wired SQL pool at
285
- * `ctx.svc.db.client`; nothing stops a component from exposing several
286
- * helpers under one service (e.g. `client`, `admin`, `truncate()`).
287
- */
288
- export interface ServiceDefinition<
289
- // `any` (not `unknown`) so closed-shape interfaces — like
290
- // `PostgresHelpers` — are assignable. The constraint is only here to
291
- // signal intent ("helpers is a record of named conveniences");
292
- // `ServiceHandlesFor<S>` infers the helpers' real type at use sites.
293
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
294
- H extends Record<string, any> = Record<string, never>,
295
- > extends ServiceConfig {
296
- /**
297
- * Build the helpers exposed at `ctx.svc.<name>.<key>`. Called by the
298
- * daemon the first time a test touches this service; the result is
299
- * cached for the lifetime of the daemon (it survives snapshot/fork
300
- * along with the rest of daemon memory). `args.name` is the key the
301
- * user chose in the services map and matches the in-VM DNS name; the
302
- * rest of the {@link ServiceHelpersContext} (exec, projectRoot) lets a
303
- * component reach into its containers without daemon internals.
304
- */
305
- helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
306
- /**
307
- * One-shot setup after the container's `readyCheck` passes, before
308
- * dependents start and before any test runs. Awaited in-line with
309
- * bootstrap, so anything it produces (an ingress controller deployed
310
- * into k3s, a schema applied to a database) is part of the warm-template
311
- * snapshot and never re-runs on warm starts.
312
- *
313
- * `helpers` is the same record the `helpers` factory returns — building
314
- * it is cached, so `setup` and tests share one instance. If the service
315
- * doesn't declare `helpers`, the field is the empty object. The rest of
316
- * the {@link ServiceSetupContext} carries `exec` (docker exec with
317
- * stdin) and `projectRoot`/`readProjectFile` for project-file access.
318
- */
319
- setup?: (args: ServiceSetupContext<H>) => void | Promise<void>;
320
- }
321
-
322
- /** A services map — what users pass to `environment.services`. Entries
323
- * can be plain `ServiceConfig` literals or `ServiceDefinition`s that
324
- * carry a `helpers` factory (e.g. what `postgres(...)` returns). */
325
- export type ServicesMap = Record<string, ServiceConfig>;
326
-
327
- /** Awaited return type of a service's `helpers` factory, or `never` if
328
- * the service doesn't ship one. */
329
- type HelpersOf<D> = D extends {
330
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
331
- helpers: (...args: any) => infer R;
332
- }
333
- ? Awaited<R>
334
- : never;
335
-
336
- /**
337
- * Per-service handles derived from a concrete services map. Only
338
- * services that ship a `helpers` factory appear here; `ctx.svc.<name>`
339
- * is exactly the record the factory returned (e.g. `{ client: SqlClient }`
340
- * for `postgres(...)`). Services without helpers don't show up at all,
341
- * so plain `services: { api: { image: ... } }` adds no noise.
342
- */
343
- export type ServiceHandlesFor<S extends ServicesMap> = {
344
- [K in keyof S as HelpersOf<S[K]> extends never ? never : K]: HelpersOf<S[K]>;
345
- };
346
-
347
- /** Loose, runtime-friendly shape of `ctx.svc` for code that doesn't
348
- * know the concrete services map (the daemon, generic helpers). The
349
- * typed `ctx.svc` in test bodies is `ServiceHandlesFor<S>`. */
350
- export type ServiceHandles = Record<string, Record<string, unknown>>;
351
-
352
- // ──────────────────────────────────────────────────────────────────────────
353
- // Service groups — one services-map entry that expands to several services.
354
- //
355
- // A single container is a `ServiceConfig`/`ServiceDefinition`; a component
356
- // that is inherently a *constellation* of containers (Supabase ≈ 7 of them)
357
- // is a `ServiceGroup`. The user mounts the whole group under ONE key:
358
- //
359
- // services: { supabase: sb.group, app: { ... } }
360
- //
361
- // and `defineEnvironment` expands it: the group's `primary` part takes the
362
- // group's own key (`supabase`), every other part lands at `<key>-<part>`
363
- // (`supabase-db`, `supabase-auth`, …). Inside the group, `dependsOn`
364
- // entries naming a relative part are rewritten to the final keys, so a
365
- // group author never manually prefixes anything. Groups are pure sugar —
366
- // they expand to plain services before validation, so the wire config the
367
- // control plane sees is unchanged.
368
- // ──────────────────────────────────────────────────────────────────────────
369
-
370
- /** Symbol marking a value in the services map as a group to expand.
371
- * Enumerable-symbol convention (like the ingress `provides` decls): it
372
- * survives object spread but `JSON.stringify` drops it — not that a group
373
- * ever reaches the wire; expansion happens before the config exists. */
374
- const SERVICE_GROUP: unique symbol = Symbol.for("spectest.service.group");
375
-
376
- /**
377
- * Naming context handed to a group's `services` factory. Lets the factory
378
- * embed *final* DNS names in env vars and config files without knowing the
379
- * key the user will mount the group under.
380
- */
381
- export interface GroupNaming {
382
- /** The services-map key the user chose for the group. */
383
- name: string;
384
- /** Final services-map key of a member part: `key(primary)` is `name`
385
- * itself; any other part maps to `` `${name}-${part}` ``. */
386
- key(part: string): string;
387
- }
388
-
389
- export interface ServiceGroupInput<
390
- P extends ServicesMap,
391
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
392
- H extends Record<string, any> = Record<string, never>,
393
- Primary extends keyof P & string = keyof P & string,
394
- > {
395
- /**
396
- * Build the group's member services, keyed by RELATIVE part name
397
- * (`db`, `auth`, …). Called at expansion time with the final naming
398
- * context, so strings that must carry final DNS names (connection
399
- * URLs, embedded config) use `g.key("db")`. `dependsOn` entries that
400
- * name a relative part are rewritten to final keys automatically
401
- * (entries that don't match a part pass through untouched, so a group
402
- * service may still depend on an outside service).
403
- */
404
- services: (g: GroupNaming) => P;
405
- /**
406
- * The part that represents the group: it takes the group's own map key
407
- * (so `dependsOn: ["<groupKey>"]` from outside waits for it), and it
408
- * carries the group's consolidated `helpers`/`setup`. The primary is
409
- * made the group's dependency **sink** — every other member is added to
410
- * its `dependsOn` — so "the primary is ready" means "the whole group is
411
- * up, setup included". Consequently no member may depend on the
412
- * primary (that would be a cycle); pick a gateway/front-door part.
413
- */
414
- primary: Primary;
415
- /**
416
- * The group's consolidated handle: helpers exposed at
417
- * `ctx.svc.<groupKey>` — one typed surface for the whole group (e.g.
418
- * `{ sql, url, anonKey }` for Supabase). Attached to the primary
419
- * service; the primary part itself must not also declare `helpers`.
420
- */
421
- helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
422
- /**
423
- * Group-level setup: runs once every member service is ready (run →
424
- * probe → per-part setup), before anything that `dependsOn` the group
425
- * starts and before any test runs. The home for "the whole stack is
426
- * up, now do X". Runs after the primary part's own `setup`, if any.
427
- */
428
- setup?: (args: ServiceSetupContext<H>) => void | Promise<void>;
429
- /**
430
- * Pin the services-map key the group must be mounted under. For
431
- * components whose *derived* surface (connection URLs, env for the app
432
- * under test) is computed from a name option before expansion — a
433
- * mismatched key would silently split the two. Expansion errors with a
434
- * pointer to the component's `name` option instead.
435
- */
436
- expectKey?: string;
437
- }
438
-
439
- /**
440
- * A multi-service component — the value `serviceGroup(...)` returns, and
441
- * what a constellation component (e.g. `supabase()`) hands you to put in
442
- * the services map. Opaque; `defineEnvironment` expands it.
443
- */
444
- export interface ServiceGroup<
445
- P extends ServicesMap = ServicesMap,
446
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
447
- H extends Record<string, any> = Record<string, never>,
448
- Primary extends keyof P & string = keyof P & string,
449
- > extends ServiceGroupInput<P, H, Primary> {
450
- readonly [SERVICE_GROUP]: true;
451
- }
452
-
453
- /**
454
- * Define a multi-service group. See {@link ServiceGroupInput} for the
455
- * fields; the result goes straight into a services map:
456
- *
457
- * ```ts
458
- * const stack = serviceGroup({
459
- * primary: "gateway",
460
- * services: (g) => ({
461
- * db: { image: ..., ... },
462
- * gateway: { image: ..., env: { DB_URL: `postgres://${g.key("db")}:5432/db` },
463
- * dependsOn: ["db"] },
464
- * }),
465
- * helpers: () => ({ url: "http://..." }),
466
- * });
467
- *
468
- * defineEnvironment({ name: "app", services: { stack } });
469
- * // expands to services `stack` (the gateway) + `stack-db`;
470
- * // ctx.svc.stack is the helpers record.
471
- * ```
472
- */
473
- export function serviceGroup<
474
- P extends ServicesMap,
475
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
476
- H extends Record<string, any> = Record<string, never>,
477
- Primary extends keyof P & string = keyof P & string,
478
- >(input: ServiceGroupInput<P, H, Primary>): ServiceGroup<P, H, Primary> {
479
- if (typeof input.services !== "function") {
480
- throw new Error("serviceGroup: `services` must be a factory function");
481
- }
482
- if (!input.primary || typeof input.primary !== "string") {
483
- throw new Error("serviceGroup: `primary` (a relative part name) is required");
484
- }
485
- const group = { ...input } as ServiceGroup<P, H, Primary>;
486
- Object.defineProperty(group, SERVICE_GROUP, {
487
- value: true,
488
- enumerable: true,
489
- configurable: true,
490
- writable: false,
491
- });
492
- return group;
493
- }
494
-
495
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
496
- function isServiceGroup(v: unknown): v is ServiceGroup<any, any, any> {
497
- return (
498
- typeof v === "object" &&
499
- v !== null &&
500
- (v as Record<symbol, unknown>)[SERVICE_GROUP] === true
501
- );
502
- }
503
-
504
- /** What users pass as `services` to `defineEnvironment`: plain services
505
- * and/or groups to expand. */
506
- export type InputServicesMap = Record<
507
- string,
508
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
509
- ServiceConfig | ServiceGroup<any, any, any>
510
- >;
511
-
512
- type UnionToIntersection<U> = (
513
- U extends unknown ? (x: U) => void : never
514
- ) extends (x: infer I) => void
515
- ? I
516
- : never;
517
-
518
- /** Flatten an intersection of records into one mapped type (which also
519
- * gives it the implicit index signature `ServicesMap` needs). */
520
- type Reify<T> = { [K in keyof T]: T[K] };
521
-
522
- /** The primary part with the group's consolidated `helpers` grafted on,
523
- * so `ServiceHandlesFor` surfaces the group handle at the group key. */
524
- type PrimaryWithHandle<C, H> = [H] extends [Record<string, never>]
525
- ? C
526
- : Omit<C, "helpers"> & { helpers: (args: ServiceHelpersContext) => H };
527
-
528
- type ExpandGroupEntry<K extends string, G> = G extends ServiceGroup<
529
- infer P,
530
- infer H,
531
- infer Primary
532
- >
533
- ? {
534
- [Q in Exclude<keyof P & string, Primary> as `${K}-${Q}`]: P[Q];
535
- } & { [Q in K]: PrimaryWithHandle<P[Primary & keyof P], H> }
536
- : never;
537
-
538
- /**
539
- * The services map after group expansion — what `ctx.svc` and the wire
540
- * config are typed against. Groups expand to `<key>` (primary, carrying
541
- * the group handle) + `<key>-<part>` entries; plain services pass through.
542
- */
543
- export type ExpandServices<SI extends InputServicesMap> = Reify<
544
- UnionToIntersection<
545
- {
546
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
547
- [K in keyof SI & string]: SI[K] extends ServiceGroup<any, any, any>
548
- ? ExpandGroupEntry<K, SI[K]>
549
- : { [Q in K]: SI[K] };
550
- }[keyof SI & string]
551
- >
552
- > extends infer S extends ServicesMap
553
- ? S
554
- : ServicesMap;
555
-
556
- /**
557
- * Runtime counterpart of {@link ExpandServices}: expand every group entry
558
- * into plain services. Throws on key collisions, a missing primary,
559
- * nested groups, a violated `expectKey`, and members depending on the
560
- * primary (the primary is the group's sink — see
561
- * {@link ServiceGroupInput.primary}).
562
- */
563
- function expandServiceGroups(input: InputServicesMap): ServicesMap {
564
- const out: ServicesMap = {};
565
- const ownerOf = new Map<string, string>();
566
- const claim = (key: string, owner: string): void => {
567
- const prior = ownerOf.get(key);
568
- if (prior !== undefined) {
569
- throw new Error(
570
- `service key ${JSON.stringify(key)} is produced by both ${prior} and ${owner}`,
571
- );
572
- }
573
- ownerOf.set(key, owner);
574
- };
575
-
576
- for (const [key, entry] of Object.entries(input)) {
577
- if (!isServiceGroup(entry)) {
578
- claim(key, "the services map");
579
- out[key] = entry;
580
- continue;
581
- }
582
- const owner = `group ${JSON.stringify(key)}`;
583
- if (entry.expectKey !== undefined && entry.expectKey !== key) {
584
- throw new Error(
585
- `service group at key ${JSON.stringify(key)} expects to be mounted at ` +
586
- `${JSON.stringify(entry.expectKey)} — its derived names (URLs, app env) were built ` +
587
- `from that name. Mount it at ${JSON.stringify(entry.expectKey)}, or pass ` +
588
- `\`name: ${JSON.stringify(key)}\` to the component so both agree.`,
589
- );
590
- }
591
- const naming: GroupNaming = {
592
- name: key,
593
- key: (part: string) => (part === entry.primary ? key : `${key}-${part}`),
594
- };
595
- const parts: ServicesMap = entry.services(naming);
596
- const partKeys = new Set(Object.keys(parts));
597
- if (!partKeys.has(entry.primary)) {
598
- throw new Error(
599
- `${owner}: primary part ${JSON.stringify(entry.primary)} is not in the parts the ` +
600
- `services factory returned (${[...partKeys].join(", ")})`,
601
- );
602
- }
603
- // No member may depend on the primary (directly or transitively within
604
- // the group): the primary is about to become the group's sink.
605
- const reachesPrimary = (part: string, seen = new Set<string>()): boolean => {
606
- if (seen.has(part)) return false;
607
- seen.add(part);
608
- for (const dep of parts[part]?.dependsOn ?? []) {
609
- if (!partKeys.has(dep)) continue;
610
- if (dep === entry.primary || reachesPrimary(dep, seen)) return true;
611
- }
612
- return false;
613
- };
614
- for (const part of partKeys) {
615
- if (isServiceGroup(parts[part])) {
616
- throw new Error(`${owner}: part ${JSON.stringify(part)} is itself a group — groups don't nest`);
617
- }
618
- if (part !== entry.primary && reachesPrimary(part)) {
619
- throw new Error(
620
- `${owner}: part ${JSON.stringify(part)} depends on the primary ` +
621
- `${JSON.stringify(entry.primary)} — the primary must be the group's sink ` +
622
- `(everything else becomes its dependency so "primary ready" means "group up")`,
623
- );
624
- }
625
- }
626
- for (const [part, svc] of Object.entries(parts)) {
627
- const finalKey = naming.key(part);
628
- claim(finalKey, owner);
629
- // Rewrite relative dependsOn to final keys; a spread keeps function
630
- // fields (helpers/setup) and the enumerable-symbol ingress decls.
631
- const final: ServiceConfig = { ...svc };
632
- if (svc.dependsOn?.length) {
633
- final.dependsOn = svc.dependsOn.map((d) =>
634
- partKeys.has(d) ? naming.key(d) : d,
635
- );
636
- }
637
- if (part === entry.primary) {
638
- // Sink: the primary waits for every other member, so an outside
639
- // `dependsOn: ["<groupKey>"]` (and the group `setup` below) means
640
- // the whole group. Members already in dependsOn stay put.
641
- const deps = new Set(final.dependsOn ?? []);
642
- for (const other of partKeys) {
643
- if (other !== entry.primary) deps.add(naming.key(other));
644
- }
645
- final.dependsOn = [...deps];
646
- const def = final as ServiceDefinition<Record<string, unknown>>;
647
- if (entry.helpers) {
648
- if (def.helpers) {
649
- throw new Error(
650
- `${owner}: both the group and its primary part declare \`helpers\` — ` +
651
- `declare them once, on the group`,
652
- );
653
- }
654
- def.helpers = entry.helpers as (
655
- args: ServiceHelpersContext,
656
- ) => Record<string, unknown> | Promise<Record<string, unknown>>;
657
- }
658
- if (entry.setup) {
659
- const partSetup = def.setup;
660
- const groupSetup = entry.setup as (
661
- args: ServiceSetupContext<Record<string, unknown>>,
662
- ) => void | Promise<void>;
663
- def.setup = async (args) => {
664
- if (partSetup) await partSetup(args);
665
- await groupSetup(args);
666
- };
667
- }
668
- }
669
- out[finalKey] = final;
670
- }
671
- }
672
- return out;
673
- }
674
-
675
- /**
676
- * One TLS-terminated hostname for a service. The daemon binds the
677
- * hostname on `:443` (with a leaf cert signed by the in-VM root CA)
678
- * and `:80`, and reverse-proxies each request to `http://<service>:<port>`
679
- * inside the docker network. WebSocket upgrades are bridged.
680
- */
681
- export interface ServiceTls {
682
- /** Fully-qualified hostname clients use (e.g. `app.test`). Must be
683
- * multi-label, lowercase, not under the reserved `.internal` TLD,
684
- * and unique across all services and fakes in this environment. */
685
- hostname: string;
686
- /** HTTP port the service listens on inside its container. The
687
- * daemon forwards proxied requests here over `spectest-net`. */
688
- port: number;
689
- }
690
-
691
- export type ServiceImage =
692
- | { type: "registry"; reference: string }
693
- | {
694
- type: "dockerfile";
695
- /**
696
- * Dockerfile contents, written verbatim into the build context. The
697
- * build context is the project root (where `spectest/` lives), so any
698
- * `COPY` / `ADD` references resolve relative to that directory.
699
- */
700
- content: string;
701
- /** Extra glob patterns to exclude from the build context. */
702
- exclude?: readonly string[];
703
- };
704
-
705
- export interface VolumeMount {
706
- /**
707
- * Named shared volume. Two services mounting the same `name` share one
708
- * backing directory — the fit for sidecar pairs that exchange files
709
- * (e.g. an image proxy reading what a storage API wrote). The directory
710
- * lives in the per-env state tree, so it snapshots/forks with the rest
711
- * of the environment and is torn down for fresh-state like any other
712
- * volume. Names are environment-global: prefix with your service/group
713
- * name in a reusable component so two instances never collide.
714
- * Mutually exclusive with `source`.
715
- */
716
- name?: string;
717
- /**
718
- * Host path. Relative paths resolve under
719
- * `.spectest/volumes/<service>/`. Defaults to a path derived from `target`.
720
- */
721
- source?: string;
722
- /** Container path. */
723
- target: string;
724
- readOnly?: boolean;
725
- /**
726
- * Cache volume: the backing dir lives outside the per-env state tree and
727
- * survives a delta-restore teardown (which recreates every container,
728
- * volume, and the daemon for fresh-state semantics). Reserve this for
729
- * content-addressed data whose presence is purely an accelerator — an
730
- * image/layer store, a package cache — never for app state: anything in
731
- * a cache volume is visible to the "fresh" environment.
732
- */
733
- cache?: boolean;
734
- }
735
-
736
- export interface FileMount {
737
- /** Absolute path inside the container where the file is mounted. */
738
- path: string;
739
- /**
740
- * File contents. The literal token `{{SPECTEST_SERVICE}}` is expanded
741
- * to the owning service's name (its services-map key) before the file
742
- * is written — handy for self-referential config like a registry host
743
- * of `<key>.internal`, where a component can't know the key in advance.
744
- */
745
- content: string;
746
- /**
747
- * Optional octal mode string (e.g. `"0644"`) applied to the staged
748
- * file before it's bind-mounted. Defaults to the writer's umask.
749
- */
750
- mode?: string;
751
- }
752
-
753
- export type ReadyCheck =
754
- | { type: "tcp"; port: number; timeoutSecs?: number }
755
- | {
756
- type: "http";
757
- port: number;
758
- path?: string;
759
- /**
760
- * Extra request headers sent with each probe — for health endpoints
761
- * behind auth (`{ Authorization: "Bearer …" }`). Keeps the probe
762
- * image-agnostic where an `exec` + curl would depend on curl being
763
- * in the image.
764
- */
765
- headers?: Record<string, string>;
766
- /**
767
- * Exact status code that counts as ready. Default: any 2xx. Use for
768
- * endpoints whose healthy answer isn't 2xx (e.g. a root path that
769
- * 301s or 401s once the server is actually up).
770
- */
771
- expectStatus?: number;
772
- timeoutSecs?: number;
773
- }
774
- /**
775
- * Run a shell command inside the container; exit 0 = ready. Used when
776
- * the readiness signal isn't reachable via plain TCP/HTTP from outside
777
- * (k3s API server uses mTLS, so the natural probe is `kubectl get
778
- * --raw=/readyz` from inside).
779
- */
780
- | { type: "exec"; command: string; timeoutSecs?: number };
781
-
782
- // ──────────────────────────────────────────────────────────────────────────
783
- // Runtime services — containers started *during* a test/eval/setup or from
784
- // inside a fake handler, rather than declared up-front in `services`.
785
- // ──────────────────────────────────────────────────────────────────────────
786
-
787
- /**
788
- * Spec for a service started at runtime via {@link TestContext.startService}
789
- * (or the `ctx` handed to a fake). It's a normal {@link ServiceConfig} plus a
790
- * required `name`, minus only `dependsOn` (there is no boot DAG at runtime;
791
- * the caller orders `startService` calls itself with `await`).
792
- *
793
- * The container joins `spectest-net` with its own IP and is resolvable by
794
- * `name` (single-label, via the resolver / docker embedded DNS) and by any
795
- * `hostnames` (extra `--network-alias`es). Like everything else in the VM it
796
- * is captured by the per-test post-state snapshot, so a `dependsOn` child
797
- * inherits the live container while siblings never see it.
798
- *
799
- * `tls: [{ hostname, port }]` works exactly as it does for a boot service:
800
- * the daemon mints a leaf cert from the in-VM root CA and stands up a
801
- * TLS-terminating reverse proxy at `https://<hostname>/` (and plain
802
- * `http://<hostname>/`) → the container's `port`, binding it onto the live
803
- * `:443`/`:80` ingress listeners the moment the container is ready. The
804
- * hostname resolves to the daemon gateway for tests, `ctx.browser()`, and
805
- * peer containers. Because the route lives in daemon memory (and the
806
- * resolver registry file), it forks with the per-test snapshot like fake
807
- * state, and is torn down when the service is stopped. This lets a runtime
808
- * provider mint a CA-trusted HTTPS endpoint on demand — e.g. a per-DB proxy
809
- * the Neon serverless driver reaches at its *default* `https://<host>/sql`.
810
- */
811
- export interface RuntimeServiceSpec extends Omit<ServiceConfig, "dependsOn"> {
812
- /**
813
- * Container name and primary DNS name on `spectest-net`. Must be unique
814
- * within the current fork — generate a fresh one per provisioned instance
815
- * (e.g. `db-${crypto.randomUUID().slice(0, 8)}`).
816
- */
817
- name: string;
818
- }
819
-
820
- /** What {@link TestContext.startService} resolves to once the container is up
821
- * and its `readyCheck` (if any) has passed. */
822
- export interface RuntimeServiceHandle {
823
- /** The container/DNS name — the spec's `name`. */
824
- name: string;
825
- /** The container's IP on `spectest-net`. Reachable directly from peer
826
- * containers and from VM-host/test code. */
827
- ip: string;
828
- }
829
-
830
- /**
831
- * The slice of the environment a fake can mutate at runtime — the same
832
- * primitives {@link TestContext} exposes to tests. Passed as the third
833
- * argument to a fake's `handler` and as `ctx` to its `helpers` factory, so
834
- * a fake (e.g. a database-provider control plane) can provision real
835
- * backing services on demand and wire up DNS for them, exactly as a test
836
- * would.
837
- */
838
- export interface FakeContext {
839
- /** Start a real container on `spectest-net` at runtime. See
840
- * {@link RuntimeServiceSpec}. */
841
- startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
842
- /** Stop and remove a runtime service started earlier (no-op if gone). */
843
- stopService(name: string): Promise<void>;
844
- /** Map a DNS name onto a service IP (or the daemon ingress). See
845
- * {@link TestContext.dnsName}. */
846
- dnsName(hostname: string, target: DnsTarget): Promise<void>;
847
- }
848
-
849
- function validateEnvironmentConfig<S extends ServicesMap>(
850
- config: EnvironmentConfig<S>,
851
- ): void {
852
- const entries = Object.entries(config.services);
853
- const serviceNames = new Set(entries.map(([n]) => n));
854
- const claimedBy = new Map<string, string>();
855
- const claimBy = (h: string, name: string, kind: "hostname" | "tls"): void => {
856
- const prior = claimedBy.get(h);
857
- if (prior !== undefined && prior !== name) {
858
- throw new Error(
859
- `hostname ${JSON.stringify(h)} is claimed by both "${prior}" and "${name}"`,
860
- );
861
- }
862
- if (serviceNames.has(h)) {
863
- throw new Error(
864
- `${kind} ${JSON.stringify(h)} collides with service name "${h}"`,
865
- );
866
- }
867
- claimedBy.set(h, name);
868
- };
869
- for (const [name, svc] of entries) {
870
- if (name.length === 0) {
871
- throw new Error(
872
- "service name (services map key) must be a non-empty string",
873
- );
874
- }
875
- for (const dep of svc.dependsOn ?? []) {
876
- if (!serviceNames.has(dep)) {
877
- throw new Error(
878
- `service "${name}" dependsOn "${dep}" which is not a service in this environment`,
879
- );
880
- }
881
- }
882
- for (const vol of svc.volumes ?? []) {
883
- if (vol.name !== undefined && vol.source !== undefined) {
884
- throw new Error(
885
- `service "${name}" volume for ${JSON.stringify(vol.target)} sets both \`name\` and \`source\` — a named shared volume derives its backing dir from the name`,
886
- );
887
- }
888
- if (vol.name !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(vol.name)) {
889
- throw new Error(
890
- `service "${name}" volume name ${JSON.stringify(vol.name)} must be alphanumeric plus [._-]`,
891
- );
892
- }
893
- }
894
- for (const raw of svc.hostnames ?? []) {
895
- const h = raw.toLowerCase();
896
- if (!HOSTNAME_RE.test(h)) {
897
- throw new Error(
898
- `service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name (e.g. "api.stripe.com")`,
899
- );
900
- }
901
- if (h === "internal" || h.endsWith(".internal")) {
902
- throw new Error(
903
- `service "${name}" declares hostname ${JSON.stringify(raw)} — the ".internal" TLD is reserved; every service already answers to "<name>.internal" automatically`,
904
- );
905
- }
906
- claimBy(h, name, "hostname");
907
- }
908
- for (const entry of svc.tls ?? []) {
909
- if (!entry || typeof entry.hostname !== "string" || typeof entry.port !== "number") {
910
- throw new Error(
911
- `service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`,
912
- );
913
- }
914
- const h = entry.hostname.toLowerCase();
915
- if (!HOSTNAME_RE.test(h)) {
916
- throw new Error(
917
- `service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name (e.g. "app.test")`,
918
- );
919
- }
920
- if (h === "internal" || h.endsWith(".internal")) {
921
- throw new Error(
922
- `service "${name}" tls hostname ${JSON.stringify(entry.hostname)} ends in reserved ".internal" TLD`,
923
- );
924
- }
925
- if (!Number.isInteger(entry.port) || entry.port <= 0 || entry.port > 65535) {
926
- throw new Error(
927
- `service "${name}" tls hostname ${JSON.stringify(entry.hostname)} has invalid port ${entry.port}`,
928
- );
929
- }
930
- claimBy(h, name, "tls");
931
- }
932
- }
933
- }
934
-
935
- // Multi-label hostname: at least one dot, each label 1–63 chars of
936
- // [a-z0-9-], no leading/trailing hyphen.
937
- const HOSTNAME_RE =
938
- /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
939
-
940
- // ──────────────────────────────────────────────────────────────────────────
941
- // Test framework
942
- // ──────────────────────────────────────────────────────────────────────────
943
-
944
- /**
945
- * A single test step. Created via `test(...)` or `createTest(services)`.
946
- * Tests are referenced (not named by string) when one test depends on
947
- * another — keeps refactors and type-checking honest.
948
- *
949
- * `T` is the return type of the test body; it surfaces as `ctx.parent`
950
- * in children that `dependsOn` this case. `S` is the project's services
951
- * map (threaded through `defineEnvironment(...).test`) so `ctx.svc.<key>`
952
- * is strongly typed.
953
- */
954
- export interface TestCase<
955
- T = unknown,
956
- S extends ServicesMap = ServicesMap,
957
- F extends FakesMap = FakesMap,
958
- > {
959
- /** Stable id (slug of name). Used in MCP responses and CLI flags. */
960
- readonly id: string;
961
- readonly name: string;
962
- /** Parent test, if any. Single-parent for now. */
963
- readonly dependsOn?: TestCase<unknown, S, F>;
964
- /** Override the default per-test timeout (default 60s). */
965
- readonly timeoutMs?: number;
966
- /** @internal — the body that the in-sandbox daemon invokes. */
967
- readonly run: TestFn<T, unknown, S, F>;
968
- }
969
-
970
- export interface TestOpts<
971
- P = undefined,
972
- S extends ServicesMap = ServicesMap,
973
- F extends FakesMap = FakesMap,
974
- > {
975
- dependsOn?: TestCase<P, S, F>;
976
- timeoutMs?: number;
977
- }
978
-
979
- export type TestFn<
980
- T = void,
981
- P = undefined,
982
- S extends ServicesMap = ServicesMap,
983
- F extends FakesMap = FakesMap,
984
- > = (ctx: TestContext<P, S, F>) => T | Promise<T>;
985
-
986
- /**
987
- * Context object passed to every test function.
988
- *
989
- * `P` is the return type of the parent test (the one named in
990
- * `dependsOn`); for root tests it's `undefined`. `S` is the project's
991
- * services map (threaded through `defineEnvironment(...).test`) so
992
- * `ctx.svc` only includes services with a `client` factory, each typed
993
- * as the factory's output (e.g. a Bun SQL pool for `postgres(...)`).
994
- */
995
- export interface TestContext<
996
- P = undefined,
997
- S extends ServicesMap = ServicesMap,
998
- F extends FakesMap = FakesMap,
999
- > {
1000
- /**
1001
- * Instrumented `fetch`, exposed on `ctx`. Each call is recorded on the
1002
- * test timeline and resolves to a {@link WrappedResponse}: reads carry
1003
- * provenance so `expect(res.status)` / `expect(await res.json())` nest
1004
- * under the HTTP call. Because the status-line accessors are
1005
- * {@link Carrier}s, a raw `res.status === 200` is a *type error* — use
1006
- * `res.status.unwrap()` / `res.unwrap().status`, or assert via `expect`.
1007
- *
1008
- * (The plain global `fetch` is wrapped the same way at runtime but keeps
1009
- * the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
1010
- * results.)
1011
- */
1012
- fetch: SpectestFetch;
1013
- /** Run `sh -lc <command>` inside a service container. The result is
1014
- * {@link Wrapped}, so `res.stdout` is a `Carrier<string>` — assert via
1015
- * `expect(res.stdout)` or recover the raw string with `res.stdout.unwrap()`.
1016
- * (In `setup`/`eval` the result is wrapped too, just without a timeline
1017
- * link, so `.unwrap()` works there the same way.)
1018
- *
1019
- * The full run is also captured as an asciicast and replayed in the
1020
- * web UI — one recording per call, with output timestamped as it
1021
- * streamed, so slow or animated CLI output can be watched rather
1022
- * than read as a final blob. Unlike `terminal` there is no PTY: the
1023
- * program sees plain pipes (`isatty` false), so stdout/stderr stay
1024
- * byte-identical to what `exec` always returned and TTY-gated
1025
- * spinners/colour won't be emitted — reach for `terminal` when the
1026
- * CLI needs to believe it's on a TTY.
1027
- *
1028
- * Pass `{ cwd }` to run the command from a working directory instead of
1029
- * prefixing it with `cd <dir> && ` — the cwd is kept off the command
1030
- * string, so the timeline sidebar shows just the command and the
1031
- * directory surfaces in the detail view. */
1032
- exec(
1033
- service: string,
1034
- command: string,
1035
- opts?: ExecOpts,
1036
- ): Promise<Wrapped<ExecResult>>;
1037
- /**
1038
- * Run a command inside a service container under a PTY and record the
1039
- * full terminal session as an asciicast for replay in the web UI.
1040
- * Unlike `exec`, the program sees a TTY (`isatty(1)` true, colour
1041
- * codes preserved, line-buffered), so the byte stream may differ from
1042
- * what `exec` returns. Reach for `terminal` when you're driving a CLI
1043
- * and want a recording; reach for `exec` for plain piped output.
1044
- *
1045
- * One-shot convenience: this is a thin wrapper over `openTerminal` —
1046
- * spawn `command`, wait for exit, close, return the captured output
1047
- * and exit code. Reach for `openTerminal` when you want to keep the
1048
- * session alive across multiple sends or `waitFor` predicates (e.g.
1049
- * driving an interactive REPL or waiting on a loading spinner).
1050
- */
1051
- terminal(
1052
- service: string,
1053
- command: string,
1054
- opts?: TerminalOpts,
1055
- ): Promise<Wrapped<TerminalResult>>;
1056
- /**
1057
- * Open a long-lived interactive terminal in a service container.
1058
- * Returns a `Terminal` you can `send` keystrokes to, poll the
1059
- * rendered screen with `waitFor`, and `close` when done. Every byte
1060
- * the PTY emits is captured as an asciicast and replayed in the web
1061
- * UI the same way a one-shot `terminal(...)` is.
1062
- *
1063
- * Terminals are NOT auto-closed when the test ends. A docker exec
1064
- * subprocess is cheap to keep alive and Freestyle captures it
1065
- * cleanly in the snapshot along with the rest of the container, so
1066
- * leaking the handle past test end is fine. Call `.close()`
1067
- * explicitly if you want a `close` step in the timeline; otherwise
1068
- * `await term.exited` (after the program self-terminates) is the
1069
- * natural way to assert on the exit code.
1070
- */
1071
- openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
1072
- /**
1073
- * Open the headless browser. Backed by Chromium-over-CDP inside the VM.
1074
- *
1075
- * There is ONE persistent browser per environment: every `ctx.browser()`
1076
- * call returns it, and it stays alive across tests — the browser is part
1077
- * of the state a test's snapshot captures, so a `dependsOn` child resumes
1078
- * the exact live page its parent left (cookies, localStorage, signed-in
1079
- * SPA state). Sign in once in a parent test; every descendant is already
1080
- * signed in. Sibling tests fork from the same parent snapshot, so they
1081
- * can't see each other's browsing. A test with no browser-using ancestor
1082
- * gets a fresh browser on first call (first call's options win).
1083
- *
1084
- * `.close()` destroys the shared instance — the next `ctx.browser()`
1085
- * starts fresh. Don't call it for routine cleanup; recording is detached
1086
- * automatically at test end.
1087
- */
1088
- browser(opts?: BrowserOptions): Promise<Browser>;
1089
- /**
1090
- * Open a phone-emulated session for a mobile app and return a {@link Mobile}
1091
- * handle already pointed at it — no `navigate`. Pass the app handle a
1092
- * mobile-app component exposes on `ctx.svc`, e.g. a service declared with
1093
- * `expo()`:
1094
- *
1095
- * ```ts
1096
- * services: { app: expo() }
1097
- * // in a test:
1098
- * const m = await ctx.mobile(ctx.svc.app);
1099
- * await m.getByTestId("email").fill("a@b.com");
1100
- * await m.getByRole("button", { name: "Sign in" }).tap();
1101
- * await expect(m.getByText(/Welcome/)).toBeVisible();
1102
- * ```
1103
- *
1104
- * The session emulates the latest iPhone (viewport + DPR + mobile UA +
1105
- * touch) and the dashboard replays it inside a phone bezel.
1106
- *
1107
- * Sessions are persistent, one per app: like `ctx.browser()`, the live
1108
- * session is captured in the test's snapshot, so a `dependsOn` child
1109
- * picks up the app exactly where the parent left it (already signed in,
1110
- * mid-flow) instead of reloading it. `.close()` discards the session;
1111
- * the next `ctx.mobile(app)` opens the app fresh.
1112
- */
1113
- mobile(app: MobileApp): Promise<Mobile>;
1114
- /** The test's display name. */
1115
- readonly testName: string;
1116
- /**
1117
- * Value returned by the parent test. Carried across the fork in the
1118
- * daemon's own memory (Freestyle's snapshot is memory + filesystem), so
1119
- * any JS value works — including Maps, Sets, class instances, and live
1120
- * connections that survive the fork. `undefined` for root tests or when
1121
- * the parent returned nothing.
1122
- */
1123
- readonly parent: P;
1124
- /**
1125
- * Per-service helper namespaces, keyed by service name. Only services
1126
- * whose definition ships a `helpers` factory appear here; the value at
1127
- * `ctx.svc.<name>` is exactly the record that factory returned. For a
1128
- * `postgres(...)` service that ships `{ client }`, tests do
1129
- * `await ctx.svc.db.client\`SELECT 1\``.
1130
- */
1131
- readonly svc: ServiceHandlesFor<S>;
1132
- /**
1133
- * Per-fake helper namespaces, keyed by fake name. Each is the record
1134
- * of functions the fake's `helpers` factory returned (or `{ state }`
1135
- * when it ships none — tests never touch a fake's private state
1136
- * directly). Those functions read/mutate the fake's state internally;
1137
- * the state itself is in-process and lives across the fork along with
1138
- * the rest of daemon memory, so calls in a child test see the fork's
1139
- * own copy as mutated by its ancestors. Every helper call is recorded
1140
- * as a step and its return value tracked, so assertions on it nest
1141
- * under the call in the timeline.
1142
- *
1143
- * Strongly typed against the project's fakes map when fakes are
1144
- * declared in `defineEnvironment({ ..., fakes })`: `ctx.fakes.stripe`
1145
- * is exactly the helpers record `defineFake`'s `helpers` factory
1146
- * returned — no cast. (Falls back to a loose record only when the
1147
- * environment declares no fakes.)
1148
- */
1149
- readonly fakes: FakeHandlesFor<F>;
1150
- /**
1151
- * Poll a predicate until it returns a truthy value, then return that
1152
- * value. Records one `wait` event for the whole loop (with attempt
1153
- * count, total duration, and the description) instead of one event
1154
- * per probe — useful for "wait until pod Running"-style checks where
1155
- * the intermediate states are noise.
1156
- *
1157
- * - `null`, `undefined`, or `false` from `fn` mean "not yet" — wait
1158
- * `intervalMs` and try again.
1159
- * - Anything else is the success value and is returned, tagged with
1160
- * the wait event's seq. Downstream `expect(...)` on it links to
1161
- * the wait (one logical step), not to N suppressed HTTP calls.
1162
- * - Throws from `fn` propagate out immediately; the wait event is
1163
- * still recorded (with `error` set) so the timeline reflects the
1164
- * abort.
1165
- * - Defaults: `timeoutMs = 30_000`, `intervalMs = 1_000`.
1166
- *
1167
- * Side-effect calls inside `fn` (fetch, ctx.svc.* helpers, etc.)
1168
- * don't show up on the event log — the recorder is paused for the
1169
- * duration. Use `ctx.poll` for read-only observation, not for
1170
- * stateful work you want recorded.
1171
- */
1172
- poll<T>(
1173
- description: string,
1174
- fn: () => T | null | undefined | false | Promise<T | null | undefined | false>,
1175
- opts?: { timeoutMs?: number; intervalMs?: number },
1176
- ): Promise<Wrapped<T>>;
1177
- /**
1178
- * Register a DNS name at runtime so the rest of this test (and anything
1179
- * downstream of it) can reach it. `{ ingress: true }` points the name at
1180
- * the daemon (a fake / TLS proxy); `{ service }` points it at a
1181
- * container's live IP; a `*.suffix` wildcard (e.g. `"*.example.com"`)
1182
- * routes a whole domain — the natural fit for k3s Ingress hosts a test
1183
- * applies on the fly.
1184
- *
1185
- * Answered by spectest-resolver, so it works for VM-host/test code,
1186
- * `ctx.browser()`, and peer containers (Docker forwards unknown names to
1187
- * the host resolver). It does NOT land in any container's `/etc/hosts`.
1188
- * The registration mutates in-daemon state, so it's isolated to this
1189
- * test's fork — like fake state.
1190
- *
1191
- * ```ts
1192
- * await ctx.svc.k8s.apply(ingressFor("foo.example.com"));
1193
- * await ctx.dnsName("foo.example.com", { service: "k8s" });
1194
- * const res = await ctx.fetch("http://foo.example.com");
1195
- * ```
1196
- */
1197
- dnsName(hostname: string, target: DnsTarget): Promise<void>;
1198
- /**
1199
- * Start a real container on `spectest-net` at runtime — a peer machine
1200
- * with its own IP, reachable like any boot service. Returns once the
1201
- * container is up and its `readyCheck` (if any) has passed.
1202
- *
1203
- * The new container is part of this test's post-state snapshot, so a
1204
- * `dependsOn` child inherits it (same PID, same data) while siblings,
1205
- * which fork from the parent's earlier snapshot, never see it — the same
1206
- * isolation fake `state` and {@link dnsName} get. Reach it by `name`
1207
- * (single-label, via the resolver) or by any `hostnames` you pass; map a
1208
- * multi-label name onto it with `ctx.dnsName(host, { service: name })`.
1209
- *
1210
- * The image is pulled on first use (fast through the host cache). See
1211
- * {@link RuntimeServiceSpec}.
1212
- *
1213
- * ```ts
1214
- * const { name } = await ctx.startService({
1215
- * name: `db-${crypto.randomUUID().slice(0, 8)}`,
1216
- * image: { type: "registry", reference: "postgres:16-alpine" },
1217
- * env: { POSTGRES_PASSWORD: "secret" },
1218
- * readyCheck: { type: "exec", command: "pg_isready -h 127.0.0.1 -p 5432" },
1219
- * });
1220
- * const sql = new Bun.SQL(`postgres://postgres:secret@${name}:5432/postgres`);
1221
- * ```
1222
- */
1223
- startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
1224
- /** Stop and remove a runtime service started via {@link startService}
1225
- * (no-op if it's already gone). */
1226
- stopService(name: string): Promise<void>;
1227
- }
1228
-
1229
- export interface ExecResult {
1230
- stdout: string;
1231
- stderr: string;
1232
- exitCode: number;
1233
- }
1234
-
1235
- /** Options for {@link TestContext.exec}. */
1236
- export interface ExecOpts {
1237
- /**
1238
- * Working directory inside the container to run the command from.
1239
- * Equivalent to prefixing the command with `cd <cwd> && `, but the
1240
- * directory is kept off the command string: the recorded step's
1241
- * sidebar summary shows just the command, while the working directory
1242
- * is surfaced in the detail view (the prompt line of the captured
1243
- * terminal and the step's panel header). Implemented as `docker exec
1244
- * -w <cwd>`, so a relative path resolves against the image's WORKDIR.
1245
- */
1246
- cwd?: string;
1247
- }
1248
-
1249
- export interface TestSuite<
1250
- S extends ServicesMap = ServicesMap,
1251
- F extends FakesMap = FakesMap,
1252
- > {
1253
- tests: TestCase<unknown, S, F>[];
1254
- }
1255
-
1256
- // ──────────────────────────────────────────────────────────────────────────
1257
- // Fakes — in-daemon HTTP servers that masquerade as external APIs.
1258
- // ──────────────────────────────────────────────────────────────────────────
1259
-
1260
- /**
1261
- * A fake server hosted in the spectest-daemon. Use this to stand in for
1262
- * external HTTP APIs (auth providers, payment gateways, …) that you can't
1263
- * call directly from the hermetic test VM.
1264
- *
1265
- * Each fake declares one or more `hostnames` it answers to. The daemon
1266
- * binds an HTTP listener per unique `port` on 0.0.0.0 (the bridge gateway
1267
- * is reachable from every service container), and `spectest-resolver`
1268
- * answers DNS for those hostnames with the bridge gateway IP. Containers
1269
- * doing `fetch("http://api.stripe.com/v1/charges")` route into the daemon,
1270
- * which dispatches to the matching fake by Host header.
1271
- *
1272
- * Internal state lives in plain JS memory — it forks with the rest of the
1273
- * snapshot, so per-test forks start from a known baseline and each fork
1274
- * has its own copy. State is private to the fake; expose it for assertions
1275
- * via `helpers` functions that tests call as `ctx.fakes.<name>.<fn>(...)`.
1276
- *
1277
- * HTTPS works out of the box. Every fake is auto-bound on :443 with a
1278
- * leaf cert signed by the in-VM root CA (SANs = `hostnames`), and
1279
- * every service container trusts that CA via bind-mount + system-trust
1280
- * layer — point your app at `https://api.stripe.com` and it Just Works.
1281
- * HTTP also stays bound on `port` (default 80) for back-compat.
1282
- */
1283
- export interface FakeDefinition<
1284
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1285
- S = any,
1286
- H extends Record<string, unknown> = Record<string, never>,
1287
- > {
1288
- /** Stable name. Used as the key in `ctx.fakes` and in logs/UI. */
1289
- name: string;
1290
- /**
1291
- * Fully-qualified hostnames the fake answers to (e.g.
1292
- * `"api.stripe.com"`). Same rules as service `hostnames`: multi-label,
1293
- * lowercase, no `.internal` suffix, no collisions with services or
1294
- * other fakes.
1295
- */
1296
- hostnames: readonly string[];
1297
- /** TCP port the fake listens on. Default `80`. */
1298
- port?: number;
1299
- /**
1300
- * Build the fake's initial state. Called once when the project loads.
1301
- * The returned value lives in daemon memory for the rest of the
1302
- * environment's life — it survives `bootstrap`, the warm-template
1303
- * snapshot, and every per-test fork (each fork sees its own copy).
1304
- */
1305
- state?: () => S | Promise<S>;
1306
- /**
1307
- * HTTP handler. Receives a standard `Request` (Bun-native), the fake's
1308
- * mutable `state`, and a {@link FakeContext} `ctx` for mutating the
1309
- * environment at runtime (e.g. `ctx.startService(...)` to provision a
1310
- * real backing instance, `ctx.dnsName(...)` to name it). Return any
1311
- * `Response`. Thrown errors surface as 500s. The request URL is the
1312
- * absolute URL the client used — useful for routing on the path.
1313
- */
1314
- handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
1315
- /**
1316
- * Build the helpers exposed at `ctx.fakes.<name>` — a record of
1317
- * **functions** that read or mutate the fake's `state` (received here)
1318
- * via closure. Called the first time a test touches the fake; result
1319
- * is cached for the daemon's life. Omit it and tests see `{}`: state
1320
- * stays private, reachable only through the functions you expose
1321
- * (e.g. `{ lastCharge(), declineSource(src) }`). Don't expose raw
1322
- * `state` or use getters — return copies/derived values from functions
1323
- * instead.
1324
- *
1325
- * Every call is tracked in the test timeline: it records a `fake` step
1326
- * and the return value is tagged so a later `expect(...)` on it nests
1327
- * under that step in the UI (same provenance as `fetch`/db results).
1328
- *
1329
- * Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
1330
- * helper can provision/teardown runtime services just like the handler.
1331
- */
1332
- helpers?: (args: { name: string; state: S; ctx: FakeContext }) => H | Promise<H>;
1333
- /**
1334
- * Internal. Platform secret references this fake needs at *record* time
1335
- * (set by {@link replayFake}). The daemon reports the union of these to
1336
- * the control plane, which resolves each via its `SecretResolver` and
1337
- * pushes the values on the eval path only — they never enter project
1338
- * files, the config hash, or a cassette. Not part of the authoring
1339
- * surface; `JSON.stringify` ignores it (fakes never serialize to config).
1340
- */
1341
- secretRefs?: readonly string[];
1342
- }
1343
-
1344
- /**
1345
- * Define a fake. `defineFake({ ... })` is a thin wrapper that pins the
1346
- * generic `S` and `H` so `helpers`/`handler` see the inferred state type
1347
- * without the caller having to spell it out twice.
1348
- */
1349
- export function defineFake<
1350
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1351
- S = any,
1352
- H extends Record<string, unknown> = Record<string, never>,
1353
- >(opts: FakeDefinition<S, H>): FakeDefinition<S, H> {
1354
- if (!opts.name || opts.name.length === 0) {
1355
- throw new Error("defineFake: `name` is required");
1356
- }
1357
- if (!Array.isArray(opts.hostnames) || opts.hostnames.length === 0) {
1358
- throw new Error(`defineFake(${opts.name}): at least one hostname is required`);
1359
- }
1360
- for (const h of opts.hostnames) {
1361
- const lower = h.toLowerCase();
1362
- if (!HOSTNAME_RE.test(lower)) {
1363
- throw new Error(
1364
- `defineFake(${opts.name}): invalid hostname ${JSON.stringify(h)} — must be a multi-label DNS name (e.g. "api.stripe.com")`,
1365
- );
1366
- }
1367
- if (lower === "internal" || lower.endsWith(".internal")) {
1368
- throw new Error(
1369
- `defineFake(${opts.name}): hostname ${JSON.stringify(h)} ends in reserved ".internal" TLD`,
1370
- );
1371
- }
1372
- }
1373
- if (typeof opts.handler !== "function") {
1374
- throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
1375
- }
1376
- return opts;
1377
- }
1378
-
1379
- /** Map of fake-name -> FakeDefinition, keyed by stable name. `any` for
1380
- * both generic args (not the bare `FakeDefinition` default of
1381
- * `<any, Record<string, never>>`) so a concrete fake that ships real
1382
- * `helpers` — whose `helpers`/`handler` function types would otherwise be
1383
- * invariant-incompatible with the narrower default — is still assignable.
1384
- * The precise per-fake helper types are recovered by `FakeHandlesFor<F>`
1385
- * at use sites. */
1386
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1387
- export type FakesMap = Record<string, FakeDefinition<any, any>>;
1388
-
1389
- /** Rewrite a helpers record so each function's result is inspect-wrapped
1390
- * ({@link Wrapped}) — mirroring the runtime, where `trackFakeHelpers` wraps
1391
- * every helper return value for assertion provenance. Without this the raw
1392
- * return type (e.g. `Charge[]`) reaches `expect`, which the `Provenanced`
1393
- * gate rejects. Handles sync and async helpers; non-function members (and
1394
- * `void` side-effect helpers) pass through untouched. Mirrors `Tagged<T>` in
1395
- * `components/k3s.ts`, extended to cover synchronous returns. */
1396
- type WrappedHelpers<H> = {
1397
- [K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R>
1398
- ? (...args: A) => Promise<Wrapped<R>>
1399
- : H[K] extends (...args: infer A) => infer R
1400
- ? (...args: A) => [R] extends [void] ? void : Wrapped<R>
1401
- : H[K];
1402
- };
1403
-
1404
- /** Awaited return type of a fake's `helpers` factory (with each result
1405
- * inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
1406
- * default) when the user didn't ship one. */
1407
- type FakeHelpersOf<F> = F extends FakeDefinition<infer S, infer H>
1408
- ? [H] extends [Record<string, never>]
1409
- ? { state: S }
1410
- : WrappedHelpers<H>
1411
- : never;
1412
-
1413
- /** Per-fake handles derived from a concrete fakes map; what tests see at
1414
- * `ctx.fakes`. For a concrete map (fakes declared in `defineEnvironment`)
1415
- * each key is the precise helpers record. When `F` is the loose default
1416
- * `FakesMap` (no fakes declared, or generic daemon-side code), this
1417
- * collapses to a permissive record so untyped access still compiles. */
1418
- export type FakeHandlesFor<F extends FakesMap> = string extends keyof F
1419
- ? Record<string, Record<string, unknown>>
1420
- : { [K in keyof F]: FakeHelpersOf<F[K]> };
1421
-
1422
- /**
1423
- * A `test(...)` function bound to a concrete services map (and fakes map).
1424
- * Returned by `defineEnvironment(...).test`; gives tests fully-typed access
1425
- * to `ctx.svc.<key>` and `ctx.fakes.<key>` without per-call casts.
1426
- */
1427
- export interface TypedTest<
1428
- S extends ServicesMap,
1429
- F extends FakesMap = FakesMap,
1430
- > {
1431
- <T = void>(name: string, fn: TestFn<T, undefined, S, F>): TestCase<T, S, F>;
1432
- <T = void, P = undefined>(
1433
- name: string,
1434
- opts: TestOpts<P, S, F>,
1435
- fn: TestFn<T, P, S, F>,
1436
- ): TestCase<T, S, F>;
1437
- }
1438
-
1439
- // ──────────────────────────────────────────────────────────────────────────
1440
- // Project (the unified default export)
1441
- // ──────────────────────────────────────────────────────────────────────────
1442
-
1443
- /**
1444
- * A complete project definition: an environment, an optional one-shot
1445
- * setup hook, and an optional test suite. `spectest/index.ts`
1446
- * default-exports one of these via `env.project([...tests])` or
1447
- * `env.project({ setup, tests })`.
1448
- */
1449
- export interface Project<
1450
- S extends ServicesMap = ServicesMap,
1451
- F extends FakesMap = FakesMap,
1452
- > {
1453
- environment: EnvironmentConfig<S>;
1454
- /**
1455
- * Fake servers hosted in the spectest-daemon (see {@link defineFake}).
1456
- * Keyed by stable fake name; the key becomes the slot in
1457
- * `ctx.fakes.<key>` from test code. Populated from the `fakes` declared
1458
- * in `defineEnvironment(...)`.
1459
- */
1460
- fakes?: F;
1461
- /**
1462
- * Project-level setup. Runs once, after every service is Ready and
1463
- * its per-service `setup` has completed, before the warm-template
1464
- * snapshot. Use this for state that's part of "the environment as
1465
- * the tests expect to find it" — seed data in a database, an initial
1466
- * Deployment in k3s, etc. Like service-level setup, the result is
1467
- * captured by every snapshot taken from that point, so warm restore
1468
- * and per-test forks inherit it without re-running.
1469
- *
1470
- * The ctx here is a slimmer cousin of TestContext: no recorder, no
1471
- * timeout, no browser/terminal — setup is not a test and doesn't
1472
- * appear in the timeline.
1473
- */
1474
- setup?: ProjectSetupFn<S, F>;
1475
- tests?: TestSuite<S, F>;
1476
- }
1477
-
1478
- /**
1479
- * Slim context handed to the project-level `setup` hook. Same shape as
1480
- * `TestContext` but pared down — no testName, no parent, no recording
1481
- * surfaces (browser, terminal, poll). If you need polling inside setup,
1482
- * write a plain loop: setup runs outside the test timeline so there's
1483
- * nothing to record into.
1484
- */
1485
- export interface ProjectSetupContext<
1486
- S extends ServicesMap = ServicesMap,
1487
- F extends FakesMap = FakesMap,
1488
- > {
1489
- fetch: SpectestFetch;
1490
- exec(
1491
- service: string,
1492
- command: string,
1493
- opts?: ExecOpts,
1494
- ): Promise<Wrapped<ExecResult>>;
1495
- readonly svc: ServiceHandlesFor<S>;
1496
- /** Same surface tests see — fakes are already up by setup time. Typed
1497
- * against the declared fakes map (see {@link TestContext.fakes}). */
1498
- readonly fakes: FakeHandlesFor<F>;
1499
- /**
1500
- * Register a DNS name (see {@link TestContext.dnsName}). Useful here to
1501
- * wire a wildcard like `"*.example.com"` to a k3s cluster once, before
1502
- * any test runs — it's captured into the warm-template snapshot, so every
1503
- * test inherits the route without re-registering.
1504
- */
1505
- dnsName(hostname: string, target: DnsTarget): Promise<void>;
1506
- /**
1507
- * Start a runtime service (see {@link TestContext.startService}). Started
1508
- * here, it's captured into the warm-template snapshot and inherited by
1509
- * every test — use it for backing instances that should exist before any
1510
- * test runs but aren't worth a boot-time `services` entry.
1511
- */
1512
- startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
1513
- /** Stop a runtime service (see {@link TestContext.stopService}). */
1514
- stopService(name: string): Promise<void>;
1515
- }
1516
-
1517
- export type ProjectSetupFn<
1518
- S extends ServicesMap = ServicesMap,
1519
- F extends FakesMap = FakesMap,
1520
- > = (ctx: ProjectSetupContext<S, F>) => void | Promise<void>;
1521
-
1522
- /**
1523
- * A defined environment. Returned by `defineEnvironment(...)` and used to
1524
- * (a) build typed tests against this environment's services and (b)
1525
- * bundle those tests into the project's default export.
1526
- *
1527
- * ```ts
1528
- * const env = defineEnvironment({
1529
- * name: "todos",
1530
- * services: {
1531
- * db: postgres({ database: "todos", user: "todos", password: "todos" }),
1532
- * },
1533
- * });
1534
- *
1535
- * const createTodo = env.test("create todo", async (ctx) => {
1536
- * // ctx.svc.db.client is the Bun SQL pool (the helper postgres ships).
1537
- * await ctx.svc.db.client`SELECT 1`;
1538
- * return { id: 1 };
1539
- * });
1540
- *
1541
- * const markDone = env.test(
1542
- * "mark done",
1543
- * { dependsOn: createTodo },
1544
- * async (ctx) => {
1545
- * // ctx.parent is { id: number }
1546
- * await ctx.svc.db.client`UPDATE todos SET done = TRUE WHERE id = ${ctx.parent.id}`;
1547
- * },
1548
- * );
1549
- *
1550
- * export default env.project([createTodo, markDone]);
1551
- * ```
1552
- */
1553
- export interface ProjectOpts<
1554
- S extends ServicesMap = ServicesMap,
1555
- F extends FakesMap = FakesMap,
1556
- > {
1557
- /** Optional project-level setup. See `Project.setup`. */
1558
- setup?: ProjectSetupFn<S, F>;
1559
- /** Test cases for this project's suite. */
1560
- tests?: TestCase<unknown, S, F>[];
1561
- }
1562
-
1563
- /**
1564
- * Input to {@link defineEnvironment}: the environment config plus an
1565
- * optional `fakes` map. `fakes` is declared here (rather than on
1566
- * `project(...)`) so the fakes' types are bound when `env.test(...)`
1567
- * creates a test — that's what makes `ctx.fakes.<key>` strongly typed.
1568
- * `fakes` is stripped from `config` before it's stored/serialised, so the
1569
- * wire `EnvironmentConfig` the Rust control plane sees never carries it.
1570
- *
1571
- * Unlike the wire {@link EnvironmentConfig}, `services` entries here may
1572
- * be {@link ServiceGroup}s — they're expanded to plain services (primary
1573
- * at the group's key, other parts at `<key>-<part>`) before validation.
1574
- */
1575
- export interface EnvironmentInput<
1576
- SI extends InputServicesMap,
1577
- F extends FakesMap = FakesMap,
1578
- > {
1579
- /** Human-friendly name for the environment, e.g. "my-app". */
1580
- name: string;
1581
- /** Services and/or service groups, keyed by name. See
1582
- * {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
1583
- services: SI;
1584
- /** Sandbox timeout in seconds (default 1h). */
1585
- timeoutSecs?: number;
1586
- /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
1587
- * shows up as `ctx.fakes.<key>` in tests, typed as that fake's helpers
1588
- * record. */
1589
- fakes?: F;
1590
- }
1591
-
1592
- export interface DefinedEnvironment<
1593
- S extends ServicesMap,
1594
- F extends FakesMap = FakesMap,
1595
- > {
1596
- /** The validated environment config. Plain data, JSON-serialisable —
1597
- * the in-VM daemon ships this to the control plane on `/load`. */
1598
- readonly config: EnvironmentConfig<S>;
1599
- /** Define a test against this environment. `ctx.svc.<key>` and
1600
- * `ctx.fakes.<key>` are strongly typed against the services and fakes
1601
- * maps. */
1602
- readonly test: TypedTest<S, F>;
1603
- /** Bundle this environment with a test suite into the project default
1604
- * export. Pass tests as a plain array for the common case, or an
1605
- * options bag to attach project-level `setup`. (Fakes are declared on
1606
- * `defineEnvironment`, not here.) */
1607
- project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
1608
- project(opts: ProjectOpts<S, F>): Project<S, F>;
1609
- }
1610
-
1611
- /**
1612
- * The `ctx` type for an environment, for typing shared test helpers without
1613
- * `any`. Instantiate with the environment from `defineEnvironment`:
1614
- *
1615
- * ```ts
1616
- * const env = defineEnvironment({ ... });
1617
- * export type AppCtx = Ctx<typeof env>;
1618
- *
1619
- * // A helper reaching into ctx.svc / ctx.fakes stays fully typed:
1620
- * async function runJob(ctx: AppCtx) {
1621
- * await ctx.svc.db.client`SELECT 1`;
1622
- * }
1623
- * ```
1624
- *
1625
- * This is the same `ctx` an `env.test(...)` callback receives. The parent
1626
- * return type is left as `unknown` (helpers rarely touch `ctx.parent` — read
1627
- * it in the test body and pass the value in). Prefer this over `ctx: any`:
1628
- * an `any`-typed ctx also defeats the `expect(...)` overloads, silently
1629
- * resolving `expect(value)` to the `expect(locator)` overload so value
1630
- * matchers like `.toBe(...)` disappear.
1631
- */
1632
- export type Ctx<E> =
1633
- E extends DefinedEnvironment<infer S, infer F>
1634
- ? TestContext<unknown, S, F>
1635
- : never;
1636
-
1637
- /**
1638
- * Define an environment and get back a builder you can hang tests off.
1639
- * The builder's `.test(...)` returns test cases typed against the
1640
- * environment's services (and any declared `fakes`), and `.project([...])`
1641
- * produces the file's default export.
1642
- */
1643
- export function defineEnvironment<
1644
- SI extends InputServicesMap,
1645
- F extends FakesMap = FakesMap,
1646
- >(input: EnvironmentInput<SI, F>): DefinedEnvironment<ExpandServices<SI>, F> {
1647
- type S = ExpandServices<SI>;
1648
- // Split fakes off the wire config and expand service groups; only
1649
- // { name, services, timeoutSecs } — with plain, fully-expanded services —
1650
- // is stored as `config` and shipped to the control plane.
1651
- const { fakes, services: rawServices, ...rest } = input;
1652
- const config: EnvironmentConfig<S> = {
1653
- ...rest,
1654
- services: expandServiceGroups(rawServices) as S,
1655
- };
1656
- validateEnvironmentConfig(config);
1657
- if (fakes) validateFakes(config, fakes);
1658
-
1659
- // Every test created via this env's `.test(...)` is registered here. A
1660
- // split-layout project keeps its tests in `spectest/tests/**` — each file
1661
- // imports this `env` (for types + `dependsOn` refs) and calls `env.test`,
1662
- // which appends to `registry`. The daemon imports those files and then
1663
- // reads the suite back off the default-exported Project's lazy `tests`
1664
- // getter (installed in `project()` below). Keeping the env definition
1665
- // itself free of test bodies is what lets the warm-template cache key
1666
- // ignore test edits — see `project_content_hash` in the control plane.
1667
- const registry: TestCase<unknown, S, F>[] = [];
1668
- const test = ((
1669
- name: string,
1670
- optsOrFn: TestOpts<unknown, S, F> | TestFn<unknown, undefined, S, F>,
1671
- maybeFn?: TestFn<unknown, unknown, S, F>,
1672
- ): TestCase<unknown, S, F> => {
1673
- const tc = (
1674
- buildTestCase as unknown as (
1675
- n: string,
1676
- o: typeof optsOrFn,
1677
- f?: typeof maybeFn,
1678
- ) => TestCase<unknown, S, F>
1679
- )(name, optsOrFn, maybeFn);
1680
- registry.push(tc);
1681
- return tc;
1682
- }) as unknown as TypedTest<S, F>;
1683
-
1684
- function project(
1685
- arg?: TestCase<unknown, S, F>[] | ProjectOpts<S, F>,
1686
- ): Project<S, F> {
1687
- const opts: ProjectOpts<S, F> = Array.isArray(arg)
1688
- ? { tests: arg }
1689
- : (arg ?? {});
1690
- const proj: Project<S, F> = { environment: config };
1691
- if (opts.setup) proj.setup = opts.setup;
1692
- if (fakes) proj.fakes = fakes;
1693
- if (opts.tests && opts.tests.length > 0) {
1694
- // Explicit suite (single-file / legacy layout): the tests are listed
1695
- // right here, so freeze them now.
1696
- proj.tests = validateSuite({ tests: opts.tests });
1697
- } else {
1698
- // Split layout: tests are defined in separate files and collected from
1699
- // the registry as those files are imported. Expose them lazily so the
1700
- // daemon sees whatever has registered by the time it reads `.tests`
1701
- // (i.e. after it has imported `spectest/tests/**` on `/load-tests`).
1702
- Object.defineProperty(proj, "tests", {
1703
- enumerable: true,
1704
- configurable: true,
1705
- get(): TestSuite<S, F> | undefined {
1706
- return registry.length > 0
1707
- ? validateSuite({ tests: [...registry] })
1708
- : undefined;
1709
- },
1710
- });
1711
- }
1712
- return proj;
1713
- }
1714
- return {
1715
- config,
1716
- test,
1717
- project,
1718
- };
1719
- }
1720
-
1721
- /**
1722
- * Cross-check fakes against the environment's services: no duplicate
1723
- * hostnames, no collisions with service names or service hostnames, and
1724
- * each fake's name is unique (the user passed a map, so JS guarantees
1725
- * the second condition — but we re-check defensively for the case where
1726
- * an object literal is built programmatically).
1727
- */
1728
- function validateFakes(env: EnvironmentConfig, fakes: FakesMap): void {
1729
- const claimedByService = new Map<string, string>();
1730
- for (const [svcName, svc] of Object.entries(env.services)) {
1731
- claimedByService.set(svcName.toLowerCase(), svcName);
1732
- claimedByService.set(`${svcName.toLowerCase()}.internal`, svcName);
1733
- for (const h of svc.hostnames ?? []) {
1734
- claimedByService.set(h.toLowerCase(), svcName);
1735
- }
1736
- for (const entry of svc.tls ?? []) {
1737
- claimedByService.set(entry.hostname.toLowerCase(), svcName);
1738
- }
1739
- }
1740
- const claimedByFake = new Map<string, string>();
1741
- for (const [key, fake] of Object.entries(fakes)) {
1742
- if (!fake || typeof fake !== "object" || typeof fake.handler !== "function") {
1743
- throw new Error(
1744
- `fake ${JSON.stringify(key)} is not a FakeDefinition — pass the result of defineFake({...})`,
1745
- );
1746
- }
1747
- for (const raw of fake.hostnames) {
1748
- const h = raw.toLowerCase();
1749
- const owningSvc = claimedByService.get(h);
1750
- if (owningSvc !== undefined) {
1751
- throw new Error(
1752
- `fake ${JSON.stringify(key)} hostname ${JSON.stringify(raw)} collides with service ${JSON.stringify(owningSvc)}`,
1753
- );
1754
- }
1755
- const owningFake = claimedByFake.get(h);
1756
- if (owningFake !== undefined && owningFake !== key) {
1757
- throw new Error(
1758
- `hostname ${JSON.stringify(raw)} is claimed by both fake ${JSON.stringify(owningFake)} and fake ${JSON.stringify(key)}`,
1759
- );
1760
- }
1761
- claimedByFake.set(h, key);
1762
- }
1763
- }
1764
- }
1765
-
1766
- // `buildTestCase` is the runtime implementation behind every typed
1767
- // `env.test(...)`. The DefinedEnvironment exposes it cast to TypedTest<S>
1768
- // so callers see the strongly-typed overloads while the body stays
1769
- // generic — TestFn is contravariant in `ctx`, so a single implementation
1770
- // satisfies every TypedTest<S>.
1771
- function buildTestCase(
1772
- name: string,
1773
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1774
- optsOrFn: TestOpts<any> | TestFn<unknown, any>,
1775
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1776
- maybeFn?: TestFn<unknown, any>,
1777
- ): TestCase<unknown> {
1778
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1779
- const [opts, fn]: [TestOpts<any>, TestFn<unknown, any>] =
1780
- typeof optsOrFn === "function" ? [{}, optsOrFn] : [optsOrFn, maybeFn!];
1781
- if (typeof fn !== "function") {
1782
- throw new TypeError(
1783
- `test(${JSON.stringify(name)}): missing test function`,
1784
- );
1785
- }
1786
- return {
1787
- id: slugify(name),
1788
- name,
1789
- dependsOn: opts.dependsOn,
1790
- timeoutMs: opts.timeoutMs,
1791
- run: fn,
1792
- };
1793
- }
1794
-
1795
- function validateSuite<
1796
- S extends ServicesMap,
1797
- F extends FakesMap,
1798
- >(suite: TestSuite<S, F>): TestSuite<S, F> {
1799
- const seenIds = new Map<string, string>();
1800
- for (const t of suite.tests) {
1801
- const prior = seenIds.get(t.id);
1802
- if (prior !== undefined) {
1803
- throw new Error(
1804
- `duplicate test id "${t.id}" (from ${JSON.stringify(
1805
- prior,
1806
- )} and ${JSON.stringify(t.name)}) — rename one`,
1807
- );
1808
- }
1809
- seenIds.set(t.id, t.name);
1810
- }
1811
- // Validate that each dependsOn ref is in the suite. Catches typos /
1812
- // imports forgotten in the tests array.
1813
- for (const t of suite.tests) {
1814
- if (t.dependsOn && !suite.tests.includes(t.dependsOn)) {
1815
- throw new Error(
1816
- `test "${t.name}" depends on "${t.dependsOn.name}" which is not in the suite`,
1817
- );
1818
- }
1819
- }
1820
- return suite;
1821
- }
1822
-
1823
- function slugify(name: string): string {
1824
- const slug = name
1825
- .toLowerCase()
1826
- .replace(/[^a-z0-9]+/g, "-")
1827
- .replace(/^-+|-+$/g, "");
1828
- if (!slug) {
1829
- throw new Error(`cannot derive id from test name ${JSON.stringify(name)}`);
1830
- }
1831
- return slug;
1832
- }
1833
-
1834
- // ──────────────────────────────────────────────────────────────────────────
1835
- // Assertions
1836
- // ──────────────────────────────────────────────────────────────────────────
1837
-
1838
- export class ExpectationError extends Error {
1839
- constructor(message: string) {
1840
- super(message);
1841
- this.name = "ExpectationError";
1842
- }
1843
- }
1844
-
1845
- interface Matchers {
1846
- toBe(expected: unknown): void;
1847
- toEqual(expected: unknown): void;
1848
- toBeTruthy(): void;
1849
- toBeFalsy(): void;
1850
- toBeGreaterThan(n: number): void;
1851
- toBeLessThan(n: number): void;
1852
- toBeGreaterThanOrEqual(n: number): void;
1853
- toBeLessThanOrEqual(n: number): void;
1854
- toContain(expected: unknown): void;
1855
- toMatch(re: RegExp): void;
1856
- toHaveLength(n: number): void;
1857
- }
1858
-
1859
- export interface Expectation extends Matchers {
1860
- not: Matchers;
1861
- }
1862
-
1863
- /**
1864
- * Auto-retrying web-first assertions for a {@link Locator} — Playwright's
1865
- * `expect(locator)` matchers. Each polls the element until it passes or a
1866
- * deadline elapses (default 5 s, `{ timeout }` overrides) and records an
1867
- * assertion event just like a value `expect`. `await` them — they are async.
1868
- */
1869
- export interface LocatorMatchers {
1870
- /** The element is present and visible. */
1871
- toBeVisible(opts?: { timeout?: number }): Promise<void>;
1872
- /** The element is absent or hidden. */
1873
- toBeHidden(opts?: { timeout?: number }): Promise<void>;
1874
- /** The element's (trimmed) text equals `expected` (or matches a RegExp). */
1875
- toHaveText(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
1876
- /** The element's text contains `expected`. */
1877
- toContainText(expected: string, opts?: { timeout?: number }): Promise<void>;
1878
- /** The input's value equals `expected` (or matches a RegExp). */
1879
- toHaveValue(expected: string | RegExp, opts?: { timeout?: number }): Promise<void>;
1880
- /** The locator resolves to exactly `expected` elements. */
1881
- toHaveCount(expected: number, opts?: { timeout?: number }): Promise<void>;
1882
- toBeEnabled(opts?: { timeout?: number }): Promise<void>;
1883
- toBeDisabled(opts?: { timeout?: number }): Promise<void>;
1884
- toBeChecked(opts?: { timeout?: number }): Promise<void>;
1885
- }
1886
-
1887
- export interface LocatorAssertion extends LocatorMatchers {
1888
- /** Negate every matcher (retries until the negated condition holds). */
1889
- not: LocatorMatchers;
1890
- }
1891
-
1892
- // `expect(locator)` returns the async web-first matchers; `expect(value)` the
1893
- // synchronous value matchers. The Locator overload is listed first so a
1894
- // locator (an object with no `unwrap`, hence not `Provenanced`) resolves to it.
1895
- export function expect(actual: Locator, message?: string): LocatorAssertion;
1896
- export function expect(actual: Provenanced, message?: string): Expectation;
1897
- export function expect(
1898
- actual: Provenanced | Locator,
1899
- message?: string,
1900
- ): Expectation | LocatorAssertion {
1901
- if (isLocator(actual)) return buildLocatorAssertion(actual, message);
1902
- return expectValue(actual, message);
1903
- }
1904
-
1905
- function expectValue(actual: Provenanced, message?: string): Expectation {
1906
- // The first parameter is typed to the {@link Provenanced} family so a raw
1907
- // value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
1908
- // every assertion that reaches the timeline this way carries a provenance link
1909
- // back to the op that produced it. Assert on a genuinely raw value with
1910
- // `expectRaw(value, message)` instead.
1911
- //
1912
- // `message` is an **optional** human label for the assertion. The UI already
1913
- // renders the target, matcher, and expected value, so a message that just
1914
- // restates them is noise — supply one *only* when the check's intent isn't
1915
- // obvious from those alone (e.g.
1916
- // `expect(res.status, "blocked once the rate limit trips").toBe(429)`). When
1917
- // present it leads the assertion's summary, the same way `expectRaw`'s does.
1918
- //
1919
- // A `null`/`undefined` read off a wrapped op result reaches here untagged
1920
- // (a symbol can't ride on nullish). `adoptNullishTag` recovers the tag from
1921
- // the proxy's most-recent nullish-leaf note and mints a tagged holder, so
1922
- // `expect(dep.status.readyReplicas).toBeFalsy()` nests under its op just like
1923
- // a non-nullish read. Done once here (not in `buildMatchers`) so the `.not`
1924
- // re-pass reuses the same tagged holder instead of re-consuming the note.
1925
- return buildMatchers(adoptNullishTag(actual), false, message);
1926
- }
1927
-
1928
- // ── expect(locator): auto-retrying web-first matchers ─────────────────────
1929
-
1930
- /** Poll interval for locator matchers. */
1931
- const LOCATOR_POLL_MS = 50;
1932
-
1933
- function buildLocatorAssertion(loc: Locator, message?: string): LocatorAssertion {
1934
- return Object.assign(buildLocatorMatchers(loc, false, message), {
1935
- not: buildLocatorMatchers(loc, true, message),
1936
- });
1937
- }
1938
-
1939
- function buildLocatorMatchers(
1940
- loc: Locator,
1941
- negated: boolean,
1942
- message?: string,
1943
- ): LocatorMatchers {
1944
- const probe = getLocatorProbe(loc);
1945
-
1946
- // Poll `check` (a silent, non-recorded read) until the desired condition
1947
- // holds or the deadline elapses, then emit ONE settled browser step (the
1948
- // locator label + replay seek point) and record ONE assertion nested under
1949
- // it via `sourceSeq` — so a web-first `expect(locator)` assertion carries
1950
- // the same provenance a value assertion does (`expect(await loc.isVisible())`
1951
- // renders identically). Without the anchor step these assertions floated as
1952
- // disconnected top-level "value ✓" rows with no element and no replay seek.
1953
- // `check` returns whether the base condition is satisfied plus the observed
1954
- // value for the timeline.
1955
- const run = async (
1956
- matcher: string,
1957
- timeout: number | undefined,
1958
- check: () => Promise<{ satisfied: boolean; actual: unknown }>,
1959
- describe: (actual: unknown) => string,
1960
- expected?: unknown,
1961
- ): Promise<void> => {
1962
- const started = Date.now();
1963
- const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
1964
- let actual: unknown;
1965
- for (;;) {
1966
- let satisfied: boolean;
1967
- try {
1968
- const r = await check();
1969
- satisfied = r.satisfied;
1970
- actual = r.actual;
1971
- } catch (err) {
1972
- if (Date.now() < deadline) {
1973
- await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
1974
- continue;
1975
- }
1976
- satisfied = false;
1977
- actual = `<error: ${(err as Error)?.message ?? String(err)}>`;
1978
- }
1979
- const passed = satisfied !== negated;
1980
- if (passed) {
1981
- const sourceSeq = await probe.settle(matcher, Date.now() - started);
1982
- recordAssertion({
1983
- matcher,
1984
- negated,
1985
- passed: true,
1986
- actual: safeSerialize(actual),
1987
- expected: expected === undefined ? undefined : safeSerialize(expected),
1988
- message,
1989
- sourceSeq,
1990
- });
1991
- return;
1992
- }
1993
- if (Date.now() >= deadline) {
1994
- const msg = describe(actual);
1995
- const sourceSeq = await probe.settle(matcher, Date.now() - started, msg);
1996
- recordAssertion({
1997
- matcher,
1998
- negated,
1999
- passed: false,
2000
- actual: safeSerialize(actual),
2001
- expected: expected === undefined ? undefined : safeSerialize(expected),
2002
- error: msg,
2003
- message,
2004
- sourceSeq,
2005
- });
2006
- throw new ExpectationError(msg);
2007
- }
2008
- await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
2009
- }
2010
- };
2011
-
2012
- const not = negated ? " not" : "";
2013
- const matchesText = (v: string, expected: string | RegExp): boolean =>
2014
- expected instanceof RegExp ? expected.test(v) : v === expected;
2015
-
2016
- return {
2017
- toBeVisible: (opts) =>
2018
- run(
2019
- "toBeVisible",
2020
- opts?.timeout,
2021
- async () => {
2022
- const v = await probe.isVisible();
2023
- return { satisfied: v, actual: v };
2024
- },
2025
- () => `expected ${probe.label}${not} to be visible`,
2026
- ),
2027
- toBeHidden: (opts) =>
2028
- run(
2029
- "toBeHidden",
2030
- opts?.timeout,
2031
- async () => {
2032
- const v = await probe.isVisible();
2033
- return { satisfied: !v, actual: v };
2034
- },
2035
- () => `expected ${probe.label}${not} to be hidden`,
2036
- ),
2037
- toHaveText: (expected, opts) =>
2038
- run(
2039
- "toHaveText",
2040
- opts?.timeout,
2041
- async () => {
2042
- const t = (await probe.textContent(opts?.timeout)) ?? "";
2043
- return { satisfied: matchesText(t.trim(), expected), actual: t };
2044
- },
2045
- (a) => `expected ${probe.label}${not} to have text ${fmt(expected)}, got ${fmt(a)}`,
2046
- expected instanceof RegExp ? String(expected) : expected,
2047
- ),
2048
- toContainText: (expected, opts) =>
2049
- run(
2050
- "toContainText",
2051
- opts?.timeout,
2052
- async () => {
2053
- const t = (await probe.textContent(opts?.timeout)) ?? "";
2054
- return { satisfied: t.includes(expected), actual: t };
2055
- },
2056
- (a) => `expected ${probe.label}${not} to contain text ${fmt(expected)}, got ${fmt(a)}`,
2057
- expected,
2058
- ),
2059
- toHaveValue: (expected, opts) =>
2060
- run(
2061
- "toHaveValue",
2062
- opts?.timeout,
2063
- async () => {
2064
- const v = await probe.inputValue(opts?.timeout);
2065
- return { satisfied: matchesText(v, expected), actual: v };
2066
- },
2067
- (a) => `expected ${probe.label}${not} to have value ${fmt(expected)}, got ${fmt(a)}`,
2068
- expected instanceof RegExp ? String(expected) : expected,
2069
- ),
2070
- toHaveCount: (expected, opts) =>
2071
- run(
2072
- "toHaveCount",
2073
- opts?.timeout,
2074
- async () => {
2075
- const c = await probe.count();
2076
- return { satisfied: c === expected, actual: c };
2077
- },
2078
- (a) => `expected ${probe.label}${not} to have count ${expected}, got ${fmt(a)}`,
2079
- expected,
2080
- ),
2081
- toBeEnabled: (opts) =>
2082
- run(
2083
- "toBeEnabled",
2084
- opts?.timeout,
2085
- async () => {
2086
- const v = await probe.isEnabled(opts?.timeout);
2087
- return { satisfied: v, actual: v };
2088
- },
2089
- () => `expected ${probe.label}${not} to be enabled`,
2090
- ),
2091
- toBeDisabled: (opts) =>
2092
- run(
2093
- "toBeDisabled",
2094
- opts?.timeout,
2095
- async () => {
2096
- const v = await probe.isEnabled(opts?.timeout);
2097
- return { satisfied: !v, actual: v };
2098
- },
2099
- () => `expected ${probe.label}${not} to be disabled`,
2100
- ),
2101
- toBeChecked: (opts) =>
2102
- run(
2103
- "toBeChecked",
2104
- opts?.timeout,
2105
- async () => {
2106
- const v = await probe.isChecked(opts?.timeout);
2107
- return { satisfied: v, actual: v };
2108
- },
2109
- () => `expected ${probe.label}${not} to be checked`,
2110
- ),
2111
- };
2112
- }
2113
-
2114
- /**
2115
- * Assert on a value with **no provenance** — a computed number, a raw
2116
- * WebSocket frame, anything that didn't flow from a recorded op. `message` is
2117
- * required (it's the second argument) and reads as the natural follow-on to
2118
- * "assert …" (e.g. `expectRaw(id, "id matches the generated value")`); it
2119
- * renders as the assertion's label in the CLI/dashboard ("ASSERT <message>")
2120
- * since a raw assertion has no op to nest under. Prefer `expect(...)` whenever
2121
- * the value carries provenance — only reach for this when the type gate would
2122
- * (rightly) reject the value. (`expect`'s own `message` is optional; here it is
2123
- * mandatory, since the label is the only human-meaningful summary a raw
2124
- * assertion has.)
2125
- */
2126
- export function expectRaw(actual: unknown, message: string): Expectation {
2127
- // Force the raw form so no stray tag is read even if a wrapped value is
2128
- // passed: an `expectRaw` assertion is *deliberately* unlinked, rendered at
2129
- // top level under its own message rather than nested beneath an op.
2130
- return buildMatchers(readRaw(actual), false, message);
2131
- }
2132
-
2133
- function buildMatchers(wrapped: unknown, negated: boolean, message?: string): Expectation {
2134
- // If `wrapped` is an inspect-tagged value (from fetch / db / browser),
2135
- // pull its origin metadata and run matchers against the raw underlying
2136
- // value. Plain `expect(value)` is unaffected. Tag + raw are read once here
2137
- // and threaded explicitly into `buildCore`, so `.not` and transforms re-pass
2138
- // them directly instead of re-reading the wrapper (and re-consuming any
2139
- // adopted nullish note).
2140
- return buildCore(readRaw(wrapped), readTag(wrapped), negated, message);
2141
- }
2142
-
2143
- /**
2144
- * The matcher/transform factory, working off an already-unwrapped `actual` and
2145
- * an explicit `tag`. `pendingError`, when set, marks a transform that failed
2146
- * upstream: every matcher then records a failed assertion carrying that error
2147
- * (irrespective of negation) and throws, and further transforms propagate it.
2148
- */
2149
- function buildCore(
2150
- actual: unknown,
2151
- tag: OpTag | undefined,
2152
- negated: boolean,
2153
- message?: string,
2154
- pendingError?: string,
2155
- ): Expectation {
2156
- // Wraps a matcher: it computes the raw condition, the failure message,
2157
- // records the assertion event, then throws iff the result is a failure.
2158
- // `expectedFor` lets matchers record their expected value where it
2159
- // makes sense (toBe / toEqual / toContain / toMatch) while matchers
2160
- // like toBeTruthy leave it unset.
2161
- const run = (
2162
- matcher: string,
2163
- cond: boolean,
2164
- msg: string,
2165
- expectedFor?: unknown,
2166
- opts?: { actual?: unknown },
2167
- ): void => {
2168
- // A failed upstream transform short-circuits every matcher to a failure —
2169
- // there is no meaningful value to match, and negation can't rescue a value
2170
- // that never decoded — so we record `pendingError` and throw regardless of
2171
- // `cond`/`negated`.
2172
- if (pendingError !== undefined) {
2173
- recordAssertion({
2174
- matcher,
2175
- negated,
2176
- passed: false,
2177
- actual: safeSerialize(actual),
2178
- expected: expectedFor === undefined ? undefined : safeSerialize(expectedFor),
2179
- error: pendingError,
2180
- message,
2181
- sourceSeq: tag?.sourceSeq,
2182
- path: tag ? [...tag.path] : undefined,
2183
- });
2184
- throw new ExpectationError(pendingError);
2185
- }
2186
- const passed = cond !== negated;
2187
- recordAssertion({
2188
- matcher,
2189
- negated,
2190
- passed,
2191
- actual: safeSerialize(opts && "actual" in opts ? opts.actual : actual),
2192
- expected: expectedFor === undefined ? undefined : safeSerialize(expectedFor),
2193
- error: passed ? undefined : msg,
2194
- message,
2195
- sourceSeq: tag?.sourceSeq,
2196
- path: tag ? [...tag.path] : undefined,
2197
- });
2198
- if (!passed) throw new ExpectationError(msg);
2199
- };
2200
- return {
2201
- toBe(expected) {
2202
- const exp = readRaw(expected);
2203
- run(
2204
- "toBe",
2205
- Object.is(actual, exp),
2206
- `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}`,
2207
- exp,
2208
- );
2209
- },
2210
- toEqual(expected) {
2211
- const exp = readRaw(expected);
2212
- let equal = true;
2213
- try {
2214
- nodeAssert.deepStrictEqual(actual, exp);
2215
- } catch {
2216
- equal = false;
2217
- }
2218
- run(
2219
- "toEqual",
2220
- equal,
2221
- `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}`,
2222
- exp,
2223
- );
2224
- },
2225
- toBeTruthy() {
2226
- run(
2227
- "toBeTruthy",
2228
- !!actual,
2229
- `expected ${fmt(actual)}${negated ? " not" : ""} to be truthy`,
2230
- );
2231
- },
2232
- toBeFalsy() {
2233
- run(
2234
- "toBeFalsy",
2235
- !actual,
2236
- `expected ${fmt(actual)}${negated ? " not" : ""} to be falsy`,
2237
- );
2238
- },
2239
- toBeGreaterThan(n) {
2240
- run(
2241
- "toBeGreaterThan",
2242
- typeof actual === "number" && actual > n,
2243
- `expected ${fmt(actual)}${negated ? " not" : ""} to be > ${n}`,
2244
- n,
2245
- );
2246
- },
2247
- toBeLessThan(n) {
2248
- run(
2249
- "toBeLessThan",
2250
- typeof actual === "number" && actual < n,
2251
- `expected ${fmt(actual)}${negated ? " not" : ""} to be < ${n}`,
2252
- n,
2253
- );
2254
- },
2255
- toBeGreaterThanOrEqual(n) {
2256
- run(
2257
- "toBeGreaterThanOrEqual",
2258
- typeof actual === "number" && actual >= n,
2259
- `expected ${fmt(actual)}${negated ? " not" : ""} to be >= ${n}`,
2260
- n,
2261
- );
2262
- },
2263
- toBeLessThanOrEqual(n) {
2264
- run(
2265
- "toBeLessThanOrEqual",
2266
- typeof actual === "number" && actual <= n,
2267
- `expected ${fmt(actual)}${negated ? " not" : ""} to be <= ${n}`,
2268
- n,
2269
- );
2270
- },
2271
- toContain(expected) {
2272
- const exp = readRaw(expected);
2273
- let contained = false;
2274
- if (typeof actual === "string" && typeof exp === "string") {
2275
- contained = actual.includes(exp);
2276
- } else if (Array.isArray(actual)) {
2277
- contained = actual.some((v) => {
2278
- try {
2279
- nodeAssert.deepStrictEqual(v, exp);
2280
- return true;
2281
- } catch {
2282
- return false;
2283
- }
2284
- });
2285
- }
2286
- run(
2287
- "toContain",
2288
- contained,
2289
- `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}`,
2290
- exp,
2291
- );
2292
- },
2293
- toMatch(re) {
2294
- run(
2295
- "toMatch",
2296
- typeof actual === "string" && re.test(actual),
2297
- `expected ${fmt(actual)}${negated ? " not" : ""} to match ${re}`,
2298
- String(re),
2299
- );
2300
- },
2301
- toHaveLength(n) {
2302
- // Provenance-preserving count assertion. A wrapped array's `.length`
2303
- // is deliberately raw (so `rows.length === 1` works), which means
2304
- // `expect(rows.length)` can't link back to the originating op —
2305
- // pass the container itself instead: `expect(rows).toHaveLength(1)`
2306
- // keeps the tag, and we read the length off the raw value here.
2307
- const len =
2308
- typeof actual === "string" || Array.isArray(actual)
2309
- ? actual.length
2310
- : actual !== null &&
2311
- typeof actual === "object" &&
2312
- typeof (actual as { length?: unknown }).length === "number"
2313
- ? ((actual as { length: number }).length as number)
2314
- : undefined;
2315
- // The provenance `path` stays the container's own path — `.length` is
2316
- // the matcher's internal read, not a property access the author wrote,
2317
- // so it doesn't belong in the path (recording it produced a redundant
2318
- // "length toHaveLength N" in the UI, since the matcher name already says
2319
- // "length"). `actual` is still the length number: that's what's worth
2320
- // showing on failure, and the `toHaveLength` matcher disambiguates it.
2321
- run(
2322
- "toHaveLength",
2323
- len === n,
2324
- `expected ${fmt(actual)}${negated ? " not" : ""} to have length ${n}` +
2325
- (len === undefined ? " (value has no length)" : ` (got ${len})`),
2326
- n,
2327
- { actual: len },
2328
- );
2329
- },
2330
- get not(): Matchers {
2331
- // Re-pass the already-unwrapped value, tag and message so the tag and raw
2332
- // label are preserved for the negated branch's AssertionEvent.
2333
- return buildCore(actual, tag, !negated, message, pendingError);
2334
- },
2335
- } as Expectation;
2336
- }
2337
-
2338
- function fmt(v: unknown): string {
2339
- if (typeof v === "string") return JSON.stringify(v);
2340
- if (typeof v === "bigint") return `${v}n`;
2341
- if (v === undefined) return "undefined";
2342
- try {
2343
- return JSON.stringify(v);
2344
- } catch {
2345
- return String(v);
2346
- }
2347
- }
2348
-
2349
- /** `node:assert/strict` re-exported for users who prefer Node's built-in API. */
2350
- export const assert = nodeAssert;