@specific.dev/spectest 0.26.0 → 0.27.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/aws-sigv4.d.ts +42 -0
- package/dist/aws-sigv4.js +166 -0
- package/dist/browser.d.ts +314 -0
- package/dist/browser.js +1320 -0
- package/dist/components/email.d.ts +135 -0
- package/dist/components/email.js +271 -0
- package/dist/components/expo.d.ts +69 -0
- package/dist/components/expo.js +125 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +18 -0
- package/dist/components/k3s.d.ts +143 -0
- package/dist/components/k3s.js +1067 -0
- package/dist/components/postgres.d.ts +93 -0
- package/dist/components/postgres.js +58 -0
- package/dist/components/replayFake.d.ts +169 -0
- package/dist/components/replayFake.js +738 -0
- package/dist/components/s3.d.ts +99 -0
- package/dist/components/s3.js +81 -0
- package/dist/components/supabase.d.ts +197 -0
- package/dist/components/supabase.js +1003 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +4223 -0
- package/dist/ids.d.ts +2 -0
- package/{src/ids.ts → dist/ids.js} +46 -50
- package/dist/index.d.ts +1183 -0
- package/dist/index.js +769 -0
- package/dist/ingress.d.ts +114 -0
- package/dist/ingress.js +210 -0
- package/dist/inspect.d.ts +228 -0
- package/dist/inspect.js +429 -0
- package/dist/locator.d.ts +260 -0
- package/dist/locator.js +293 -0
- package/dist/mobile.d.ts +71 -0
- package/dist/mobile.js +65 -0
- package/dist/record-secrets.d.ts +9 -0
- package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
- package/dist/recorder.d.ts +516 -0
- package/dist/recorder.js +219 -0
- package/dist/redis.d.ts +54 -0
- package/dist/redis.js +126 -0
- package/dist/replay-bundle.d.ts +38 -0
- package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.js +309 -0
- package/dist/s3.d.ts +89 -0
- package/dist/s3.js +198 -0
- package/dist/sql.d.ts +74 -0
- package/dist/sql.js +151 -0
- package/dist/terminal.d.ts +161 -0
- package/dist/terminal.js +538 -0
- package/package.json +24 -9
- package/src/browser.ts +0 -1819
- package/src/components/email.ts +0 -398
- package/src/components/expo.ts +0 -167
- package/src/components/index.ts +0 -63
- package/src/components/k3s.ts +0 -1312
- package/src/components/postgres.ts +0 -105
- package/src/components/replayFake.ts +0 -848
- package/src/components/s3.ts +0 -132
- package/src/components/supabase.ts +0 -1299
- package/src/daemon.ts +0 -4969
- package/src/index.ts +0 -2350
- package/src/ingress.ts +0 -288
- package/src/inspect.ts +0 -673
- package/src/locator.ts +0 -594
- package/src/mobile.ts +0 -133
- package/src/recorder.ts +0 -817
- package/src/redis.ts +0 -202
- package/src/resolver.ts +0 -351
- package/src/s3.ts +0 -333
- package/src/sql.ts +0 -243
- package/src/terminal.ts +0 -740
- package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
- 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;
|