@specific.dev/spectest 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +172 -0
  12. package/dist/components/k3s.js +1124 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4611 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1328 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +527 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1819
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
package/dist/index.js ADDED
@@ -0,0 +1,769 @@
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
+ import { strict as nodeAssert } from "node:assert";
7
+ import { recordAssertion, safeSerialize } from "./recorder.js";
8
+ import { adoptNullishTag, readRaw, readTag } from "./inspect.js";
9
+ // `field` — a provenance-preserving, null-safe selector: a `null`/`undefined`
10
+ // leaf read off a wrapped op result is raw and untagged, so an `expect(...)` on
11
+ // it renders detached from its source op; `field` tags from the container so the
12
+ // assertion still nests. (A decoded value loses provenance the same way — that
13
+ // case is the `.transform(label, fn)` method every wrapped value carries, which
14
+ // runs the still-tagged value through `fn` and re-wraps the result.)
15
+ // To recover a raw value, call `.unwrap()` on it — spectest op results are
16
+ // always wrapped (in every context), so the method is always there; there is no
17
+ // `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
18
+ export { field } from "./inspect.js";
19
+ // Instrumented client primitives — drop-in replacements for Bun's native
20
+ // clients that record each operation on the test event log and return their
21
+ // results inspect-wrapped, so `expect(...)` on a result links back to the op
22
+ // in the timeline (same provenance mechanism as the wrapped `fetch`). Reach
23
+ // for these over the raw `Bun.*` clients in service `helpers` and tests so
24
+ // assertions stay tracked. See `sql.ts` / `redis.ts` / `s3.ts`.
25
+ export { SQL } from "./sql.js";
26
+ export { RedisClient } from "./redis.js";
27
+ export { S3Client } from "./s3.js";
28
+ import { isLocator, getLocatorProbe, DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
29
+ // Low-level ingress primitives + the framework lowering that the friendly
30
+ // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
31
+ // `ingress.ts`.
32
+ export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
33
+ // ──────────────────────────────────────────────────────────────────────────
34
+ // Service groups — one services-map entry that expands to several services.
35
+ //
36
+ // A single container is a `ServiceConfig`/`ServiceDefinition`; a component
37
+ // that is inherently a *constellation* of containers (Supabase ≈ 7 of them)
38
+ // is a `ServiceGroup`. The user mounts the whole group under ONE key:
39
+ //
40
+ // services: { supabase: sb.group, app: { ... } }
41
+ //
42
+ // and `defineEnvironment` expands it: the group's `primary` part takes the
43
+ // group's own key (`supabase`), every other part lands at `<key>-<part>`
44
+ // (`supabase-db`, `supabase-auth`, …). Inside the group, `dependsOn`
45
+ // entries naming a relative part are rewritten to the final keys, so a
46
+ // group author never manually prefixes anything. Groups are pure sugar —
47
+ // they expand to plain services before validation, so the wire config the
48
+ // control plane sees is unchanged.
49
+ // ──────────────────────────────────────────────────────────────────────────
50
+ /** Symbol marking a value in the services map as a group to expand.
51
+ * Enumerable-symbol convention (like the ingress `provides` decls): it
52
+ * survives object spread but `JSON.stringify` drops it — not that a group
53
+ * ever reaches the wire; expansion happens before the config exists. */
54
+ const SERVICE_GROUP = Symbol.for("spectest.service.group");
55
+ /**
56
+ * Define a multi-service group. See {@link ServiceGroupInput} for the
57
+ * fields; the result goes straight into a services map:
58
+ *
59
+ * ```ts
60
+ * const stack = serviceGroup({
61
+ * primary: "gateway",
62
+ * services: (g) => ({
63
+ * db: { image: ..., ... },
64
+ * gateway: { image: ..., env: { DB_URL: `postgres://${g.key("db")}:5432/db` },
65
+ * dependsOn: ["db"] },
66
+ * }),
67
+ * helpers: () => ({ url: "http://..." }),
68
+ * });
69
+ *
70
+ * defineEnvironment({ name: "app", services: { stack } });
71
+ * // expands to services `stack` (the gateway) + `stack-db`;
72
+ * // ctx.svc.stack is the helpers record.
73
+ * ```
74
+ */
75
+ export function serviceGroup(input) {
76
+ if (typeof input.services !== "function") {
77
+ throw new Error("serviceGroup: `services` must be a factory function");
78
+ }
79
+ if (!input.primary || typeof input.primary !== "string") {
80
+ throw new Error("serviceGroup: `primary` (a relative part name) is required");
81
+ }
82
+ const group = { ...input };
83
+ Object.defineProperty(group, SERVICE_GROUP, {
84
+ value: true,
85
+ enumerable: true,
86
+ configurable: true,
87
+ writable: false,
88
+ });
89
+ return group;
90
+ }
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ function isServiceGroup(v) {
93
+ return (typeof v === "object" &&
94
+ v !== null &&
95
+ v[SERVICE_GROUP] === true);
96
+ }
97
+ /**
98
+ * Runtime counterpart of {@link ExpandServices}: expand every group entry
99
+ * into plain services. Throws on key collisions, a missing primary,
100
+ * nested groups, a violated `expectKey`, and members depending on the
101
+ * primary (the primary is the group's sink — see
102
+ * {@link ServiceGroupInput.primary}).
103
+ */
104
+ function expandServiceGroups(input) {
105
+ const out = {};
106
+ const ownerOf = new Map();
107
+ const claim = (key, owner) => {
108
+ const prior = ownerOf.get(key);
109
+ if (prior !== undefined) {
110
+ throw new Error(`service key ${JSON.stringify(key)} is produced by both ${prior} and ${owner}`);
111
+ }
112
+ ownerOf.set(key, owner);
113
+ };
114
+ for (const [key, entry] of Object.entries(input)) {
115
+ if (!isServiceGroup(entry)) {
116
+ claim(key, "the services map");
117
+ out[key] = entry;
118
+ continue;
119
+ }
120
+ const owner = `group ${JSON.stringify(key)}`;
121
+ if (entry.expectKey !== undefined && entry.expectKey !== key) {
122
+ throw new Error(`service group at key ${JSON.stringify(key)} expects to be mounted at ` +
123
+ `${JSON.stringify(entry.expectKey)} — its derived names (URLs, app env) were built ` +
124
+ `from that name. Mount it at ${JSON.stringify(entry.expectKey)}, or pass ` +
125
+ `\`name: ${JSON.stringify(key)}\` to the component so both agree.`);
126
+ }
127
+ const naming = {
128
+ name: key,
129
+ key: (part) => (part === entry.primary ? key : `${key}-${part}`),
130
+ };
131
+ const parts = entry.services(naming);
132
+ const partKeys = new Set(Object.keys(parts));
133
+ if (!partKeys.has(entry.primary)) {
134
+ throw new Error(`${owner}: primary part ${JSON.stringify(entry.primary)} is not in the parts the ` +
135
+ `services factory returned (${[...partKeys].join(", ")})`);
136
+ }
137
+ // No member may depend on the primary (directly or transitively within
138
+ // the group): the primary is about to become the group's sink.
139
+ const reachesPrimary = (part, seen = new Set()) => {
140
+ if (seen.has(part))
141
+ return false;
142
+ seen.add(part);
143
+ for (const dep of parts[part]?.dependsOn ?? []) {
144
+ if (!partKeys.has(dep))
145
+ continue;
146
+ if (dep === entry.primary || reachesPrimary(dep, seen))
147
+ return true;
148
+ }
149
+ return false;
150
+ };
151
+ for (const part of partKeys) {
152
+ if (isServiceGroup(parts[part])) {
153
+ throw new Error(`${owner}: part ${JSON.stringify(part)} is itself a group — groups don't nest`);
154
+ }
155
+ if (part !== entry.primary && reachesPrimary(part)) {
156
+ throw new Error(`${owner}: part ${JSON.stringify(part)} depends on the primary ` +
157
+ `${JSON.stringify(entry.primary)} — the primary must be the group's sink ` +
158
+ `(everything else becomes its dependency so "primary ready" means "group up")`);
159
+ }
160
+ }
161
+ for (const [part, svc] of Object.entries(parts)) {
162
+ const finalKey = naming.key(part);
163
+ claim(finalKey, owner);
164
+ // Rewrite relative dependsOn to final keys; a spread keeps function
165
+ // fields (helpers/setup) and the enumerable-symbol ingress decls.
166
+ const final = { ...svc };
167
+ if (svc.dependsOn?.length) {
168
+ final.dependsOn = svc.dependsOn.map((d) => partKeys.has(d) ? naming.key(d) : d);
169
+ }
170
+ if (part === entry.primary) {
171
+ // Sink: the primary waits for every other member, so an outside
172
+ // `dependsOn: ["<groupKey>"]` (and the group `setup` below) means
173
+ // the whole group. Members already in dependsOn stay put.
174
+ const deps = new Set(final.dependsOn ?? []);
175
+ for (const other of partKeys) {
176
+ if (other !== entry.primary)
177
+ deps.add(naming.key(other));
178
+ }
179
+ final.dependsOn = [...deps];
180
+ const def = final;
181
+ if (entry.helpers) {
182
+ if (def.helpers) {
183
+ throw new Error(`${owner}: both the group and its primary part declare \`helpers\` — ` +
184
+ `declare them once, on the group`);
185
+ }
186
+ def.helpers = entry.helpers;
187
+ }
188
+ if (entry.setup) {
189
+ const partSetup = def.setup;
190
+ const groupSetup = entry.setup;
191
+ def.setup = async (args) => {
192
+ if (partSetup)
193
+ await partSetup(args);
194
+ await groupSetup(args);
195
+ };
196
+ }
197
+ }
198
+ out[finalKey] = final;
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+ function validateEnvironmentConfig(config) {
204
+ const entries = Object.entries(config.services);
205
+ const serviceNames = new Set(entries.map(([n]) => n));
206
+ const claimedBy = new Map();
207
+ const claimBy = (h, name, kind) => {
208
+ const prior = claimedBy.get(h);
209
+ if (prior !== undefined && prior !== name) {
210
+ throw new Error(`hostname ${JSON.stringify(h)} is claimed by both "${prior}" and "${name}"`);
211
+ }
212
+ if (serviceNames.has(h)) {
213
+ throw new Error(`${kind} ${JSON.stringify(h)} collides with service name "${h}"`);
214
+ }
215
+ claimedBy.set(h, name);
216
+ };
217
+ for (const [name, svc] of entries) {
218
+ if (name.length === 0) {
219
+ throw new Error("service name (services map key) must be a non-empty string");
220
+ }
221
+ for (const dep of svc.dependsOn ?? []) {
222
+ if (!serviceNames.has(dep)) {
223
+ throw new Error(`service "${name}" dependsOn "${dep}" which is not a service in this environment`);
224
+ }
225
+ }
226
+ for (const vol of svc.volumes ?? []) {
227
+ if (vol.name !== undefined && vol.source !== undefined) {
228
+ throw new Error(`service "${name}" volume for ${JSON.stringify(vol.target)} sets both \`name\` and \`source\` — a named shared volume derives its backing dir from the name`);
229
+ }
230
+ if (vol.name !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(vol.name)) {
231
+ throw new Error(`service "${name}" volume name ${JSON.stringify(vol.name)} must be alphanumeric plus [._-]`);
232
+ }
233
+ }
234
+ for (const raw of svc.hostnames ?? []) {
235
+ const h = raw.toLowerCase();
236
+ if (!HOSTNAME_RE.test(h)) {
237
+ throw new Error(`service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name (e.g. "api.stripe.com")`);
238
+ }
239
+ if (h === "internal" || h.endsWith(".internal")) {
240
+ throw new Error(`service "${name}" declares hostname ${JSON.stringify(raw)} — the ".internal" TLD is reserved; every service already answers to "<name>.internal" automatically`);
241
+ }
242
+ claimBy(h, name, "hostname");
243
+ }
244
+ for (const entry of svc.tls ?? []) {
245
+ if (!entry || typeof entry.hostname !== "string" || typeof entry.port !== "number") {
246
+ throw new Error(`service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`);
247
+ }
248
+ const h = entry.hostname.toLowerCase();
249
+ if (!HOSTNAME_RE.test(h)) {
250
+ throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name (e.g. "app.test")`);
251
+ }
252
+ if (h === "internal" || h.endsWith(".internal")) {
253
+ throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} ends in reserved ".internal" TLD`);
254
+ }
255
+ if (!Number.isInteger(entry.port) || entry.port <= 0 || entry.port > 65535) {
256
+ throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} has invalid port ${entry.port}`);
257
+ }
258
+ claimBy(h, name, "tls");
259
+ }
260
+ }
261
+ }
262
+ // Multi-label hostname: at least one dot, each label 1–63 chars of
263
+ // [a-z0-9-], no leading/trailing hyphen.
264
+ const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
265
+ /**
266
+ * Define a fake. `defineFake({ ... })` is a thin wrapper that pins the
267
+ * generic `S` and `H` so `helpers`/`handler` see the inferred state type
268
+ * without the caller having to spell it out twice.
269
+ */
270
+ export function defineFake(opts) {
271
+ if (!opts.name || opts.name.length === 0) {
272
+ throw new Error("defineFake: `name` is required");
273
+ }
274
+ if (!Array.isArray(opts.hostnames) || opts.hostnames.length === 0) {
275
+ throw new Error(`defineFake(${opts.name}): at least one hostname is required`);
276
+ }
277
+ for (const h of opts.hostnames) {
278
+ const lower = h.toLowerCase();
279
+ if (!HOSTNAME_RE.test(lower)) {
280
+ throw new Error(`defineFake(${opts.name}): invalid hostname ${JSON.stringify(h)} — must be a multi-label DNS name (e.g. "api.stripe.com")`);
281
+ }
282
+ if (lower === "internal" || lower.endsWith(".internal")) {
283
+ throw new Error(`defineFake(${opts.name}): hostname ${JSON.stringify(h)} ends in reserved ".internal" TLD`);
284
+ }
285
+ }
286
+ if (typeof opts.handler !== "function") {
287
+ throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
288
+ }
289
+ return opts;
290
+ }
291
+ /**
292
+ * Define an environment and get back a builder you can hang tests off.
293
+ * The builder's `.test(...)` returns test cases typed against the
294
+ * environment's services (and any declared `fakes`), and `.project([...])`
295
+ * produces the file's default export.
296
+ */
297
+ export function defineEnvironment(input) {
298
+ // Split fakes off the wire config and expand service groups; only
299
+ // { name, services, timeoutSecs } — with plain, fully-expanded services —
300
+ // is stored as `config` and shipped to the control plane.
301
+ const { fakes, services: rawServices, ...rest } = input;
302
+ const config = {
303
+ ...rest,
304
+ services: expandServiceGroups(rawServices),
305
+ };
306
+ validateEnvironmentConfig(config);
307
+ if (fakes)
308
+ validateFakes(config, fakes);
309
+ // Every test created via this env's `.test(...)` is registered here. A
310
+ // split-layout project keeps its tests in `spectest/tests/**` — each file
311
+ // imports this `env` (for types + `dependsOn` refs) and calls `env.test`,
312
+ // which appends to `registry`. The daemon imports those files and then
313
+ // reads the suite back off the default-exported Project's lazy `tests`
314
+ // getter (installed in `project()` below). Keeping the env definition
315
+ // itself free of test bodies is what lets the warm-template cache key
316
+ // ignore test edits — see `project_content_hash` in the control plane.
317
+ const registry = [];
318
+ const test = ((name, optsOrFn, maybeFn) => {
319
+ const tc = buildTestCase(name, optsOrFn, maybeFn);
320
+ registry.push(tc);
321
+ return tc;
322
+ });
323
+ function project(arg) {
324
+ const opts = Array.isArray(arg)
325
+ ? { tests: arg }
326
+ : (arg ?? {});
327
+ const proj = { environment: config };
328
+ if (opts.setup)
329
+ proj.setup = opts.setup;
330
+ if (fakes)
331
+ proj.fakes = fakes;
332
+ if (opts.tests && opts.tests.length > 0) {
333
+ // Explicit suite (single-file / legacy layout): the tests are listed
334
+ // right here, so freeze them now.
335
+ proj.tests = validateSuite({ tests: opts.tests });
336
+ }
337
+ else {
338
+ // Split layout: tests are defined in separate files and collected from
339
+ // the registry as those files are imported. Expose them lazily so the
340
+ // daemon sees whatever has registered by the time it reads `.tests`
341
+ // (i.e. after it has imported `spectest/tests/**` on `/load-tests`).
342
+ Object.defineProperty(proj, "tests", {
343
+ enumerable: true,
344
+ configurable: true,
345
+ get() {
346
+ return registry.length > 0
347
+ ? validateSuite({ tests: [...registry] })
348
+ : undefined;
349
+ },
350
+ });
351
+ }
352
+ return proj;
353
+ }
354
+ return {
355
+ config,
356
+ test,
357
+ project,
358
+ };
359
+ }
360
+ /**
361
+ * Cross-check fakes against the environment's services: no duplicate
362
+ * hostnames, no collisions with service names or service hostnames, and
363
+ * each fake's name is unique (the user passed a map, so JS guarantees
364
+ * the second condition — but we re-check defensively for the case where
365
+ * an object literal is built programmatically).
366
+ */
367
+ function validateFakes(env, fakes) {
368
+ const claimedByService = new Map();
369
+ for (const [svcName, svc] of Object.entries(env.services)) {
370
+ claimedByService.set(svcName.toLowerCase(), svcName);
371
+ claimedByService.set(`${svcName.toLowerCase()}.internal`, svcName);
372
+ for (const h of svc.hostnames ?? []) {
373
+ claimedByService.set(h.toLowerCase(), svcName);
374
+ }
375
+ for (const entry of svc.tls ?? []) {
376
+ claimedByService.set(entry.hostname.toLowerCase(), svcName);
377
+ }
378
+ }
379
+ const claimedByFake = new Map();
380
+ for (const [key, fake] of Object.entries(fakes)) {
381
+ if (!fake || typeof fake !== "object" || typeof fake.handler !== "function") {
382
+ throw new Error(`fake ${JSON.stringify(key)} is not a FakeDefinition — pass the result of defineFake({...})`);
383
+ }
384
+ for (const raw of fake.hostnames) {
385
+ const h = raw.toLowerCase();
386
+ const owningSvc = claimedByService.get(h);
387
+ if (owningSvc !== undefined) {
388
+ throw new Error(`fake ${JSON.stringify(key)} hostname ${JSON.stringify(raw)} collides with service ${JSON.stringify(owningSvc)}`);
389
+ }
390
+ const owningFake = claimedByFake.get(h);
391
+ if (owningFake !== undefined && owningFake !== key) {
392
+ throw new Error(`hostname ${JSON.stringify(raw)} is claimed by both fake ${JSON.stringify(owningFake)} and fake ${JSON.stringify(key)}`);
393
+ }
394
+ claimedByFake.set(h, key);
395
+ }
396
+ }
397
+ }
398
+ // `buildTestCase` is the runtime implementation behind every typed
399
+ // `env.test(...)`. The DefinedEnvironment exposes it cast to TypedTest<S>
400
+ // so callers see the strongly-typed overloads while the body stays
401
+ // generic — TestFn is contravariant in `ctx`, so a single implementation
402
+ // satisfies every TypedTest<S>.
403
+ function buildTestCase(name,
404
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
405
+ optsOrFn,
406
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
407
+ maybeFn) {
408
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
409
+ const [opts, fn] = typeof optsOrFn === "function" ? [{}, optsOrFn] : [optsOrFn, maybeFn];
410
+ if (typeof fn !== "function") {
411
+ throw new TypeError(`test(${JSON.stringify(name)}): missing test function`);
412
+ }
413
+ return {
414
+ id: slugify(name),
415
+ name,
416
+ dependsOn: opts.dependsOn,
417
+ timeoutMs: opts.timeoutMs,
418
+ run: fn,
419
+ };
420
+ }
421
+ function validateSuite(suite) {
422
+ const seenIds = new Map();
423
+ for (const t of suite.tests) {
424
+ const prior = seenIds.get(t.id);
425
+ if (prior !== undefined) {
426
+ throw new Error(`duplicate test id "${t.id}" (from ${JSON.stringify(prior)} and ${JSON.stringify(t.name)}) — rename one`);
427
+ }
428
+ seenIds.set(t.id, t.name);
429
+ }
430
+ // Validate that each dependsOn ref is in the suite. Catches typos /
431
+ // imports forgotten in the tests array.
432
+ for (const t of suite.tests) {
433
+ if (t.dependsOn && !suite.tests.includes(t.dependsOn)) {
434
+ throw new Error(`test "${t.name}" depends on "${t.dependsOn.name}" which is not in the suite`);
435
+ }
436
+ }
437
+ return suite;
438
+ }
439
+ function slugify(name) {
440
+ const slug = name
441
+ .toLowerCase()
442
+ .replace(/[^a-z0-9]+/g, "-")
443
+ .replace(/^-+|-+$/g, "");
444
+ if (!slug) {
445
+ throw new Error(`cannot derive id from test name ${JSON.stringify(name)}`);
446
+ }
447
+ return slug;
448
+ }
449
+ // ──────────────────────────────────────────────────────────────────────────
450
+ // Assertions
451
+ // ──────────────────────────────────────────────────────────────────────────
452
+ export class ExpectationError extends Error {
453
+ constructor(message) {
454
+ super(message);
455
+ this.name = "ExpectationError";
456
+ }
457
+ }
458
+ export function expect(actual, message) {
459
+ if (isLocator(actual))
460
+ return buildLocatorAssertion(actual, message);
461
+ return expectValue(actual, message);
462
+ }
463
+ function expectValue(actual, message) {
464
+ // The first parameter is typed to the {@link Provenanced} family so a raw
465
+ // value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
466
+ // every assertion that reaches the timeline this way carries a provenance link
467
+ // back to the op that produced it. Assert on a genuinely raw value with
468
+ // `expectRaw(value, message)` instead.
469
+ //
470
+ // `message` is an **optional** human label for the assertion. The UI already
471
+ // renders the target, matcher, and expected value, so a message that just
472
+ // restates them is noise — supply one *only* when the check's intent isn't
473
+ // obvious from those alone (e.g.
474
+ // `expect(res.status, "blocked once the rate limit trips").toBe(429)`). When
475
+ // present it leads the assertion's summary, the same way `expectRaw`'s does.
476
+ //
477
+ // A `null`/`undefined` read off a wrapped op result reaches here untagged
478
+ // (a symbol can't ride on nullish). `adoptNullishTag` recovers the tag from
479
+ // the proxy's most-recent nullish-leaf note and mints a tagged holder, so
480
+ // `expect(dep.status.readyReplicas).toBeFalsy()` nests under its op just like
481
+ // a non-nullish read. Done once here (not in `buildMatchers`) so the `.not`
482
+ // re-pass reuses the same tagged holder instead of re-consuming the note.
483
+ return buildMatchers(adoptNullishTag(actual), false, message);
484
+ }
485
+ // ── expect(locator): auto-retrying web-first matchers ─────────────────────
486
+ /** Poll interval for locator matchers. */
487
+ const LOCATOR_POLL_MS = 50;
488
+ function buildLocatorAssertion(loc, message) {
489
+ return Object.assign(buildLocatorMatchers(loc, false, message), {
490
+ not: buildLocatorMatchers(loc, true, message),
491
+ });
492
+ }
493
+ function buildLocatorMatchers(loc, negated, message) {
494
+ const probe = getLocatorProbe(loc);
495
+ // Poll `check` (a silent, non-recorded read) until the desired condition
496
+ // holds or the deadline elapses, then emit ONE settled browser step (the
497
+ // locator label + replay seek point) and record ONE assertion nested under
498
+ // it via `sourceSeq` — so a web-first `expect(locator)` assertion carries
499
+ // the same provenance a value assertion does (`expect(await loc.isVisible())`
500
+ // renders identically). Without the anchor step these assertions floated as
501
+ // disconnected top-level "value ✓" rows with no element and no replay seek.
502
+ // `check` returns whether the base condition is satisfied plus the observed
503
+ // value for the timeline.
504
+ const run = async (matcher, timeout, check, describe, expected) => {
505
+ const started = Date.now();
506
+ const deadline = started + (timeout ?? DEFAULT_ACTION_TIMEOUT_MS);
507
+ let actual;
508
+ for (;;) {
509
+ let satisfied;
510
+ try {
511
+ const r = await check();
512
+ satisfied = r.satisfied;
513
+ actual = r.actual;
514
+ }
515
+ catch (err) {
516
+ if (Date.now() < deadline) {
517
+ await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
518
+ continue;
519
+ }
520
+ satisfied = false;
521
+ actual = `<error: ${err?.message ?? String(err)}>`;
522
+ }
523
+ const passed = satisfied !== negated;
524
+ if (passed) {
525
+ const sourceSeq = await probe.settle(matcher, Date.now() - started);
526
+ recordAssertion({
527
+ matcher,
528
+ negated,
529
+ passed: true,
530
+ actual: safeSerialize(actual),
531
+ expected: expected === undefined ? undefined : safeSerialize(expected),
532
+ message,
533
+ sourceSeq,
534
+ });
535
+ return;
536
+ }
537
+ if (Date.now() >= deadline) {
538
+ const msg = describe(actual);
539
+ const sourceSeq = await probe.settle(matcher, Date.now() - started, msg);
540
+ recordAssertion({
541
+ matcher,
542
+ negated,
543
+ passed: false,
544
+ actual: safeSerialize(actual),
545
+ expected: expected === undefined ? undefined : safeSerialize(expected),
546
+ error: msg,
547
+ message,
548
+ sourceSeq,
549
+ });
550
+ throw new ExpectationError(msg);
551
+ }
552
+ await new Promise((r) => setTimeout(r, LOCATOR_POLL_MS));
553
+ }
554
+ };
555
+ const not = negated ? " not" : "";
556
+ const matchesText = (v, expected) => expected instanceof RegExp ? expected.test(v) : v === expected;
557
+ return {
558
+ toBeVisible: (opts) => run("toBeVisible", opts?.timeout, async () => {
559
+ const v = await probe.isVisible();
560
+ return { satisfied: v, actual: v };
561
+ }, () => `expected ${probe.label}${not} to be visible`),
562
+ toBeHidden: (opts) => run("toBeHidden", opts?.timeout, async () => {
563
+ const v = await probe.isVisible();
564
+ return { satisfied: !v, actual: v };
565
+ }, () => `expected ${probe.label}${not} to be hidden`),
566
+ toHaveText: (expected, opts) => run("toHaveText", opts?.timeout, async () => {
567
+ const t = (await probe.textContent(opts?.timeout)) ?? "";
568
+ return { satisfied: matchesText(t.trim(), expected), actual: t };
569
+ }, (a) => `expected ${probe.label}${not} to have text ${fmt(expected)}, got ${fmt(a)}`, expected instanceof RegExp ? String(expected) : expected),
570
+ toContainText: (expected, opts) => run("toContainText", opts?.timeout, async () => {
571
+ const t = (await probe.textContent(opts?.timeout)) ?? "";
572
+ return { satisfied: t.includes(expected), actual: t };
573
+ }, (a) => `expected ${probe.label}${not} to contain text ${fmt(expected)}, got ${fmt(a)}`, expected),
574
+ toHaveValue: (expected, opts) => run("toHaveValue", opts?.timeout, async () => {
575
+ const v = await probe.inputValue(opts?.timeout);
576
+ return { satisfied: matchesText(v, expected), actual: v };
577
+ }, (a) => `expected ${probe.label}${not} to have value ${fmt(expected)}, got ${fmt(a)}`, expected instanceof RegExp ? String(expected) : expected),
578
+ toHaveCount: (expected, opts) => run("toHaveCount", opts?.timeout, async () => {
579
+ const c = await probe.count();
580
+ return { satisfied: c === expected, actual: c };
581
+ }, (a) => `expected ${probe.label}${not} to have count ${expected}, got ${fmt(a)}`, expected),
582
+ toBeEnabled: (opts) => run("toBeEnabled", opts?.timeout, async () => {
583
+ const v = await probe.isEnabled(opts?.timeout);
584
+ return { satisfied: v, actual: v };
585
+ }, () => `expected ${probe.label}${not} to be enabled`),
586
+ toBeDisabled: (opts) => run("toBeDisabled", opts?.timeout, async () => {
587
+ const v = await probe.isEnabled(opts?.timeout);
588
+ return { satisfied: !v, actual: v };
589
+ }, () => `expected ${probe.label}${not} to be disabled`),
590
+ toBeChecked: (opts) => run("toBeChecked", opts?.timeout, async () => {
591
+ const v = await probe.isChecked(opts?.timeout);
592
+ return { satisfied: v, actual: v };
593
+ }, () => `expected ${probe.label}${not} to be checked`),
594
+ };
595
+ }
596
+ /**
597
+ * Assert on a value with **no provenance** — a computed number, a raw
598
+ * WebSocket frame, anything that didn't flow from a recorded op. `message` is
599
+ * required (it's the second argument) and reads as the natural follow-on to
600
+ * "assert …" (e.g. `expectRaw(id, "id matches the generated value")`); it
601
+ * renders as the assertion's label in the CLI/dashboard ("ASSERT <message>")
602
+ * since a raw assertion has no op to nest under. Prefer `expect(...)` whenever
603
+ * the value carries provenance — only reach for this when the type gate would
604
+ * (rightly) reject the value. (`expect`'s own `message` is optional; here it is
605
+ * mandatory, since the label is the only human-meaningful summary a raw
606
+ * assertion has.)
607
+ */
608
+ export function expectRaw(actual, message) {
609
+ // Force the raw form so no stray tag is read even if a wrapped value is
610
+ // passed: an `expectRaw` assertion is *deliberately* unlinked, rendered at
611
+ // top level under its own message rather than nested beneath an op.
612
+ return buildMatchers(readRaw(actual), false, message);
613
+ }
614
+ function buildMatchers(wrapped, negated, message) {
615
+ // If `wrapped` is an inspect-tagged value (from fetch / db / browser),
616
+ // pull its origin metadata and run matchers against the raw underlying
617
+ // value. Plain `expect(value)` is unaffected. Tag + raw are read once here
618
+ // and threaded explicitly into `buildCore`, so `.not` and transforms re-pass
619
+ // them directly instead of re-reading the wrapper (and re-consuming any
620
+ // adopted nullish note).
621
+ return buildCore(readRaw(wrapped), readTag(wrapped), negated, message);
622
+ }
623
+ /**
624
+ * The matcher/transform factory, working off an already-unwrapped `actual` and
625
+ * an explicit `tag`. `pendingError`, when set, marks a transform that failed
626
+ * upstream: every matcher then records a failed assertion carrying that error
627
+ * (irrespective of negation) and throws, and further transforms propagate it.
628
+ */
629
+ function buildCore(actual, tag, negated, message, pendingError) {
630
+ // Wraps a matcher: it computes the raw condition, the failure message,
631
+ // records the assertion event, then throws iff the result is a failure.
632
+ // `expectedFor` lets matchers record their expected value where it
633
+ // makes sense (toBe / toEqual / toContain / toMatch) while matchers
634
+ // like toBeTruthy leave it unset.
635
+ const run = (matcher, cond, msg, expectedFor, opts) => {
636
+ // A failed upstream transform short-circuits every matcher to a failure —
637
+ // there is no meaningful value to match, and negation can't rescue a value
638
+ // that never decoded — so we record `pendingError` and throw regardless of
639
+ // `cond`/`negated`.
640
+ if (pendingError !== undefined) {
641
+ recordAssertion({
642
+ matcher,
643
+ negated,
644
+ passed: false,
645
+ actual: safeSerialize(actual),
646
+ expected: expectedFor === undefined ? undefined : safeSerialize(expectedFor),
647
+ error: pendingError,
648
+ message,
649
+ sourceSeq: tag?.sourceSeq,
650
+ path: tag ? [...tag.path] : undefined,
651
+ });
652
+ throw new ExpectationError(pendingError);
653
+ }
654
+ const passed = cond !== negated;
655
+ recordAssertion({
656
+ matcher,
657
+ negated,
658
+ passed,
659
+ actual: safeSerialize(opts && "actual" in opts ? opts.actual : actual),
660
+ expected: expectedFor === undefined ? undefined : safeSerialize(expectedFor),
661
+ error: passed ? undefined : msg,
662
+ message,
663
+ sourceSeq: tag?.sourceSeq,
664
+ path: tag ? [...tag.path] : undefined,
665
+ });
666
+ if (!passed)
667
+ throw new ExpectationError(msg);
668
+ };
669
+ return {
670
+ toBe(expected) {
671
+ const exp = readRaw(expected);
672
+ run("toBe", Object.is(actual, exp), `expected ${fmt(actual)}${negated ? " not" : ""} to be ${fmt(exp)}`, exp);
673
+ },
674
+ toEqual(expected) {
675
+ const exp = readRaw(expected);
676
+ let equal = true;
677
+ try {
678
+ nodeAssert.deepStrictEqual(actual, exp);
679
+ }
680
+ catch {
681
+ equal = false;
682
+ }
683
+ run("toEqual", equal, `expected ${fmt(actual)}${negated ? " not" : ""} to deep-equal ${fmt(exp)}`, exp);
684
+ },
685
+ toBeTruthy() {
686
+ run("toBeTruthy", !!actual, `expected ${fmt(actual)}${negated ? " not" : ""} to be truthy`);
687
+ },
688
+ toBeFalsy() {
689
+ run("toBeFalsy", !actual, `expected ${fmt(actual)}${negated ? " not" : ""} to be falsy`);
690
+ },
691
+ toBeGreaterThan(n) {
692
+ run("toBeGreaterThan", typeof actual === "number" && actual > n, `expected ${fmt(actual)}${negated ? " not" : ""} to be > ${n}`, n);
693
+ },
694
+ toBeLessThan(n) {
695
+ run("toBeLessThan", typeof actual === "number" && actual < n, `expected ${fmt(actual)}${negated ? " not" : ""} to be < ${n}`, n);
696
+ },
697
+ toBeGreaterThanOrEqual(n) {
698
+ run("toBeGreaterThanOrEqual", typeof actual === "number" && actual >= n, `expected ${fmt(actual)}${negated ? " not" : ""} to be >= ${n}`, n);
699
+ },
700
+ toBeLessThanOrEqual(n) {
701
+ run("toBeLessThanOrEqual", typeof actual === "number" && actual <= n, `expected ${fmt(actual)}${negated ? " not" : ""} to be <= ${n}`, n);
702
+ },
703
+ toContain(expected) {
704
+ const exp = readRaw(expected);
705
+ let contained = false;
706
+ if (typeof actual === "string" && typeof exp === "string") {
707
+ contained = actual.includes(exp);
708
+ }
709
+ else if (Array.isArray(actual)) {
710
+ contained = actual.some((v) => {
711
+ try {
712
+ nodeAssert.deepStrictEqual(v, exp);
713
+ return true;
714
+ }
715
+ catch {
716
+ return false;
717
+ }
718
+ });
719
+ }
720
+ run("toContain", contained, `expected ${fmt(actual)}${negated ? " not" : ""} to contain ${fmt(exp)}`, exp);
721
+ },
722
+ toMatch(re) {
723
+ run("toMatch", typeof actual === "string" && re.test(actual), `expected ${fmt(actual)}${negated ? " not" : ""} to match ${re}`, String(re));
724
+ },
725
+ toHaveLength(n) {
726
+ // Provenance-preserving count assertion. A wrapped array's `.length`
727
+ // is deliberately raw (so `rows.length === 1` works), which means
728
+ // `expect(rows.length)` can't link back to the originating op —
729
+ // pass the container itself instead: `expect(rows).toHaveLength(1)`
730
+ // keeps the tag, and we read the length off the raw value here.
731
+ const len = typeof actual === "string" || Array.isArray(actual)
732
+ ? actual.length
733
+ : actual !== null &&
734
+ typeof actual === "object" &&
735
+ typeof actual.length === "number"
736
+ ? actual.length
737
+ : undefined;
738
+ // The provenance `path` stays the container's own path — `.length` is
739
+ // the matcher's internal read, not a property access the author wrote,
740
+ // so it doesn't belong in the path (recording it produced a redundant
741
+ // "length toHaveLength N" in the UI, since the matcher name already says
742
+ // "length"). `actual` is still the length number: that's what's worth
743
+ // showing on failure, and the `toHaveLength` matcher disambiguates it.
744
+ run("toHaveLength", len === n, `expected ${fmt(actual)}${negated ? " not" : ""} to have length ${n}` +
745
+ (len === undefined ? " (value has no length)" : ` (got ${len})`), n, { actual: len });
746
+ },
747
+ get not() {
748
+ // Re-pass the already-unwrapped value, tag and message so the tag and raw
749
+ // label are preserved for the negated branch's AssertionEvent.
750
+ return buildCore(actual, tag, !negated, message, pendingError);
751
+ },
752
+ };
753
+ }
754
+ function fmt(v) {
755
+ if (typeof v === "string")
756
+ return JSON.stringify(v);
757
+ if (typeof v === "bigint")
758
+ return `${v}n`;
759
+ if (v === undefined)
760
+ return "undefined";
761
+ try {
762
+ return JSON.stringify(v);
763
+ }
764
+ catch {
765
+ return String(v);
766
+ }
767
+ }
768
+ /** `node:assert/strict` re-exported for users who prefer Node's built-in API. */
769
+ export const assert = nodeAssert;