@jterrazz/test 10.1.0 → 11.0.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/queue.js ADDED
@@ -0,0 +1,555 @@
1
+ import { n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
2
+ //#region src/core/matching/structural.ts
3
+ /**
4
+ * Structural comparison engine shared by every fixture matcher.
5
+ *
6
+ * Handles three kinds of "expected" values:
7
+ * - plain JSON values → strict deep equality
8
+ * - {@link Matcher} instances (code-side `match.*`)
9
+ * - strings containing `{{placeholder}}` forms (file-side fixtures)
10
+ *
11
+ * One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
12
+ * works in `expected/*.http` (body and headers), `expected/*.json`, and
13
+ * text snapshots (`expected/*.txt`).
14
+ *
15
+ * Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
16
+ * {@link CaptureScope} supplied by the caller.
17
+ */
18
+ const UUID_SOURCE = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
19
+ const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
20
+ const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
21
+ const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
22
+ const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
23
+ const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
24
+ const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
25
+ const INT_SOURCE = String.raw`-?\d+`;
26
+ const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
27
+ const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
28
+ const SHA_SOURCE = "[0-9a-f]{7,64}";
29
+ const HEX_SOURCE = "[0-9a-fA-F]+";
30
+ const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
31
+ const PORT_SOURCE = String.raw`(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})`;
32
+ const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
33
+ const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
34
+ const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
35
+ const EMBEDDED_SOURCES = {
36
+ base64: BASE64_SOURCE,
37
+ date: DATE_SOURCE,
38
+ duration: DURATION_SOURCE,
39
+ email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
40
+ float: FLOAT_SOURCE,
41
+ hex: HEX_SOURCE,
42
+ int: INT_SOURCE,
43
+ ip: IP_SOURCE,
44
+ iso8601: ISO8601_SOURCE,
45
+ number: NUMBER_SOURCE,
46
+ path: String.raw`\.{0,2}\/[^\s"'<>]*`,
47
+ port: PORT_SOURCE,
48
+ semver: SEMVER_SOURCE,
49
+ sha: SHA_SOURCE,
50
+ time: TIME_SOURCE,
51
+ ulid: ULID_SOURCE,
52
+ url: URL_SOURCE,
53
+ uuid: UUID_SOURCE
54
+ };
55
+ const wholeRe = (source) => new RegExp(`^(?:${source})$`);
56
+ const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
57
+ const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "g");
58
+ /** Whether a fixture string contains at least one `{{placeholder}}`. */
59
+ function hasPlaceholders(value) {
60
+ PLACEHOLDER_RE.lastIndex = 0;
61
+ return PLACEHOLDER_RE.test(value);
62
+ }
63
+ /** Key-order-independent stringification for captured-object comparison. */
64
+ function stableStringify(value) {
65
+ if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
66
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
67
+ return JSON.stringify(value) ?? "undefined";
68
+ }
69
+ function capturedEquals(a, b) {
70
+ if (Object.is(a, b)) return true;
71
+ if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
72
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
73
+ return false;
74
+ }
75
+ function recordRef(name, actual, scope) {
76
+ if (scope.has(name)) return capturedEquals(scope.get(name), actual);
77
+ scope.set(name, actual);
78
+ return true;
79
+ }
80
+ function isPortValue(value) {
81
+ return Number.isInteger(value) && value >= 0 && value <= 65535;
82
+ }
83
+ function kindMatches(kind, actual, scope) {
84
+ switch (kind) {
85
+ case "any": return true;
86
+ case "float":
87
+ if (typeof actual === "number") return Number.isFinite(actual);
88
+ return typeof actual === "string" && WHOLE_RES.float.test(actual);
89
+ case "int":
90
+ if (typeof actual === "number") return Number.isInteger(actual);
91
+ return typeof actual === "string" && WHOLE_RES.int.test(actual);
92
+ case "number":
93
+ if (typeof actual === "number") return Number.isFinite(actual);
94
+ return typeof actual === "string" && WHOLE_RES.number.test(actual);
95
+ case "port":
96
+ if (typeof actual === "number") return isPortValue(actual);
97
+ return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
98
+ case "string": return typeof actual === "string";
99
+ case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
100
+ default: {
101
+ const re = WHOLE_RES[kind];
102
+ return re !== void 0 && typeof actual === "string" && re.test(actual);
103
+ }
104
+ }
105
+ }
106
+ function matcherMatches(matcher, actual, scope) {
107
+ if (matcher.kind === "includes") return typeof actual === "string" && actual.includes(matcher.includes);
108
+ if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
109
+ if (matcher.kind === "ref") {
110
+ if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
111
+ return recordRef(matcher.refName, actual, scope);
112
+ }
113
+ return kindMatches(matcher.kind, actual, scope);
114
+ }
115
+ function placeholderSource(kind, scope) {
116
+ if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
117
+ const source = EMBEDDED_SOURCES[kind];
118
+ if (source) return source;
119
+ return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
120
+ }
121
+ function escapeRegExp(text) {
122
+ return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
123
+ }
124
+ function parsePlaceholderString(expected, scope) {
125
+ PLACEHOLDER_RE.lastIndex = 0;
126
+ const refs = [];
127
+ let source = "";
128
+ let lastIndex = 0;
129
+ let single = null;
130
+ let count = 0;
131
+ for (const found of expected.matchAll(PLACEHOLDER_RE)) {
132
+ const kind = found.groups.kind;
133
+ const ref = found.groups.ref;
134
+ source += escapeRegExp(expected.slice(lastIndex, found.index));
135
+ source += `(${placeholderSource(kind, scope)})`;
136
+ refs.push({
137
+ index: count,
138
+ kind,
139
+ ref
140
+ });
141
+ count++;
142
+ lastIndex = found.index + found[0].length;
143
+ if (found.index === 0 && found[0].length === expected.length) single = {
144
+ kind,
145
+ ref
146
+ };
147
+ }
148
+ source += escapeRegExp(expected.slice(lastIndex));
149
+ return {
150
+ pattern: new RegExp(`^${source}$`),
151
+ refs,
152
+ single,
153
+ source
154
+ };
155
+ }
156
+ /**
157
+ * Unanchored regex source matching a fixture string — plain text escaped
158
+ * verbatim, each `{{token}}` expanded to its embedded grammar. Used to route
159
+ * declared `.http` intercept paths (`/articles/{{uuid}}`) as URL patterns.
160
+ */
161
+ function placeholderPatternSource(expected) {
162
+ return parsePlaceholderString(expected, new CaptureScope()).source;
163
+ }
164
+ function placeholderStringMatches(expected, actual, scope) {
165
+ const parsed = parsePlaceholderString(expected, scope);
166
+ if (parsed.single) {
167
+ if (!kindMatches(parsed.single.kind, actual, scope)) return false;
168
+ if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
169
+ return true;
170
+ }
171
+ if (typeof actual !== "string" && typeof actual !== "number") return false;
172
+ const text = String(actual);
173
+ const found = parsed.pattern.exec(text);
174
+ if (!found) return false;
175
+ for (const entry of parsed.refs) {
176
+ if (!entry.ref) continue;
177
+ if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
178
+ }
179
+ return true;
180
+ }
181
+ function isPlainObject(value) {
182
+ return value !== null && typeof value === "object" && !Array.isArray(value);
183
+ }
184
+ /**
185
+ * Deep structural equality with matcher / placeholder support. Ref captures
186
+ * are recorded in `scope` in traversal order (arrays left-to-right, object
187
+ * keys in expected-key order).
188
+ */
189
+ function structuralEquals(expected, actual, scope) {
190
+ if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
191
+ if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
192
+ if (Array.isArray(expected)) {
193
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
194
+ return expected.every((item, i) => structuralEquals(item, actual[i], scope));
195
+ }
196
+ if (isPlainObject(expected)) {
197
+ if (!isPlainObject(actual)) return false;
198
+ const expectedKeys = Object.keys(expected);
199
+ const actualKeys = Object.keys(actual);
200
+ if (expectedKeys.length !== actualKeys.length) return false;
201
+ return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
202
+ }
203
+ return Object.is(expected, actual);
204
+ }
205
+ /**
206
+ * Deep structural SUBSET match (toMatchObject-style) with matcher /
207
+ * placeholder support. Plain objects match when every expected key is present
208
+ * and recursively subset-matches — the actual value may carry extra keys.
209
+ * Arrays require equal length with each element subset-matched. Leaves fall
210
+ * back to {@link structuralEquals} semantics (matchers, placeholders, strict
211
+ * equality). Used by request-body filters on intercept triggers.
212
+ */
213
+ function structuralSubset(expected, actual, scope) {
214
+ if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
215
+ if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
216
+ if (Array.isArray(expected)) {
217
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
218
+ return expected.every((item, i) => structuralSubset(item, actual[i], scope));
219
+ }
220
+ if (isPlainObject(expected)) {
221
+ if (!isPlainObject(actual)) return false;
222
+ return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
223
+ }
224
+ return Object.is(expected, actual);
225
+ }
226
+ /**
227
+ * Multi-line text comparison with `{{token}}` support — used by text
228
+ * snapshots (`expected/*.txt`). Without placeholders this is strict equality.
229
+ */
230
+ function textEquals(expected, actual, scope) {
231
+ if (!hasPlaceholders(expected)) return expected === actual;
232
+ return placeholderStringMatches(expected, actual, scope);
233
+ }
234
+ /**
235
+ * Render an expected value for failure diffs and serialization: Matcher
236
+ * instances become their placeholder text, everything else is untouched.
237
+ */
238
+ function renderExpected(value) {
239
+ if (value instanceof Matcher) return value.toString();
240
+ if (Array.isArray(value)) return value.map((item) => renderExpected(item));
241
+ if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
242
+ return value;
243
+ }
244
+ /**
245
+ * Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
246
+ * every string leaf of a structured value. The structural mirror of the text
247
+ * path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
248
+ * `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
249
+ * D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
250
+ */
251
+ function substituteWorkdirDeep(value, workdir) {
252
+ if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
253
+ if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
254
+ if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
255
+ return value;
256
+ }
257
+ /**
258
+ * Update-mode merge: rewrite a fixture from the actual output while
259
+ * preserving every segment of the previous fixture that was covered by a
260
+ * placeholder (and still matches the actual value).
261
+ *
262
+ * Token preservation is symmetric with the text path (CONVENTIONS D5): a
263
+ * previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
264
+ * RAW actual value is kept; every other leaf is taken from the actual output
265
+ * with the framework-known cwd substituted back to `{{workdir}}`.
266
+ */
267
+ function mergePreservingPlaceholders(previous, actual, workdir) {
268
+ const scope = new CaptureScope(workdir);
269
+ const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
270
+ function merge(prev, act) {
271
+ if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
272
+ if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
273
+ if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
274
+ return subst(act);
275
+ }
276
+ return merge(previous, actual);
277
+ }
278
+ /**
279
+ * Update-mode merge for text snapshots: line-by-line, a previous line whose
280
+ * placeholders still match the actual line is preserved; every other line is
281
+ * taken from the actual output. Values the framework knows to be dynamic
282
+ * (`{{workdir}}`) are substituted automatically (CONVENTIONS D5).
283
+ */
284
+ function mergeTextPreservingPlaceholders(previous, actual, scope) {
285
+ const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
286
+ if (previous === null) return substituted;
287
+ const prevLines = previous.split("\n");
288
+ return substituted.split("\n").map((line, i) => {
289
+ const prev = prevLines[i];
290
+ if (prev === void 0 || !hasPlaceholders(prev)) return line;
291
+ return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, actual.split("\n")[i] ?? line, new CaptureScope(scope.workdir)) ? prev : line;
292
+ }).join("\n");
293
+ }
294
+ //#endregion
295
+ //#region src/core/contracts/contract.ts
296
+ /**
297
+ * Declare one contract. Identity function — its value is the enforced shape
298
+ * and the naming convention:
299
+ *
300
+ * @example
301
+ * // specs/api/reports/contracts/openai/classify-article.ts
302
+ * import { defineContract, openai } from '@jterrazz/test';
303
+ *
304
+ * export default defineContract({
305
+ * request: openai.responses({ user: PROMPT, tools: ['classify'] }),
306
+ * response: openai.reply({ categories: ['TECH'] }),
307
+ * });
308
+ *
309
+ * // Dynamic — the response is computed from the observed request:
310
+ * export default defineContract({
311
+ * request: http.post('https://api.example.com/echo'),
312
+ * response: (request) => http.json({ received: request.body }),
313
+ * });
314
+ */
315
+ function defineContract(contract) {
316
+ return contract;
317
+ }
318
+ /** Is this a {@link Contracts} composite (rather than a contract or a list)? */
319
+ function isContracts(value) {
320
+ return typeof value === "object" && value !== null && Array.isArray(value.contracts) && typeof value.with === "function";
321
+ }
322
+ /** Is this a single {@link Contract} (rather than a bare request half)? */
323
+ function isContract(value) {
324
+ return typeof value === "object" && value !== null && "request" in value && "response" in value;
325
+ }
326
+ /**
327
+ * The route a contract claims — method + the DECLARED url source. Two
328
+ * contracts share a route when they would be written on the same line of a
329
+ * declaration: same method, same url pattern (`.source` for a RegExp).
330
+ */
331
+ function routeKeyOf(contract) {
332
+ const { method, url } = contract.request;
333
+ return url instanceof RegExp ? `${method} re:${url.source}` : `${method} str:${url}`;
334
+ }
335
+ /** Human-readable route of a contract — used in every failure message. */
336
+ function describeRoute(contract) {
337
+ const { method, url } = contract.request;
338
+ return `${method === "*" ? "ANY" : method} ${url instanceof RegExp ? String(url) : url}`;
339
+ }
340
+ /** Flatten contracts, lists, and composites into one ordered list. */
341
+ function flatten(items) {
342
+ const flat = [];
343
+ for (const item of items) if (Array.isArray(item)) flat.push(...flatten(item));
344
+ else if (isContracts(item)) flat.push(...item.contracts);
345
+ else flat.push(item);
346
+ return flat;
347
+ }
348
+ /**
349
+ * Compose contracts into the artifact a test imports — it's contracts all the
350
+ * way down: a composite may extend contracts, lists, and other composites,
351
+ * recursively, order preserved.
352
+ *
353
+ * `.with(...)` derives a scenario: contracts whose route the overrides claim
354
+ * are removed from the base, and the overrides are prepended. Under
355
+ * first-match selection a more specific override (`/articles/gone-1`) also
356
+ * wins over a generic base route (`/articles/{{uuid}}`) it does not replace.
357
+ *
358
+ * @example
359
+ * // contracts/newsroom.contracts.ts
360
+ * export default defineContracts(events, articleById);
361
+ * export const withArticleGone = (id: string) =>
362
+ * newsroom.with(articleGone(id));
363
+ */
364
+ function defineContracts(...items) {
365
+ const contracts = Object.freeze(flatten(items));
366
+ return {
367
+ contracts,
368
+ with(...overrides) {
369
+ const added = flatten(overrides);
370
+ const claimed = new Set(added.map(routeKeyOf));
371
+ const kept = contracts.filter((contract) => !claimed.has(routeKeyOf(contract)));
372
+ return defineContracts([...added, ...kept]);
373
+ }
374
+ };
375
+ }
376
+ /** Normalize any accepted input into a flat contract list. */
377
+ function contractsOf(input) {
378
+ return flatten([input]);
379
+ }
380
+ //#endregion
381
+ //#region src/core/contracts/queue.ts
382
+ /**
383
+ * The ONE contract queue — pure, engine-agnostic (no MSW, no node:http).
384
+ * Both engines consume it: the MSW integration on api/jobs chains, and the
385
+ * declared stub backend on website/mobile chains.
386
+ *
387
+ * Selection: the FIRST contract in declaration order that matches the observed
388
+ * request and is not exhausted. A contract with no `times` is unlimited (a
389
+ * re-render or a retry replays it); `times: n` is spent after n serves, so an
390
+ * ordered sequence is written as finite contracts before an unlimited tail:
391
+ * `[error500 ×3, ok]`.
392
+ *
393
+ * Strictness (CONVENTIONS D7): when nothing matches — including "everything
394
+ * that matches is exhausted" — the engine answers 501, records the request,
395
+ * and the chain fails with {@link ContractQueue.unmatchedError}. A chain that
396
+ * declared zero contracts is not guarded at all (unchanged boundary).
397
+ */
398
+ /** Base used to parse origin-relative observed URLs (`/events?x=1`). */
399
+ const RELATIVE_BASE = "http://contract.invalid";
400
+ /** Split a declared url into its base (origin+path, or path) and query halves.
401
+ * Never through `new URL()`, which would percent-encode `{{token}}` braces. */
402
+ function splitDeclared(url) {
403
+ const separator = url.indexOf("?");
404
+ if (separator === -1) return {
405
+ base: url,
406
+ query: new URLSearchParams()
407
+ };
408
+ return {
409
+ base: url.slice(0, separator),
410
+ query: new URLSearchParams(url.slice(separator + 1))
411
+ };
412
+ }
413
+ /** Decode percent-escapes, falling back to the raw text on malformed input. */
414
+ function safeDecode(text) {
415
+ try {
416
+ return decodeURIComponent(text);
417
+ } catch {
418
+ return text;
419
+ }
420
+ }
421
+ /** Token-aware comparison of one declared value against an observed one. */
422
+ function valueMatches(declared, observed) {
423
+ return textEquals(declared, observed, new CaptureScope());
424
+ }
425
+ /**
426
+ * Does an observed URL satisfy a declared one? A RegExp tests the full URL; a
427
+ * PATH FORM (`/articles/{{uuid}}`) ignores the origin; an absolute string
428
+ * compares origin+pathname. In both string forms `{{token}}` segments compare
429
+ * structurally and declared query params are a SUBSET of the observed ones.
430
+ */
431
+ function urlMatches(declared, observed) {
432
+ if (declared instanceof RegExp) return declared.test(observed);
433
+ let url;
434
+ try {
435
+ url = new URL(observed, RELATIVE_BASE);
436
+ } catch {
437
+ return false;
438
+ }
439
+ const { base, query } = splitDeclared(declared);
440
+ const observedBase = base.startsWith("/") ? url.pathname : `${url.origin}${url.pathname}`;
441
+ const decodedBase = base.startsWith("/") ? safeDecode(url.pathname) : `${url.origin}${safeDecode(url.pathname)}`;
442
+ if (!valueMatches(base, observedBase) && !valueMatches(base, decodedBase)) return false;
443
+ for (const key of new Set(query.keys())) {
444
+ const observedValues = url.searchParams.getAll(key);
445
+ if (!query.getAll(key).every((value) => observedValues.some((actual) => valueMatches(value, actual)))) return false;
446
+ }
447
+ return true;
448
+ }
449
+ /**
450
+ * Does an observed request satisfy a contract? Method (`*` matches any), URL,
451
+ * then the contract's own `match` predicate (headers/query/body filters).
452
+ */
453
+ function contractMatches(contract, request) {
454
+ const { match, method, url } = contract.request;
455
+ if (method !== "*" && method.toUpperCase() !== request.method.toUpperCase()) return false;
456
+ if (!urlMatches(url, request.url)) return false;
457
+ return match ? match(request) : true;
458
+ }
459
+ /**
460
+ * The URL pattern an out-of-process router (MSW) must register so the request
461
+ * REACHES the queue. Deliberately no narrower than {@link contractMatches} —
462
+ * the predicate above stays the authority on what actually matches.
463
+ */
464
+ function routePatternOf(declared) {
465
+ if (declared instanceof RegExp) return declared;
466
+ const { base } = splitDeclared(declared);
467
+ if (base.startsWith("/")) return new RegExp(String.raw`^https?:\/\/[^/?#]+${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`);
468
+ if (hasPlaceholders(base)) return new RegExp(String.raw`^${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`);
469
+ return base;
470
+ }
471
+ var ContractQueue = class {
472
+ contracts;
473
+ served;
474
+ constructor(contracts) {
475
+ this.contracts = contracts;
476
+ this.served = contracts.map(() => 0);
477
+ }
478
+ /** How many contracts the chain declared. */
479
+ get size() {
480
+ return this.contracts.length;
481
+ }
482
+ /**
483
+ * Routes to register with an out-of-process router, deduped by pattern —
484
+ * two contracts on the same URL share one handler and one queue.
485
+ */
486
+ get routes() {
487
+ const routes = /* @__PURE__ */ new Map();
488
+ for (const contract of this.contracts) {
489
+ const url = routePatternOf(contract.request.url);
490
+ const key = String(url);
491
+ const route = routes.get(key) ?? {
492
+ methods: [],
493
+ url
494
+ };
495
+ if (!route.methods.includes(contract.request.method)) route.methods.push(contract.request.method);
496
+ routes.set(key, route);
497
+ }
498
+ return [...routes.values()];
499
+ }
500
+ /** The declared routes, in order — the enumeration a 501 body carries. */
501
+ declaredRoutes() {
502
+ return this.contracts.map((contract) => describeRoute(contract));
503
+ }
504
+ /**
505
+ * Serve the first contract in declaration order that matches and is not
506
+ * exhausted, or null when nothing matches (including "everything that
507
+ * matches is spent") — the strict-violation signal.
508
+ */
509
+ take(request) {
510
+ for (let i = 0; i < this.contracts.length; i++) {
511
+ const contract = this.contracts[i];
512
+ const { times } = contract;
513
+ if (times !== void 0 && this.served[i] >= times) continue;
514
+ if (!contractMatches(contract, request)) continue;
515
+ this.served[i] += 1;
516
+ return contract;
517
+ }
518
+ return null;
519
+ }
520
+ /**
521
+ * The chain-end failure for contracts declared `required: true` that were
522
+ * never requested (or not exactly `times` times), or null when every
523
+ * requirement held.
524
+ */
525
+ requiredError() {
526
+ const unmet = [];
527
+ for (const [i, contract] of this.contracts.entries()) {
528
+ if (!contract.required) continue;
529
+ const served = this.served[i];
530
+ const { times } = contract;
531
+ if (times === void 0) {
532
+ if (served === 0) unmet.push(` - ${describeRoute(contract)} — declared and required but never requested`);
533
+ } else if (served !== times) unmet.push(` - ${describeRoute(contract)} — declared required with times: ${times} but was requested ${served} time(s)`);
534
+ }
535
+ if (unmet.length === 0) return null;
536
+ return /* @__PURE__ */ new Error(`Required contract(s) never satisfied during the chain:\n${unmet.join("\n")}\nA required contract states the call MUST happen — either the code no longer makes it, or the contract no longer describes it.`);
537
+ }
538
+ /**
539
+ * The strict failure for a request that matched no contract (CONVENTIONS
540
+ * D7): method + URL of the offending request, plus every declared contract
541
+ * and how often it was served.
542
+ */
543
+ unmatchedError(method, url) {
544
+ const declared = this.contracts.length === 0 ? " (no contracts declared)" : this.contracts.map((contract, i) => ` - ${describeRoute(contract)}${this.stateOf(i)}`).join("\n");
545
+ return /* @__PURE__ */ new Error(`Unmatched outgoing HTTP request during spec: ${method} ${url}\nDeclared contracts:\n${declared}\nEvery outgoing request of a chain that declares contracts must match one — add a contract for it (or raise its \`times\` when it is exhausted).`);
546
+ }
547
+ stateOf(index) {
548
+ const served = this.served[index];
549
+ const { times } = this.contracts[index];
550
+ if (times !== void 0 && served >= times) return ` (exhausted after ${times})`;
551
+ return served === 0 ? "" : ` (served ${served} time(s))`;
552
+ }
553
+ };
554
+ //#endregion
555
+ export { isContract as a, mergeTextPreservingPlaceholders as c, structuralSubset as d, textEquals as f, defineContracts as i, renderExpected as l, contractsOf as n, isContracts as o, defineContract as r, mergePreservingPlaceholders as s, ContractQueue as t, structuralEquals as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "10.1.0",
3
+ "version": "11.0.0",
4
4
  "description": "Declarative testing framework for HTTP APIs, CLIs, and background jobs — one entry point, real infrastructure, golden-file assertions, plus an oxlint convention plugin.",
5
5
  "keywords": [
6
6
  "api-testing",