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