@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
@@ -0,0 +1,1328 @@
1
+ import { strict as nodeAssert } from "node:assert";
2
+ export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Provenanced, SpectestFetch, Unwrap, } from "./inspect.js";
3
+ import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
4
+ export { field } from "./inspect.js";
5
+ export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
6
+ export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
7
+ export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
8
+ export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
9
+ import type { Browser, BrowserOptions } from "./browser.js";
10
+ export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, } from "./locator.js";
11
+ import type { Locator } from "./locator.js";
12
+ export type { Mobile, MobileApp } from "./mobile.js";
13
+ import type { Mobile, MobileApp } from "./mobile.js";
14
+ export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
15
+ import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
16
+ export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
17
+ export type { CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
18
+ import type { DnsTarget } from "./ingress.js";
19
+ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
20
+ /** Human-friendly name for the environment, e.g. "my-app". */
21
+ name: string;
22
+ /**
23
+ * Services that make up the environment, keyed by service name. The key
24
+ * is the container name, the DNS hostname on `spectest-net`, and the
25
+ * string used in `dependsOn` references.
26
+ */
27
+ services: S;
28
+ /** Sandbox timeout in seconds (default 1h). */
29
+ timeoutSecs?: number;
30
+ }
31
+ /**
32
+ * The shape that ends up in the JSON config the control plane and Rust
33
+ * mirror care about. `ServiceDefinition` is the same thing with an extra
34
+ * non-serialisable `client` factory tacked on; `JSON.stringify` drops
35
+ * functions, so the wire format is `ServiceConfig`.
36
+ */
37
+ export interface ServiceConfig {
38
+ /** Image definition: registry pull or inline build. */
39
+ image: ServiceImage;
40
+ /**
41
+ * Shell command to run (sh -c). Defaults to the image's CMD. NB: this
42
+ * **replaces the image's entrypoint** with `/bin/sh -c` — an
43
+ * init-wrapped image (postgres's `docker-entrypoint.sh`) skips its
44
+ * initialization. Use {@link args} to keep the entrypoint.
45
+ */
46
+ command?: string;
47
+ /**
48
+ * Arguments appended after the image name (`docker run <image>
49
+ * <args…>`) — a CMD override that **keeps the image's entrypoint**.
50
+ * E.g. `args: ["postgres", "-c", "wal_level=logical"]` still runs
51
+ * postgres's `docker-entrypoint.sh` initialization. Mutually
52
+ * exclusive with {@link command}.
53
+ */
54
+ args?: readonly string[];
55
+ env?: Record<string, string>;
56
+ /**
57
+ * Ports the container listens on. Advisory only — surfaced in
58
+ * `spectest list` output. Peer services reach each other by `<service>:<port>`
59
+ * without any port declaration.
60
+ */
61
+ ports?: readonly number[];
62
+ /**
63
+ * Extra DNS names this service answers to inside the environment.
64
+ * Each entry must be a fully-qualified, multi-label hostname (e.g.
65
+ * `api.stripe.com`). Both peer containers (via Docker's embedded DNS)
66
+ * and code on the VM host (via spectest-resolver) resolve these names
67
+ * to the service's IP on `spectest-net`. Useful for mocking external
68
+ * APIs — point an SDK at `http://api.stripe.com` and a service of
69
+ * yours answers directly (no proxy). For HTTPS, use {@link tls}
70
+ * instead — those names terminate TLS in the daemon and reverse-proxy
71
+ * to the service's HTTP port.
72
+ *
73
+ * The `.internal` TLD is reserved: every service automatically
74
+ * answers to `<name>.internal` in addition to its bare `<name>`, and
75
+ * user-supplied hostnames may not end in `.internal`.
76
+ */
77
+ hostnames?: readonly string[];
78
+ /**
79
+ * Expose this service over HTTPS via a TLS-terminating reverse proxy
80
+ * hosted in the spectest-daemon. Each entry maps a fully-qualified
81
+ * hostname to the HTTP port the service listens on inside its
82
+ * container; the daemon binds the hostname on `:443` (SNI-multiplexed,
83
+ * with a leaf cert signed by the in-VM root CA) and on `:80`, and
84
+ * proxies the request to `http://<service>:<port>`. WebSocket
85
+ * upgrades are forwarded.
86
+ *
87
+ * The in-VM root CA is already trusted by Chromium (`ctx.browser()`)
88
+ * and by service-container runtimes (Node, Python, Go, etc.) via
89
+ * the env-var bundle, so `https://<hostname>/` Just Works from
90
+ * tests and from peer services. Hostname rules match {@link hostnames}:
91
+ * multi-label, lowercase, no `.internal` suffix, no collision with
92
+ * services, other service TLS hostnames, or fakes.
93
+ */
94
+ tls?: readonly ServiceTls[];
95
+ /** Bind-mounted volumes for state that survives snapshot/fork. */
96
+ volumes?: readonly VolumeMount[];
97
+ /**
98
+ * Files seeded into the container's filesystem **before it starts**.
99
+ * Each entry's `content` is written to a VM-host staging path and
100
+ * bind-mounted (read-only) at `path` inside the container. Unlike a
101
+ * `setup` hook — which runs after the container is up — `files` is the
102
+ * way to inject configuration a process reads at boot, e.g. k3s's
103
+ * `/etc/rancher/k3s/registries.yaml`, which must exist before
104
+ * `k3s server` starts.
105
+ */
106
+ files?: readonly FileMount[];
107
+ /**
108
+ * Leaf certificates minted from the in-VM root CA and written into the
109
+ * container **before it starts**, so the service can terminate TLS
110
+ * *itself* with a certificate the whole environment already trusts.
111
+ *
112
+ * This is the counterpart to {@link ServiceConfig.tls}. `tls` puts the
113
+ * daemon in front as a terminating reverse proxy and forwards plain
114
+ * HTTP to the service — right for an ordinary web app, wrong for
115
+ * anything that has to see the TLS handshake: a gateway that routes by
116
+ * SNI, a server that verifies client certificates, or a protocol with
117
+ * its own TLS layer. Those need key material, and the only alternative
118
+ * was a self-signed cert plus `rejectUnauthorized: false` — which
119
+ * tests a code path production never runs.
120
+ *
121
+ * ```ts
122
+ * services: {
123
+ * gateway: {
124
+ * image: { type: "registry", reference: "…" },
125
+ * hostnames: ["sql.gateway.test"],
126
+ * certificates: [{
127
+ * hostnames: ["sql.gateway.test", "*.sql.gateway.test"],
128
+ * certPath: "/tls/tls.crt",
129
+ * keyPath: "/tls/tls.key",
130
+ * mode: "0600",
131
+ * }],
132
+ * },
133
+ * }
134
+ * // a test then connects with full verification on:
135
+ * // new Client({ connectionString, ssl: { rejectUnauthorized: true } })
136
+ * ```
137
+ *
138
+ * For a certificate a *test* needs as a value — to load into a
139
+ * Kubernetes Secret, say — use `ctx.certificate(hostnames)` instead,
140
+ * which returns the PEMs rather than mounting them.
141
+ */
142
+ certificates?: readonly CertificateMount[];
143
+ /** Other services (keys in the services map) that must be ready first. */
144
+ dependsOn?: readonly string[];
145
+ readyCheck?: ReadyCheck;
146
+ /** Container workdir override. */
147
+ workdir?: string;
148
+ /**
149
+ * Run the container with `--privileged`. Required by workloads that
150
+ * embed their own container runtime (e.g. k3s) and need full access
151
+ * to the host kernel surface. Off by default.
152
+ */
153
+ privileged?: boolean;
154
+ /**
155
+ * Tmpfs mounts (one `--tmpfs <path>` per entry). Required by some
156
+ * workloads — k3s wants `/run` and `/var/run` writable and
157
+ * non-persistent. Snapshots/forks preserve tmpfs contents along with
158
+ * the rest of process memory.
159
+ */
160
+ tmpfs?: readonly string[];
161
+ /**
162
+ * Cgroup namespace mode — `"host"` or `"private"`. Omit for docker's
163
+ * default. k3s needs `"host"` so its embedded containerd can manage
164
+ * cgroups for the pods it schedules.
165
+ */
166
+ cgroupns?: string;
167
+ }
168
+ /**
169
+ * The slice of the in-VM world a component's `setup` / `helpers` hooks can
170
+ * reach — beyond what the service definition itself declares. Handed to
171
+ * both hooks (see {@link ServiceSetupContext} / {@link ServiceHelpersContext})
172
+ * so components stop hand-rolling `child_process` docker execs and stop
173
+ * hard-coding control-plane paths like `/workspace`.
174
+ */
175
+ export interface ComponentContext {
176
+ /**
177
+ * Absolute path of the extracted project root inside the VM — the
178
+ * directory the user's repo lands in, with `spectest/` directly under
179
+ * it. Use this (never a hard-coded path) to locate project files a
180
+ * component consumes, e.g. `supabase/migrations/**`.
181
+ */
182
+ projectRoot: string;
183
+ /** Read a project file as UTF-8. Relative paths resolve against
184
+ * {@link projectRoot}; absolute paths are read as-is. */
185
+ readProjectFile(path: string): Promise<string>;
186
+ /**
187
+ * Run a command inside a service container (a raw `docker exec` — not
188
+ * recorded on any test timeline, result is plain, not
189
+ * provenance-wrapped). Pass an **array** for exact argv with no shell
190
+ * (`["psql", "-f", "-"]`), or a **string** to run via `sh -lc`.
191
+ * `opts.stdin` is piped to the process — the natural way to feed a SQL
192
+ * file to `psql -f -` or a manifest to `kubectl apply -f -`.
193
+ *
194
+ * Never throws on non-zero exit — inspect `exitCode` yourself.
195
+ */
196
+ exec(service: string, command: string | string[], opts?: ComponentExecOpts): Promise<ExecResult>;
197
+ }
198
+ /**
199
+ * Options for {@link ComponentContext.exec} — the same set
200
+ * {@link TestContext.exec} takes, deliberately aliased rather than
201
+ * redeclared: the two surfaces drifted once (the component one grew
202
+ * `stdin`/`timeoutMs`, the test one silently ignored them), and an alias
203
+ * makes that impossible to repeat.
204
+ *
205
+ * The one behavioural difference is the `timeoutMs` default: a component
206
+ * exec runs during boot, where nothing else bounds it, so it defaults to
207
+ * 120 s. A test-context exec has no default — the enclosing test's own
208
+ * timeout is the ceiling there.
209
+ */
210
+ export type ComponentExecOpts = ExecOpts;
211
+ /** What a service's `setup` hook receives. */
212
+ export interface ServiceSetupContext<H extends Record<string, any> = Record<string, never>> extends ComponentContext {
213
+ /** The service's key in the services map (container + DNS name). */
214
+ name: string;
215
+ /** The record the service's `helpers` factory returned (cached — setup
216
+ * and tests share one instance), or `{}` when it ships none. */
217
+ helpers: H;
218
+ }
219
+ /** What a service's `helpers` factory receives. */
220
+ export interface ServiceHelpersContext extends ComponentContext {
221
+ /** The service's key in the services map (container + DNS name). */
222
+ name: string;
223
+ }
224
+ /**
225
+ * Same shape as `ServiceConfig` plus an optional `helpers` factory that
226
+ * opens a namespace under `ctx.svc.<name>` inside tests. The factory
227
+ * returns a record — each key becomes `ctx.svc.<name>.<key>`. Components
228
+ * like `postgres(...)` use this to expose a pre-wired SQL pool at
229
+ * `ctx.svc.db.client`; nothing stops a component from exposing several
230
+ * helpers under one service (e.g. `client`, `admin`, `truncate()`).
231
+ */
232
+ export interface ServiceDefinition<H extends Record<string, any> = Record<string, never>> extends ServiceConfig {
233
+ /**
234
+ * Build the helpers exposed at `ctx.svc.<name>.<key>`. Called by the
235
+ * daemon the first time a test touches this service; the result is
236
+ * cached for the lifetime of the daemon (it survives snapshot/fork
237
+ * along with the rest of daemon memory). `args.name` is the key the
238
+ * user chose in the services map and matches the in-VM DNS name; the
239
+ * rest of the {@link ServiceHelpersContext} (exec, projectRoot) lets a
240
+ * component reach into its containers without daemon internals.
241
+ */
242
+ helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
243
+ /**
244
+ * One-shot setup after the container's `readyCheck` passes, before
245
+ * dependents start and before any test runs. Awaited in-line with
246
+ * bootstrap, so anything it produces (an ingress controller deployed
247
+ * into k3s, a schema applied to a database) is part of the warm-template
248
+ * snapshot and never re-runs on warm starts.
249
+ *
250
+ * `helpers` is the same record the `helpers` factory returns — building
251
+ * it is cached, so `setup` and tests share one instance. If the service
252
+ * doesn't declare `helpers`, the field is the empty object. The rest of
253
+ * the {@link ServiceSetupContext} carries `exec` (docker exec with
254
+ * stdin) and `projectRoot`/`readProjectFile` for project-file access.
255
+ */
256
+ setup?: (args: ServiceSetupContext<H>) => void | Promise<void>;
257
+ }
258
+ /** A services map — what users pass to `environment.services`. Entries
259
+ * can be plain `ServiceConfig` literals or `ServiceDefinition`s that
260
+ * carry a `helpers` factory (e.g. what `postgres(...)` returns). */
261
+ export type ServicesMap = Record<string, ServiceConfig>;
262
+ /** Awaited return type of a service's `helpers` factory, or `never` if
263
+ * the service doesn't ship one. */
264
+ type HelpersOf<D> = D extends {
265
+ helpers: (...args: any) => infer R;
266
+ } ? Awaited<R> : never;
267
+ /**
268
+ * Per-service handles derived from a concrete services map. Only
269
+ * services that ship a `helpers` factory appear here; `ctx.svc.<name>`
270
+ * is exactly the record the factory returned (e.g. `{ client: SqlClient }`
271
+ * for `postgres(...)`). Services without helpers don't show up at all,
272
+ * so plain `services: { api: { image: ... } }` adds no noise.
273
+ */
274
+ export type ServiceHandlesFor<S extends ServicesMap> = {
275
+ [K in keyof S as HelpersOf<S[K]> extends never ? never : K]: HelpersOf<S[K]>;
276
+ };
277
+ /** Loose, runtime-friendly shape of `ctx.svc` for code that doesn't
278
+ * know the concrete services map (the daemon, generic helpers). The
279
+ * typed `ctx.svc` in test bodies is `ServiceHandlesFor<S>`. */
280
+ export type ServiceHandles = Record<string, Record<string, unknown>>;
281
+ /** Symbol marking a value in the services map as a group to expand.
282
+ * Enumerable-symbol convention (like the ingress `provides` decls): it
283
+ * survives object spread but `JSON.stringify` drops it — not that a group
284
+ * ever reaches the wire; expansion happens before the config exists. */
285
+ declare const SERVICE_GROUP: unique symbol;
286
+ /**
287
+ * Naming context handed to a group's `services` factory. Lets the factory
288
+ * embed *final* DNS names in env vars and config files without knowing the
289
+ * key the user will mount the group under.
290
+ */
291
+ export interface GroupNaming {
292
+ /** The services-map key the user chose for the group. */
293
+ name: string;
294
+ /** Final services-map key of a member part: `key(primary)` is `name`
295
+ * itself; any other part maps to `` `${name}-${part}` ``. */
296
+ key(part: string): string;
297
+ }
298
+ export interface ServiceGroupInput<P extends ServicesMap, H extends Record<string, any> = Record<string, never>, Primary extends keyof P & string = keyof P & string> {
299
+ /**
300
+ * Build the group's member services, keyed by RELATIVE part name
301
+ * (`db`, `auth`, …). Called at expansion time with the final naming
302
+ * context, so strings that must carry final DNS names (connection
303
+ * URLs, embedded config) use `g.key("db")`. `dependsOn` entries that
304
+ * name a relative part are rewritten to final keys automatically
305
+ * (entries that don't match a part pass through untouched, so a group
306
+ * service may still depend on an outside service).
307
+ */
308
+ services: (g: GroupNaming) => P;
309
+ /**
310
+ * The part that represents the group: it takes the group's own map key
311
+ * (so `dependsOn: ["<groupKey>"]` from outside waits for it), and it
312
+ * carries the group's consolidated `helpers`/`setup`. The primary is
313
+ * made the group's dependency **sink** — every other member is added to
314
+ * its `dependsOn` — so "the primary is ready" means "the whole group is
315
+ * up, setup included". Consequently no member may depend on the
316
+ * primary (that would be a cycle); pick a gateway/front-door part.
317
+ */
318
+ primary: Primary;
319
+ /**
320
+ * The group's consolidated handle: helpers exposed at
321
+ * `ctx.svc.<groupKey>` — one typed surface for the whole group (e.g.
322
+ * `{ sql, url, anonKey }` for Supabase). Attached to the primary
323
+ * service; the primary part itself must not also declare `helpers`.
324
+ */
325
+ helpers?: (args: ServiceHelpersContext) => H | Promise<H>;
326
+ /**
327
+ * Group-level setup: runs once every member service is ready (run →
328
+ * probe → per-part setup), before anything that `dependsOn` the group
329
+ * starts and before any test runs. The home for "the whole stack is
330
+ * up, now do X". Runs after the primary part's own `setup`, if any.
331
+ */
332
+ setup?: (args: ServiceSetupContext<H>) => void | Promise<void>;
333
+ /**
334
+ * Pin the services-map key the group must be mounted under. For
335
+ * components whose *derived* surface (connection URLs, env for the app
336
+ * under test) is computed from a name option before expansion — a
337
+ * mismatched key would silently split the two. Expansion errors with a
338
+ * pointer to the component's `name` option instead.
339
+ */
340
+ expectKey?: string;
341
+ }
342
+ /**
343
+ * A multi-service component — the value `serviceGroup(...)` returns, and
344
+ * what a constellation component (e.g. `supabase()`) hands you to put in
345
+ * the services map. Opaque; `defineEnvironment` expands it.
346
+ */
347
+ export interface ServiceGroup<P extends ServicesMap = ServicesMap, H extends Record<string, any> = Record<string, never>, Primary extends keyof P & string = keyof P & string> extends ServiceGroupInput<P, H, Primary> {
348
+ readonly [SERVICE_GROUP]: true;
349
+ }
350
+ /**
351
+ * Define a multi-service group. See {@link ServiceGroupInput} for the
352
+ * fields; the result goes straight into a services map:
353
+ *
354
+ * ```ts
355
+ * const stack = serviceGroup({
356
+ * primary: "gateway",
357
+ * services: (g) => ({
358
+ * db: { image: ..., ... },
359
+ * gateway: { image: ..., env: { DB_URL: `postgres://${g.key("db")}:5432/db` },
360
+ * dependsOn: ["db"] },
361
+ * }),
362
+ * helpers: () => ({ url: "http://..." }),
363
+ * });
364
+ *
365
+ * defineEnvironment({ name: "app", services: { stack } });
366
+ * // expands to services `stack` (the gateway) + `stack-db`;
367
+ * // ctx.svc.stack is the helpers record.
368
+ * ```
369
+ */
370
+ export declare function serviceGroup<P extends ServicesMap, H extends Record<string, any> = Record<string, never>, Primary extends keyof P & string = keyof P & string>(input: ServiceGroupInput<P, H, Primary>): ServiceGroup<P, H, Primary>;
371
+ /** What users pass as `services` to `defineEnvironment`: plain services
372
+ * and/or groups to expand. */
373
+ export type InputServicesMap = Record<string, ServiceConfig | ServiceGroup<any, any, any>>;
374
+ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
375
+ /** Flatten an intersection of records into one mapped type (which also
376
+ * gives it the implicit index signature `ServicesMap` needs). */
377
+ type Reify<T> = {
378
+ [K in keyof T]: T[K];
379
+ };
380
+ /** The primary part with the group's consolidated `helpers` grafted on,
381
+ * so `ServiceHandlesFor` surfaces the group handle at the group key. */
382
+ type PrimaryWithHandle<C, H> = [H] extends [Record<string, never>] ? C : Omit<C, "helpers"> & {
383
+ helpers: (args: ServiceHelpersContext) => H;
384
+ };
385
+ type ExpandGroupEntry<K extends string, G> = G extends ServiceGroup<infer P, infer H, infer Primary> ? {
386
+ [Q in Exclude<keyof P & string, Primary> as `${K}-${Q}`]: P[Q];
387
+ } & {
388
+ [Q in K]: PrimaryWithHandle<P[Primary & keyof P], H>;
389
+ } : never;
390
+ /**
391
+ * The services map after group expansion — what `ctx.svc` and the wire
392
+ * config are typed against. Groups expand to `<key>` (primary, carrying
393
+ * the group handle) + `<key>-<part>` entries; plain services pass through.
394
+ */
395
+ export type ExpandServices<SI extends InputServicesMap> = Reify<UnionToIntersection<{
396
+ [K in keyof SI & string]: SI[K] extends ServiceGroup<any, any, any> ? ExpandGroupEntry<K, SI[K]> : {
397
+ [Q in K]: SI[K];
398
+ };
399
+ }[keyof SI & string]>> extends infer S extends ServicesMap ? S : ServicesMap;
400
+ /**
401
+ * One TLS-terminated hostname for a service. The daemon binds the
402
+ * hostname on `:443` (with a leaf cert signed by the in-VM root CA)
403
+ * and `:80`, and reverse-proxies each request to `http://<service>:<port>`
404
+ * inside the docker network. WebSocket upgrades are bridged.
405
+ */
406
+ export interface ServiceTls {
407
+ /** Fully-qualified hostname clients use (e.g. `app.test`). Must be
408
+ * multi-label, lowercase, not under the reserved `.internal` TLD,
409
+ * and unique across all services and fakes in this environment. */
410
+ hostname: string;
411
+ /** HTTP port the service listens on inside its container. The
412
+ * daemon forwards proxied requests here over `spectest-net`. */
413
+ port: number;
414
+ }
415
+ export type ServiceImage = {
416
+ type: "registry";
417
+ reference: string;
418
+ } | {
419
+ type: "dockerfile";
420
+ /**
421
+ * Dockerfile contents, written verbatim into the build context. The
422
+ * build context is the project root (where `spectest/` lives), so any
423
+ * `COPY` / `ADD` references resolve relative to that directory.
424
+ */
425
+ content: string;
426
+ /** Extra glob patterns to exclude from the build context. */
427
+ exclude?: readonly string[];
428
+ };
429
+ export interface VolumeMount {
430
+ /**
431
+ * Named shared volume. Two services mounting the same `name` share one
432
+ * backing directory — the fit for sidecar pairs that exchange files
433
+ * (e.g. an image proxy reading what a storage API wrote). The directory
434
+ * lives in the per-env state tree, so it snapshots/forks with the rest
435
+ * of the environment and is torn down for fresh-state like any other
436
+ * volume. Names are environment-global: prefix with your service/group
437
+ * name in a reusable component so two instances never collide.
438
+ * Mutually exclusive with `source`.
439
+ */
440
+ name?: string;
441
+ /**
442
+ * Host path. Relative paths resolve under
443
+ * `.spectest/volumes/<service>/`. Defaults to a path derived from `target`.
444
+ */
445
+ source?: string;
446
+ /** Container path. */
447
+ target: string;
448
+ readOnly?: boolean;
449
+ /**
450
+ * Cache volume: the backing dir lives outside the per-env state tree and
451
+ * survives a delta-restore teardown (which recreates every container,
452
+ * volume, and the daemon for fresh-state semantics). Reserve this for
453
+ * content-addressed data whose presence is purely an accelerator — an
454
+ * image/layer store, a package cache — never for app state: anything in
455
+ * a cache volume is visible to the "fresh" environment.
456
+ */
457
+ cache?: boolean;
458
+ }
459
+ export interface FileMount {
460
+ /** Absolute path inside the container where the file is mounted. */
461
+ path: string;
462
+ /**
463
+ * File contents. The literal token `{{SPECTEST_SERVICE}}` is expanded
464
+ * to the owning service's name (its services-map key) before the file
465
+ * is written — handy for self-referential config like a registry host
466
+ * of `<key>.internal`, where a component can't know the key in advance.
467
+ */
468
+ content: string;
469
+ /**
470
+ * Optional octal mode string (e.g. `"0644"`) applied to the staged
471
+ * file before it's bind-mounted. Defaults to the writer's umask.
472
+ */
473
+ mode?: string;
474
+ }
475
+ /** PEM material returned by `ctx.certificate(hostnames)`. */
476
+ export interface CertificateMaterial {
477
+ /** PEM certificate, signed by the in-VM root CA. */
478
+ cert: string;
479
+ /** PEM private key for {@link cert}. */
480
+ key: string;
481
+ /** PEM root CA certificate — the one the environment already trusts. */
482
+ ca: string;
483
+ }
484
+ /** One leaf certificate materialized into a service container — see
485
+ * {@link ServiceConfig.certificates}. */
486
+ export interface CertificateMount {
487
+ /** SANs the leaf covers. Wildcards (`*.example.com`) are allowed. */
488
+ hostnames: readonly string[];
489
+ /** Absolute path inside the container for the PEM certificate. */
490
+ certPath: string;
491
+ /** Absolute path inside the container for the PEM private key. */
492
+ keyPath: string;
493
+ /**
494
+ * Optional absolute path for the root CA certificate. Every container
495
+ * already trusts the CA (`SSL_CERT_FILE` and friends are pre-set), so
496
+ * this is only for software that wants an explicit CA file — client
497
+ * certificate verification, a `sslrootcert=` connection parameter.
498
+ */
499
+ caPath?: string;
500
+ /**
501
+ * Optional octal mode (e.g. `"0600"`) applied to the staged key.
502
+ * Servers that refuse a group/world-readable key (postgres, ssh) need
503
+ * this; the default is the writer's umask.
504
+ */
505
+ mode?: string;
506
+ }
507
+ export type ReadyCheck = {
508
+ type: "tcp";
509
+ port: number;
510
+ timeoutSecs?: number;
511
+ } | {
512
+ type: "http";
513
+ port: number;
514
+ path?: string;
515
+ /**
516
+ * Extra request headers sent with each probe — for health endpoints
517
+ * behind auth (`{ Authorization: "Bearer …" }`). Keeps the probe
518
+ * image-agnostic where an `exec` + curl would depend on curl being
519
+ * in the image.
520
+ */
521
+ headers?: Record<string, string>;
522
+ /**
523
+ * Exact status code that counts as ready. Default: any 2xx. Use for
524
+ * endpoints whose healthy answer isn't 2xx (e.g. a root path that
525
+ * 301s or 401s once the server is actually up).
526
+ */
527
+ expectStatus?: number;
528
+ timeoutSecs?: number;
529
+ }
530
+ /**
531
+ * Run a shell command inside the container; exit 0 = ready. Used when
532
+ * the readiness signal isn't reachable via plain TCP/HTTP from outside
533
+ * (k3s API server uses mTLS, so the natural probe is `kubectl get
534
+ * --raw=/readyz` from inside).
535
+ */
536
+ | {
537
+ type: "exec";
538
+ command: string;
539
+ timeoutSecs?: number;
540
+ };
541
+ /**
542
+ * Spec for a service started at runtime via {@link TestContext.startService}
543
+ * (or the `ctx` handed to a fake). It's a normal {@link ServiceConfig} plus a
544
+ * required `name`, minus only `dependsOn` (there is no boot DAG at runtime;
545
+ * the caller orders `startService` calls itself with `await`).
546
+ *
547
+ * The container joins `spectest-net` with its own IP and is resolvable by
548
+ * `name` (single-label, via the resolver / docker embedded DNS) and by any
549
+ * `hostnames` (extra `--network-alias`es). Like everything else in the VM it
550
+ * is captured by the per-test post-state snapshot, so a `dependsOn` child
551
+ * inherits the live container while siblings never see it.
552
+ *
553
+ * `tls: [{ hostname, port }]` works exactly as it does for a boot service:
554
+ * the daemon mints a leaf cert from the in-VM root CA and stands up a
555
+ * TLS-terminating reverse proxy at `https://<hostname>/` (and plain
556
+ * `http://<hostname>/`) → the container's `port`, binding it onto the live
557
+ * `:443`/`:80` ingress listeners the moment the container is ready. The
558
+ * hostname resolves to the daemon gateway for tests, `ctx.browser()`, and
559
+ * peer containers. Because the route lives in daemon memory (and the
560
+ * resolver registry file), it forks with the per-test snapshot like fake
561
+ * state, and is torn down when the service is stopped. This lets a runtime
562
+ * provider mint a CA-trusted HTTPS endpoint on demand — e.g. a per-DB proxy
563
+ * the Neon serverless driver reaches at its *default* `https://<host>/sql`.
564
+ */
565
+ export interface RuntimeServiceSpec extends Omit<ServiceConfig, "dependsOn"> {
566
+ /**
567
+ * Container name and primary DNS name on `spectest-net`. Must be unique
568
+ * within the current fork — generate a fresh one per provisioned instance
569
+ * (e.g. `db-${crypto.randomUUID().slice(0, 8)}`).
570
+ */
571
+ name: string;
572
+ }
573
+ /** What {@link TestContext.startService} resolves to once the container is up
574
+ * and its `readyCheck` (if any) has passed. */
575
+ export interface RuntimeServiceHandle {
576
+ /** The container/DNS name — the spec's `name`. */
577
+ name: string;
578
+ /** The container's IP on `spectest-net`. Reachable directly from peer
579
+ * containers and from VM-host/test code. */
580
+ ip: string;
581
+ }
582
+ /**
583
+ * The slice of the environment a fake can mutate at runtime — the same
584
+ * primitives {@link TestContext} exposes to tests. Passed as the third
585
+ * argument to a fake's `handler` and as `ctx` to its `helpers` factory, so
586
+ * a fake (e.g. a database-provider control plane) can provision real
587
+ * backing services on demand and wire up DNS for them, exactly as a test
588
+ * would.
589
+ */
590
+ export interface FakeContext {
591
+ /** Start a real container on `spectest-net` at runtime. See
592
+ * {@link RuntimeServiceSpec}. */
593
+ startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
594
+ /** Stop and remove a runtime service started earlier (no-op if gone). */
595
+ stopService(name: string): Promise<void>;
596
+ /** Map a DNS name onto a service IP (or the daemon ingress). See
597
+ * {@link TestContext.dnsName}. */
598
+ dnsName(hostname: string, target: DnsTarget): Promise<void>;
599
+ /** Mint a CA-signed leaf certificate. See
600
+ * {@link TestContext.certificate} — the primitive a fake standing in
601
+ * for a TLS-provisioning provider hands back to the app under test. */
602
+ certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
603
+ }
604
+ /**
605
+ * A single test step. Created via `test(...)` or `createTest(services)`.
606
+ * Tests are referenced (not named by string) when one test depends on
607
+ * another — keeps refactors and type-checking honest.
608
+ *
609
+ * `T` is the return type of the test body; it surfaces as `ctx.parent`
610
+ * in children that `dependsOn` this case. `S` is the project's services
611
+ * map (threaded through `defineEnvironment(...).test`) so `ctx.svc.<key>`
612
+ * is strongly typed.
613
+ */
614
+ export interface TestCase<T = unknown, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
615
+ /** Stable id (slug of name). Used in MCP responses and CLI flags. */
616
+ readonly id: string;
617
+ readonly name: string;
618
+ /** Parent test, if any. Single-parent for now. */
619
+ readonly dependsOn?: TestCase<unknown, S, F>;
620
+ /** Override the default per-test timeout (default 60s). */
621
+ readonly timeoutMs?: number;
622
+ /** @internal — the body that the in-sandbox daemon invokes. */
623
+ readonly run: TestFn<T, unknown, S, F>;
624
+ }
625
+ export interface TestOpts<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
626
+ dependsOn?: TestCase<P, S, F>;
627
+ timeoutMs?: number;
628
+ }
629
+ export type TestFn<T = void, P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> = (ctx: TestContext<P, S, F>) => T | Promise<T>;
630
+ /**
631
+ * Context object passed to every test function.
632
+ *
633
+ * `P` is the return type of the parent test (the one named in
634
+ * `dependsOn`); for root tests it's `undefined`. `S` is the project's
635
+ * services map (threaded through `defineEnvironment(...).test`) so
636
+ * `ctx.svc` only includes services with a `client` factory, each typed
637
+ * as the factory's output (e.g. a Bun SQL pool for `postgres(...)`).
638
+ */
639
+ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
640
+ /**
641
+ * Instrumented `fetch`, exposed on `ctx`. Each call is recorded on the
642
+ * test timeline and resolves to a {@link WrappedResponse}: reads carry
643
+ * provenance so `expect(res.status)` / `expect(await res.json())` nest
644
+ * under the HTTP call. Because the status-line accessors are
645
+ * {@link Carrier}s, a raw `res.status === 200` is a *type error* — use
646
+ * `res.status.unwrap()` / `res.unwrap().status`, or assert via `expect`.
647
+ *
648
+ * (The plain global `fetch` is wrapped the same way at runtime but keeps
649
+ * the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
650
+ * results.)
651
+ */
652
+ fetch: SpectestFetch;
653
+ /** Run `sh -lc <command>` inside a service container. The result is
654
+ * {@link Wrapped}, so `res.stdout` is a `Carrier<string>` — assert via
655
+ * `expect(res.stdout)` or recover the raw string with `res.stdout.unwrap()`.
656
+ * (In `setup`/`eval` the result is wrapped too, just without a timeline
657
+ * link, so `.unwrap()` works there the same way.)
658
+ *
659
+ * The full run is also captured as an asciicast and replayed in the
660
+ * web UI — one recording per call, with output timestamped as it
661
+ * streamed, so slow or animated CLI output can be watched rather
662
+ * than read as a final blob. Unlike `terminal` there is no PTY: the
663
+ * program sees plain pipes (`isatty` false), so stdout/stderr stay
664
+ * byte-identical to what `exec` always returned and TTY-gated
665
+ * spinners/colour won't be emitted — reach for `terminal` when the
666
+ * CLI needs to believe it's on a TTY.
667
+ *
668
+ * Pass `{ cwd }` to run the command from a working directory instead of
669
+ * prefixing it with `cd <dir> && ` — the cwd is kept off the command
670
+ * string, so the timeline sidebar shows just the command and the
671
+ * directory surfaces in the detail view. */
672
+ exec(service: string, command: string, opts?: ExecOpts): Promise<Wrapped<ExecResult>>;
673
+ /**
674
+ * Run a command inside a service container under a PTY and record the
675
+ * full terminal session as an asciicast for replay in the web UI.
676
+ * Unlike `exec`, the program sees a TTY (`isatty(1)` true, colour
677
+ * codes preserved, line-buffered), so the byte stream may differ from
678
+ * what `exec` returns. Reach for `terminal` when you're driving a CLI
679
+ * and want a recording; reach for `exec` for plain piped output.
680
+ *
681
+ * One-shot convenience: this is a thin wrapper over `openTerminal` —
682
+ * spawn `command`, wait for exit, close, return the captured output
683
+ * and exit code. Reach for `openTerminal` when you want to keep the
684
+ * session alive across multiple sends or `waitFor` predicates (e.g.
685
+ * driving an interactive REPL or waiting on a loading spinner).
686
+ */
687
+ terminal(service: string, command: string, opts?: TerminalOpts): Promise<Wrapped<TerminalResult>>;
688
+ /**
689
+ * Open a long-lived interactive terminal in a service container.
690
+ * Returns a `Terminal` you can `send` keystrokes to, poll the
691
+ * rendered screen with `waitFor`, and `close` when done. Every byte
692
+ * the PTY emits is captured as an asciicast and replayed in the web
693
+ * UI the same way a one-shot `terminal(...)` is.
694
+ *
695
+ * Terminals are NOT auto-closed when the test ends. A docker exec
696
+ * subprocess is cheap to keep alive and Freestyle captures it
697
+ * cleanly in the snapshot along with the rest of the container, so
698
+ * leaking the handle past test end is fine. Call `.close()`
699
+ * explicitly if you want a `close` step in the timeline; otherwise
700
+ * `await term.exited` (after the program self-terminates) is the
701
+ * natural way to assert on the exit code.
702
+ */
703
+ openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
704
+ /**
705
+ * Open the headless browser. Backed by Chromium-over-CDP inside the VM.
706
+ *
707
+ * There is ONE persistent browser per environment: every `ctx.browser()`
708
+ * call returns it, and it stays alive across tests — the browser is part
709
+ * of the state a test's snapshot captures, so a `dependsOn` child resumes
710
+ * the exact live page its parent left (cookies, localStorage, signed-in
711
+ * SPA state). Sign in once in a parent test; every descendant is already
712
+ * signed in. Sibling tests fork from the same parent snapshot, so they
713
+ * can't see each other's browsing. A test with no browser-using ancestor
714
+ * gets a fresh browser on first call (first call's options win).
715
+ *
716
+ * `.close()` destroys the shared instance — the next `ctx.browser()`
717
+ * starts fresh. Don't call it for routine cleanup; recording is detached
718
+ * automatically at test end.
719
+ */
720
+ browser(opts?: BrowserOptions): Promise<Browser>;
721
+ /**
722
+ * Open a phone-emulated session for a mobile app and return a {@link Mobile}
723
+ * handle already pointed at it — no `navigate`. Pass the app handle a
724
+ * mobile-app component exposes on `ctx.svc`, e.g. a service declared with
725
+ * `expo()`:
726
+ *
727
+ * ```ts
728
+ * services: { app: expo() }
729
+ * // in a test:
730
+ * const m = await ctx.mobile(ctx.svc.app);
731
+ * await m.getByTestId("email").fill("a@b.com");
732
+ * await m.getByRole("button", { name: "Sign in" }).tap();
733
+ * await expect(m.getByText(/Welcome/)).toBeVisible();
734
+ * ```
735
+ *
736
+ * The session emulates the latest iPhone (viewport + DPR + mobile UA +
737
+ * touch) and the dashboard replays it inside a phone bezel.
738
+ *
739
+ * Sessions are persistent, one per app: like `ctx.browser()`, the live
740
+ * session is captured in the test's snapshot, so a `dependsOn` child
741
+ * picks up the app exactly where the parent left it (already signed in,
742
+ * mid-flow) instead of reloading it. `.close()` discards the session;
743
+ * the next `ctx.mobile(app)` opens the app fresh.
744
+ */
745
+ mobile(app: MobileApp): Promise<Mobile>;
746
+ /** The test's display name. */
747
+ readonly testName: string;
748
+ /**
749
+ * Value returned by the parent test. Carried across the fork in the
750
+ * daemon's own memory (Freestyle's snapshot is memory + filesystem), so
751
+ * any JS value works — including Maps, Sets, class instances, and live
752
+ * connections that survive the fork. `undefined` for root tests or when
753
+ * the parent returned nothing.
754
+ */
755
+ readonly parent: P;
756
+ /**
757
+ * Per-service helper namespaces, keyed by service name. Only services
758
+ * whose definition ships a `helpers` factory appear here; the value at
759
+ * `ctx.svc.<name>` is exactly the record that factory returned. For a
760
+ * `postgres(...)` service that ships `{ client }`, tests do
761
+ * `await ctx.svc.db.client\`SELECT 1\``.
762
+ */
763
+ readonly svc: ServiceHandlesFor<S>;
764
+ /**
765
+ * Per-fake helper namespaces, keyed by fake name. Each is the record
766
+ * of functions the fake's `helpers` factory returned (or `{ state }`
767
+ * when it ships none — tests never touch a fake's private state
768
+ * directly). Those functions read/mutate the fake's state internally;
769
+ * the state itself is in-process and lives across the fork along with
770
+ * the rest of daemon memory, so calls in a child test see the fork's
771
+ * own copy as mutated by its ancestors. Every helper call is recorded
772
+ * as a step and its return value tracked, so assertions on it nest
773
+ * under the call in the timeline.
774
+ *
775
+ * Strongly typed against the project's fakes map when fakes are
776
+ * declared in `defineEnvironment({ ..., fakes })`: `ctx.fakes.stripe`
777
+ * is exactly the helpers record `defineFake`'s `helpers` factory
778
+ * returned — no cast. (Falls back to a loose record only when the
779
+ * environment declares no fakes.)
780
+ */
781
+ readonly fakes: FakeHandlesFor<F>;
782
+ /**
783
+ * Poll a predicate until it returns a truthy value, then return that
784
+ * value. Records one `wait` event for the whole loop (with attempt
785
+ * count, total duration, and the description) instead of one event
786
+ * per probe — useful for "wait until pod Running"-style checks where
787
+ * the intermediate states are noise.
788
+ *
789
+ * - `null`, `undefined`, or `false` from `fn` mean "not yet" — wait
790
+ * `intervalMs` and try again.
791
+ * - Anything else is the success value and is returned, tagged with
792
+ * the wait event's seq. Downstream `expect(...)` on it links to
793
+ * the wait (one logical step), not to N suppressed HTTP calls.
794
+ * - Throws from `fn` propagate out immediately; the wait event is
795
+ * still recorded (with `error` set) so the timeline reflects the
796
+ * abort.
797
+ * - Defaults: `timeoutMs = 30_000`, `intervalMs = 1_000`.
798
+ *
799
+ * Side-effect calls inside `fn` (fetch, ctx.svc.* helpers, etc.)
800
+ * don't show up on the event log — the recorder is paused for the
801
+ * duration. Use `ctx.poll` for read-only observation, not for
802
+ * stateful work you want recorded.
803
+ */
804
+ poll<T>(description: string, fn: () => T | null | undefined | false | Promise<T | null | undefined | false>, opts?: {
805
+ timeoutMs?: number;
806
+ intervalMs?: number;
807
+ }): Promise<Wrapped<T>>;
808
+ /**
809
+ * Register a DNS name at runtime so the rest of this test (and anything
810
+ * downstream of it) can reach it. `{ ingress: true }` points the name at
811
+ * the daemon (a fake / TLS proxy); `{ service }` points it at a
812
+ * container's live IP; a `*.suffix` wildcard (e.g. `"*.example.com"`)
813
+ * routes a whole domain — the natural fit for k3s Ingress hosts a test
814
+ * applies on the fly.
815
+ *
816
+ * Answered by spectest-resolver, so it works for VM-host/test code,
817
+ * `ctx.browser()`, and peer containers (Docker forwards unknown names to
818
+ * the host resolver). It does NOT land in any container's `/etc/hosts`.
819
+ * The registration mutates in-daemon state, so it's isolated to this
820
+ * test's fork — like fake state.
821
+ *
822
+ * ```ts
823
+ * await ctx.svc.k8s.apply(ingressFor("foo.example.com"));
824
+ * await ctx.dnsName("foo.example.com", { service: "k8s" });
825
+ * const res = await ctx.fetch("http://foo.example.com");
826
+ * ```
827
+ */
828
+ dnsName(hostname: string, target: DnsTarget): Promise<void>;
829
+ /**
830
+ * Mint a leaf certificate from the in-VM root CA and return the PEMs.
831
+ *
832
+ * The value-returning counterpart to the `certificates` service field
833
+ * (see {@link ServiceConfig.certificates}): that one hands a
834
+ * certificate to a container before it boots, this one hands it to
835
+ * *you* — for loading into a Kubernetes `kubernetes.io/tls` Secret,
836
+ * posting to a control-plane API that provisions TLS endpoints, or
837
+ * driving a client-certificate handshake.
838
+ *
839
+ * The returned `ca` is the same root the whole environment already
840
+ * trusts (`ctx.fetch`, `ctx.browser()`, every service container), so a
841
+ * server configured with these PEMs verifies cleanly — no
842
+ * `rejectUnauthorized: false`, no `sslmode=require` downgrade.
843
+ * Wildcards (`*.example.com`) are allowed in `hostnames`.
844
+ *
845
+ * ```ts
846
+ * const { cert, key } = await ctx.certificate(["*.apps.test"]);
847
+ * await ctx.svc.k8s.apply(`
848
+ * apiVersion: v1
849
+ * kind: Secret
850
+ * metadata: { name: apps-tls, namespace: default }
851
+ * type: kubernetes.io/tls
852
+ * stringData:
853
+ * tls.crt: |
854
+ * ${cert.replace(/^/gm, " ")}
855
+ * tls.key: |
856
+ * ${key.replace(/^/gm, " ")}
857
+ * `);
858
+ * ```
859
+ */
860
+ certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
861
+ /**
862
+ * Start a real container on `spectest-net` at runtime — a peer machine
863
+ * with its own IP, reachable like any boot service. Returns once the
864
+ * container is up and its `readyCheck` (if any) has passed.
865
+ *
866
+ * The new container is part of this test's post-state snapshot, so a
867
+ * `dependsOn` child inherits it (same PID, same data) while siblings,
868
+ * which fork from the parent's earlier snapshot, never see it — the same
869
+ * isolation fake `state` and {@link dnsName} get. Reach it by `name`
870
+ * (single-label, via the resolver) or by any `hostnames` you pass; map a
871
+ * multi-label name onto it with `ctx.dnsName(host, { service: name })`.
872
+ *
873
+ * The image is pulled on first use (fast through the host cache). See
874
+ * {@link RuntimeServiceSpec}.
875
+ *
876
+ * ```ts
877
+ * const { name } = await ctx.startService({
878
+ * name: `db-${crypto.randomUUID().slice(0, 8)}`,
879
+ * image: { type: "registry", reference: "postgres:16-alpine" },
880
+ * env: { POSTGRES_PASSWORD: "secret" },
881
+ * readyCheck: { type: "exec", command: "pg_isready -h 127.0.0.1 -p 5432" },
882
+ * });
883
+ * const sql = new Bun.SQL(`postgres://postgres:secret@${name}:5432/postgres`);
884
+ * ```
885
+ */
886
+ startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
887
+ /** Stop and remove a runtime service started via {@link startService}
888
+ * (no-op if it's already gone). */
889
+ stopService(name: string): Promise<void>;
890
+ }
891
+ export interface ExecResult {
892
+ stdout: string;
893
+ stderr: string;
894
+ exitCode: number;
895
+ }
896
+ /** Options for {@link TestContext.exec}.
897
+ *
898
+ * Unknown properties are rejected at runtime (`ctx.exec` throws), not
899
+ * silently ignored — the type-level excess-property check is advisory
900
+ * here, since `spectest test`'s typecheck never gates a run. */
901
+ export interface ExecOpts {
902
+ /**
903
+ * Working directory inside the container to run the command from.
904
+ * Equivalent to prefixing the command with `cd <cwd> && `, but the
905
+ * directory is kept off the command string: the recorded step's
906
+ * sidebar summary shows just the command, while the working directory
907
+ * is surfaced in the detail view (the prompt line of the captured
908
+ * terminal and the step's panel header). Implemented as `docker exec
909
+ * -w <cwd>`, so a relative path resolves against the image's WORKDIR.
910
+ */
911
+ cwd?: string;
912
+ /**
913
+ * Piped to the command's stdin, then closed — the natural way to feed
914
+ * a manifest to `kubectl apply -f -` or a SQL file to `psql -f -`
915
+ * without staging a temp file in the container:
916
+ *
917
+ * ```ts
918
+ * await ctx.exec("k8s", "kubectl apply -f -", { stdin: manifestYaml });
919
+ * ```
920
+ *
921
+ * The payload is written after the process starts and the stream is
922
+ * closed immediately after, so a command that never reads stdin still
923
+ * exits normally (the write is allowed to fail with EPIPE).
924
+ */
925
+ stdin?: string;
926
+ /**
927
+ * Kill the command (SIGKILL) after this long and return `exitCode`
928
+ * 124 with a `timeout after <n>ms` note on stderr, rather than
929
+ * hanging until the enclosing test's own timeout fires.
930
+ *
931
+ * **There is no default** — an `exec` runs as long as it likes. In a
932
+ * test the enclosing `env.test(..., { timeoutMs })` (60 s by default)
933
+ * is the real ceiling; in `setup`/`eval`, where no test timeout
934
+ * applies, an unbounded command hangs the boot, so set this when the
935
+ * command can plausibly wedge.
936
+ */
937
+ timeoutMs?: number;
938
+ }
939
+ export interface TestSuite<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
940
+ tests: TestCase<unknown, S, F>[];
941
+ }
942
+ /**
943
+ * A fake server hosted in the spectest-daemon. Use this to stand in for
944
+ * external HTTP APIs (auth providers, payment gateways, …) that you can't
945
+ * call directly from the hermetic test VM.
946
+ *
947
+ * Each fake declares one or more `hostnames` it answers to. The daemon
948
+ * binds an HTTP listener per unique `port` on 0.0.0.0 (the bridge gateway
949
+ * is reachable from every service container), and `spectest-resolver`
950
+ * answers DNS for those hostnames with the bridge gateway IP. Containers
951
+ * doing `fetch("http://api.stripe.com/v1/charges")` route into the daemon,
952
+ * which dispatches to the matching fake by Host header.
953
+ *
954
+ * Internal state lives in plain JS memory — it forks with the rest of the
955
+ * snapshot, so per-test forks start from a known baseline and each fork
956
+ * has its own copy. State is private to the fake; expose it for assertions
957
+ * via `helpers` functions that tests call as `ctx.fakes.<name>.<fn>(...)`.
958
+ *
959
+ * HTTPS works out of the box. Every fake is auto-bound on :443 with a
960
+ * leaf cert signed by the in-VM root CA (SANs = `hostnames`), and
961
+ * every service container trusts that CA via bind-mount + system-trust
962
+ * layer — point your app at `https://api.stripe.com` and it Just Works.
963
+ * HTTP also stays bound on `port` (default 80) for back-compat.
964
+ */
965
+ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Record<string, never>> {
966
+ /** Stable name. Used as the key in `ctx.fakes` and in logs/UI. */
967
+ name: string;
968
+ /**
969
+ * Fully-qualified hostnames the fake answers to (e.g.
970
+ * `"api.stripe.com"`). Same rules as service `hostnames`: multi-label,
971
+ * lowercase, no `.internal` suffix, no collisions with services or
972
+ * other fakes.
973
+ */
974
+ hostnames: readonly string[];
975
+ /** TCP port the fake listens on. Default `80`. */
976
+ port?: number;
977
+ /**
978
+ * Build the fake's initial state. Called once when the project loads.
979
+ * The returned value lives in daemon memory for the rest of the
980
+ * environment's life — it survives `bootstrap`, the warm-template
981
+ * snapshot, and every per-test fork (each fork sees its own copy).
982
+ */
983
+ state?: () => S | Promise<S>;
984
+ /**
985
+ * HTTP handler. Receives a standard `Request` (Bun-native), the fake's
986
+ * mutable `state`, and a {@link FakeContext} `ctx` for mutating the
987
+ * environment at runtime (e.g. `ctx.startService(...)` to provision a
988
+ * real backing instance, `ctx.dnsName(...)` to name it). Return any
989
+ * `Response`. Thrown errors surface as 500s. The request URL is the
990
+ * absolute URL the client used — useful for routing on the path.
991
+ */
992
+ handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
993
+ /**
994
+ * Build the helpers exposed at `ctx.fakes.<name>` — a record of
995
+ * **functions** that read or mutate the fake's `state` (received here)
996
+ * via closure. Called the first time a test touches the fake; result
997
+ * is cached for the daemon's life. Omit it and tests see `{}`: state
998
+ * stays private, reachable only through the functions you expose
999
+ * (e.g. `{ lastCharge(), declineSource(src) }`). Don't expose raw
1000
+ * `state` or use getters — return copies/derived values from functions
1001
+ * instead.
1002
+ *
1003
+ * Every call is tracked in the test timeline: it records a `fake` step
1004
+ * and the return value is tagged so a later `expect(...)` on it nests
1005
+ * under that step in the UI (same provenance as `fetch`/db results).
1006
+ *
1007
+ * Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
1008
+ * helper can provision/teardown runtime services just like the handler.
1009
+ */
1010
+ helpers?: (args: {
1011
+ name: string;
1012
+ state: S;
1013
+ ctx: FakeContext;
1014
+ }) => H | Promise<H>;
1015
+ /**
1016
+ * Internal. Platform secret references this fake needs at *record* time
1017
+ * (set by {@link replayFake}). The daemon reports the union of these to
1018
+ * the control plane, which resolves each via its `SecretResolver` and
1019
+ * pushes the values on the eval path only — they never enter project
1020
+ * files, the config hash, or a cassette. Not part of the authoring
1021
+ * surface; `JSON.stringify` ignores it (fakes never serialize to config).
1022
+ */
1023
+ secretRefs?: readonly string[];
1024
+ }
1025
+ /**
1026
+ * Define a fake. `defineFake({ ... })` is a thin wrapper that pins the
1027
+ * generic `S` and `H` so `helpers`/`handler` see the inferred state type
1028
+ * without the caller having to spell it out twice.
1029
+ */
1030
+ export declare function defineFake<S = any, H extends Record<string, unknown> = Record<string, never>>(opts: FakeDefinition<S, H>): FakeDefinition<S, H>;
1031
+ /** Map of fake-name -> FakeDefinition, keyed by stable name. `any` for
1032
+ * both generic args (not the bare `FakeDefinition` default of
1033
+ * `<any, Record<string, never>>`) so a concrete fake that ships real
1034
+ * `helpers` — whose `helpers`/`handler` function types would otherwise be
1035
+ * invariant-incompatible with the narrower default — is still assignable.
1036
+ * The precise per-fake helper types are recovered by `FakeHandlesFor<F>`
1037
+ * at use sites. */
1038
+ export type FakesMap = Record<string, FakeDefinition<any, any>>;
1039
+ /** Rewrite a helpers record so each function's result is inspect-wrapped
1040
+ * ({@link Wrapped}) — mirroring the runtime, where `trackFakeHelpers` wraps
1041
+ * every helper return value for assertion provenance. Without this the raw
1042
+ * return type (e.g. `Charge[]`) reaches `expect`, which the `Provenanced`
1043
+ * gate rejects. Handles sync and async helpers; non-function members (and
1044
+ * `void` side-effect helpers) pass through untouched. Mirrors `Tagged<T>` in
1045
+ * `components/k3s.ts`, extended to cover synchronous returns. */
1046
+ type WrappedHelpers<H> = {
1047
+ [K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<R>> : H[K] extends (...args: infer A) => infer R ? (...args: A) => [R] extends [void] ? void : Wrapped<R> : H[K];
1048
+ };
1049
+ /** Awaited return type of a fake's `helpers` factory (with each result
1050
+ * inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
1051
+ * default) when the user didn't ship one. */
1052
+ type FakeHelpersOf<F> = F extends FakeDefinition<infer S, infer H> ? [H] extends [Record<string, never>] ? {
1053
+ state: S;
1054
+ } : WrappedHelpers<H> : never;
1055
+ /** Per-fake handles derived from a concrete fakes map; what tests see at
1056
+ * `ctx.fakes`. For a concrete map (fakes declared in `defineEnvironment`)
1057
+ * each key is the precise helpers record. When `F` is the loose default
1058
+ * `FakesMap` (no fakes declared, or generic daemon-side code), this
1059
+ * collapses to a permissive record so untyped access still compiles. */
1060
+ export type FakeHandlesFor<F extends FakesMap> = string extends keyof F ? Record<string, Record<string, unknown>> : {
1061
+ [K in keyof F]: FakeHelpersOf<F[K]>;
1062
+ };
1063
+ /**
1064
+ * A `test(...)` function bound to a concrete services map (and fakes map).
1065
+ * Returned by `defineEnvironment(...).test`; gives tests fully-typed access
1066
+ * to `ctx.svc.<key>` and `ctx.fakes.<key>` without per-call casts.
1067
+ */
1068
+ export interface TypedTest<S extends ServicesMap, F extends FakesMap = FakesMap> {
1069
+ <T = void>(name: string, fn: TestFn<T, undefined, S, F>): TestCase<T, S, F>;
1070
+ <T = void, P = undefined>(name: string, opts: TestOpts<P, S, F>, fn: TestFn<T, P, S, F>): TestCase<T, S, F>;
1071
+ }
1072
+ /**
1073
+ * A complete project definition: an environment, an optional one-shot
1074
+ * setup hook, and an optional test suite. `spectest/index.ts`
1075
+ * default-exports one of these via `env.project([...tests])` or
1076
+ * `env.project({ setup, tests })`.
1077
+ */
1078
+ export interface Project<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
1079
+ environment: EnvironmentConfig<S>;
1080
+ /**
1081
+ * Fake servers hosted in the spectest-daemon (see {@link defineFake}).
1082
+ * Keyed by stable fake name; the key becomes the slot in
1083
+ * `ctx.fakes.<key>` from test code. Populated from the `fakes` declared
1084
+ * in `defineEnvironment(...)`.
1085
+ */
1086
+ fakes?: F;
1087
+ /**
1088
+ * Project-level setup. Runs once, after every service is Ready and
1089
+ * its per-service `setup` has completed, before the warm-template
1090
+ * snapshot. Use this for state that's part of "the environment as
1091
+ * the tests expect to find it" — seed data in a database, an initial
1092
+ * Deployment in k3s, etc. Like service-level setup, the result is
1093
+ * captured by every snapshot taken from that point, so warm restore
1094
+ * and per-test forks inherit it without re-running.
1095
+ *
1096
+ * The ctx here is a slimmer cousin of TestContext: no recorder, no
1097
+ * timeout, no browser/terminal — setup is not a test and doesn't
1098
+ * appear in the timeline.
1099
+ */
1100
+ setup?: ProjectSetupFn<S, F>;
1101
+ tests?: TestSuite<S, F>;
1102
+ }
1103
+ /**
1104
+ * Slim context handed to the project-level `setup` hook. Same shape as
1105
+ * `TestContext` but pared down — no testName, no parent, no recording
1106
+ * surfaces (browser, terminal, poll). If you need polling inside setup,
1107
+ * write a plain loop: setup runs outside the test timeline so there's
1108
+ * nothing to record into.
1109
+ */
1110
+ export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
1111
+ fetch: SpectestFetch;
1112
+ exec(service: string, command: string, opts?: ExecOpts): Promise<Wrapped<ExecResult>>;
1113
+ readonly svc: ServiceHandlesFor<S>;
1114
+ /** Same surface tests see — fakes are already up by setup time. Typed
1115
+ * against the declared fakes map (see {@link TestContext.fakes}). */
1116
+ readonly fakes: FakeHandlesFor<F>;
1117
+ /**
1118
+ * Register a DNS name (see {@link TestContext.dnsName}). Useful here to
1119
+ * wire a wildcard like `"*.example.com"` to a k3s cluster once, before
1120
+ * any test runs — it's captured into the warm-template snapshot, so every
1121
+ * test inherits the route without re-registering.
1122
+ */
1123
+ dnsName(hostname: string, target: DnsTarget): Promise<void>;
1124
+ /**
1125
+ * Mint a leaf certificate from the in-VM root CA (see
1126
+ * {@link TestContext.certificate}). Minted here it's captured into the
1127
+ * warm-template snapshot, so every test inherits whatever you seeded it
1128
+ * into — the fit for a cluster-wide TLS Secret applied once at setup.
1129
+ */
1130
+ certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
1131
+ /**
1132
+ * Start a runtime service (see {@link TestContext.startService}). Started
1133
+ * here, it's captured into the warm-template snapshot and inherited by
1134
+ * every test — use it for backing instances that should exist before any
1135
+ * test runs but aren't worth a boot-time `services` entry.
1136
+ */
1137
+ startService(spec: RuntimeServiceSpec): Promise<RuntimeServiceHandle>;
1138
+ /** Stop a runtime service (see {@link TestContext.stopService}). */
1139
+ stopService(name: string): Promise<void>;
1140
+ }
1141
+ export type ProjectSetupFn<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> = (ctx: ProjectSetupContext<S, F>) => void | Promise<void>;
1142
+ /**
1143
+ * A defined environment. Returned by `defineEnvironment(...)` and used to
1144
+ * (a) build typed tests against this environment's services and (b)
1145
+ * bundle those tests into the project's default export.
1146
+ *
1147
+ * ```ts
1148
+ * const env = defineEnvironment({
1149
+ * name: "todos",
1150
+ * services: {
1151
+ * db: postgres({ database: "todos", user: "todos", password: "todos" }),
1152
+ * },
1153
+ * });
1154
+ *
1155
+ * const createTodo = env.test("create todo", async (ctx) => {
1156
+ * // ctx.svc.db.client is the Bun SQL pool (the helper postgres ships).
1157
+ * await ctx.svc.db.client`SELECT 1`;
1158
+ * return { id: 1 };
1159
+ * });
1160
+ *
1161
+ * const markDone = env.test(
1162
+ * "mark done",
1163
+ * { dependsOn: createTodo },
1164
+ * async (ctx) => {
1165
+ * // ctx.parent is { id: number }
1166
+ * await ctx.svc.db.client`UPDATE todos SET done = TRUE WHERE id = ${ctx.parent.id}`;
1167
+ * },
1168
+ * );
1169
+ *
1170
+ * export default env.project([createTodo, markDone]);
1171
+ * ```
1172
+ */
1173
+ export interface ProjectOpts<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
1174
+ /** Optional project-level setup. See `Project.setup`. */
1175
+ setup?: ProjectSetupFn<S, F>;
1176
+ /** Test cases for this project's suite. */
1177
+ tests?: TestCase<unknown, S, F>[];
1178
+ }
1179
+ /**
1180
+ * Input to {@link defineEnvironment}: the environment config plus an
1181
+ * optional `fakes` map. `fakes` is declared here (rather than on
1182
+ * `project(...)`) so the fakes' types are bound when `env.test(...)`
1183
+ * creates a test — that's what makes `ctx.fakes.<key>` strongly typed.
1184
+ * `fakes` is stripped from `config` before it's stored/serialised, so the
1185
+ * wire `EnvironmentConfig` the Rust control plane sees never carries it.
1186
+ *
1187
+ * Unlike the wire {@link EnvironmentConfig}, `services` entries here may
1188
+ * be {@link ServiceGroup}s — they're expanded to plain services (primary
1189
+ * at the group's key, other parts at `<key>-<part>`) before validation.
1190
+ */
1191
+ export interface EnvironmentInput<SI extends InputServicesMap, F extends FakesMap = FakesMap> {
1192
+ /** Human-friendly name for the environment, e.g. "my-app". */
1193
+ name: string;
1194
+ /** Services and/or service groups, keyed by name. See
1195
+ * {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
1196
+ services: SI;
1197
+ /** Sandbox timeout in seconds (default 1h). */
1198
+ timeoutSecs?: number;
1199
+ /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
1200
+ * shows up as `ctx.fakes.<key>` in tests, typed as that fake's helpers
1201
+ * record. */
1202
+ fakes?: F;
1203
+ }
1204
+ export interface DefinedEnvironment<S extends ServicesMap, F extends FakesMap = FakesMap> {
1205
+ /** The validated environment config. Plain data, JSON-serialisable —
1206
+ * the in-VM daemon ships this to the control plane on `/load`. */
1207
+ readonly config: EnvironmentConfig<S>;
1208
+ /** Define a test against this environment. `ctx.svc.<key>` and
1209
+ * `ctx.fakes.<key>` are strongly typed against the services and fakes
1210
+ * maps. */
1211
+ readonly test: TypedTest<S, F>;
1212
+ /** Bundle this environment with a test suite into the project default
1213
+ * export. Pass tests as a plain array for the common case, or an
1214
+ * options bag to attach project-level `setup`. (Fakes are declared on
1215
+ * `defineEnvironment`, not here.) */
1216
+ project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
1217
+ project(opts: ProjectOpts<S, F>): Project<S, F>;
1218
+ }
1219
+ /**
1220
+ * The `ctx` type for an environment, for typing shared test helpers without
1221
+ * `any`. Instantiate with the environment from `defineEnvironment`:
1222
+ *
1223
+ * ```ts
1224
+ * const env = defineEnvironment({ ... });
1225
+ * export type AppCtx = Ctx<typeof env>;
1226
+ *
1227
+ * // A helper reaching into ctx.svc / ctx.fakes stays fully typed:
1228
+ * async function runJob(ctx: AppCtx) {
1229
+ * await ctx.svc.db.client`SELECT 1`;
1230
+ * }
1231
+ * ```
1232
+ *
1233
+ * This is the same `ctx` an `env.test(...)` callback receives. The parent
1234
+ * return type is left as `unknown` (helpers rarely touch `ctx.parent` — read
1235
+ * it in the test body and pass the value in). Prefer this over `ctx: any`:
1236
+ * an `any`-typed ctx also defeats the `expect(...)` overloads, silently
1237
+ * resolving `expect(value)` to the `expect(locator)` overload so value
1238
+ * matchers like `.toBe(...)` disappear.
1239
+ */
1240
+ export type Ctx<E> = E extends DefinedEnvironment<infer S, infer F> ? TestContext<unknown, S, F> : never;
1241
+ /**
1242
+ * Define an environment and get back a builder you can hang tests off.
1243
+ * The builder's `.test(...)` returns test cases typed against the
1244
+ * environment's services (and any declared `fakes`), and `.project([...])`
1245
+ * produces the file's default export.
1246
+ */
1247
+ export declare function defineEnvironment<SI extends InputServicesMap, F extends FakesMap = FakesMap>(input: EnvironmentInput<SI, F>): DefinedEnvironment<ExpandServices<SI>, F>;
1248
+ export declare class ExpectationError extends Error {
1249
+ constructor(message: string);
1250
+ }
1251
+ interface Matchers {
1252
+ toBe(expected: unknown): void;
1253
+ toEqual(expected: unknown): void;
1254
+ toBeTruthy(): void;
1255
+ toBeFalsy(): void;
1256
+ toBeGreaterThan(n: number): void;
1257
+ toBeLessThan(n: number): void;
1258
+ toBeGreaterThanOrEqual(n: number): void;
1259
+ toBeLessThanOrEqual(n: number): void;
1260
+ toContain(expected: unknown): void;
1261
+ toMatch(re: RegExp): void;
1262
+ toHaveLength(n: number): void;
1263
+ }
1264
+ export interface Expectation extends Matchers {
1265
+ not: Matchers;
1266
+ }
1267
+ /**
1268
+ * Auto-retrying web-first assertions for a {@link Locator} — Playwright's
1269
+ * `expect(locator)` matchers. Each polls the element until it passes or a
1270
+ * deadline elapses (default 5 s, `{ timeout }` overrides) and records an
1271
+ * assertion event just like a value `expect`. `await` them — they are async.
1272
+ */
1273
+ export interface LocatorMatchers {
1274
+ /** The element is present and visible. */
1275
+ toBeVisible(opts?: {
1276
+ timeout?: number;
1277
+ }): Promise<void>;
1278
+ /** The element is absent or hidden. */
1279
+ toBeHidden(opts?: {
1280
+ timeout?: number;
1281
+ }): Promise<void>;
1282
+ /** The element's (trimmed) text equals `expected` (or matches a RegExp). */
1283
+ toHaveText(expected: string | RegExp, opts?: {
1284
+ timeout?: number;
1285
+ }): Promise<void>;
1286
+ /** The element's text contains `expected`. */
1287
+ toContainText(expected: string, opts?: {
1288
+ timeout?: number;
1289
+ }): Promise<void>;
1290
+ /** The input's value equals `expected` (or matches a RegExp). */
1291
+ toHaveValue(expected: string | RegExp, opts?: {
1292
+ timeout?: number;
1293
+ }): Promise<void>;
1294
+ /** The locator resolves to exactly `expected` elements. */
1295
+ toHaveCount(expected: number, opts?: {
1296
+ timeout?: number;
1297
+ }): Promise<void>;
1298
+ toBeEnabled(opts?: {
1299
+ timeout?: number;
1300
+ }): Promise<void>;
1301
+ toBeDisabled(opts?: {
1302
+ timeout?: number;
1303
+ }): Promise<void>;
1304
+ toBeChecked(opts?: {
1305
+ timeout?: number;
1306
+ }): Promise<void>;
1307
+ }
1308
+ export interface LocatorAssertion extends LocatorMatchers {
1309
+ /** Negate every matcher (retries until the negated condition holds). */
1310
+ not: LocatorMatchers;
1311
+ }
1312
+ export declare function expect(actual: Locator, message?: string): LocatorAssertion;
1313
+ export declare function expect(actual: Provenanced, message?: string): Expectation;
1314
+ /**
1315
+ * Assert on a value with **no provenance** — a computed number, a raw
1316
+ * WebSocket frame, anything that didn't flow from a recorded op. `message` is
1317
+ * required (it's the second argument) and reads as the natural follow-on to
1318
+ * "assert …" (e.g. `expectRaw(id, "id matches the generated value")`); it
1319
+ * renders as the assertion's label in the CLI/dashboard ("ASSERT <message>")
1320
+ * since a raw assertion has no op to nest under. Prefer `expect(...)` whenever
1321
+ * the value carries provenance — only reach for this when the type gate would
1322
+ * (rightly) reject the value. (`expect`'s own `message` is optional; here it is
1323
+ * mandatory, since the label is the only human-meaningful summary a raw
1324
+ * assertion has.)
1325
+ */
1326
+ export declare function expectRaw(actual: unknown, message: string): Expectation;
1327
+ /** `node:assert/strict` re-exported for users who prefer Node's built-in API. */
1328
+ export declare const assert: typeof nodeAssert;