@nifrajs/testing 1.6.0 → 1.9.1

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.
@@ -0,0 +1,763 @@
1
+ /**
2
+ * Contract-derived adversarial tests.
3
+ *
4
+ * A contract witness is a request known to satisfy a route's input schemas. From that witness this
5
+ * module derives small hostile mutations, proves each mutation invalid with the route's own Standard
6
+ * Schema validator, and then drives it through one or more real fetch runtimes. Declared success
7
+ * responses are validated off the hot path as part of the same laboratory run.
8
+ */
9
+ import { reflectRoutes, } from "@nifrajs/core/reflection";
10
+ import { generateMockValue } from "@nifrajs/mock";
11
+ const DEFAULT_ORIGIN = "http://nifra.contract";
12
+ const DEFAULT_SEED = 0x4e_49_46_52;
13
+ const DEFAULT_MAX_MUTATIONS = 32;
14
+ const DEFAULT_MAX_SHRINK_ATTEMPTS = 12;
15
+ const DEFAULT_MAX_WITNESS_BYTES = 256 * 1024;
16
+ const NO_BODY_METHODS = new Set(["GET", "HEAD"]);
17
+ export class AdversarialContractError extends Error {
18
+ report;
19
+ constructor(report) {
20
+ const details = [
21
+ ...report.failures
22
+ .slice(0, 5)
23
+ .map((failure) => `${failure.runtime}: ${failure.id} — ${failure.message} (replay seed ${report.seed})`),
24
+ ...report.gaps
25
+ .slice(0, 5)
26
+ .map((gap) => `${gap.route ?? "contract"}${gap.target ? ` ${gap.target}` : ""}: ${gap.message}`),
27
+ ];
28
+ super(`Adversarial contract failed: ${report.failures.length} failure(s), ${report.gaps.length} coverage gap(s)${details.length > 0 ? `\n${details.join("\n")}` : ""}`);
29
+ this.report = report;
30
+ this.name = "AdversarialContractError";
31
+ }
32
+ }
33
+ const recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
34
+ ? value
35
+ : undefined;
36
+ const own = (value, key) => Object.hasOwn(value, key);
37
+ const routeKeyOf = (route) => `${route.method.toUpperCase()} ${route.path}`;
38
+ const normalizeSeed = (seed) => Number.isFinite(seed) ? Math.trunc(seed) >>> 0 : DEFAULT_SEED;
39
+ const positiveInteger = (value, fallback) => Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
40
+ const seededRandom = (seed) => {
41
+ let state = seed >>> 0;
42
+ return () => {
43
+ state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
44
+ return state / 0x1_0000_0000;
45
+ };
46
+ };
47
+ const hash = (value) => {
48
+ let out = 2_166_136_261;
49
+ for (let index = 0; index < value.length; index += 1) {
50
+ out ^= value.charCodeAt(index);
51
+ out = Math.imul(out, 16_777_619);
52
+ }
53
+ return out >>> 0;
54
+ };
55
+ const jsonClone = (value) => {
56
+ const serialized = JSON.stringify(value);
57
+ if (serialized === undefined)
58
+ throw new TypeError("value is not JSON serializable");
59
+ return JSON.parse(serialized);
60
+ };
61
+ const valueSize = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
62
+ const displayPath = (target, path) => path.reduce((out, segment) => (typeof segment === "number" ? `${out}[${segment}]` : `${out}.${segment}`), target);
63
+ const mutationId = (routeKey, target, mutation) => `${routeKey} :: ${displayPath(target, mutation.path)} :: ${mutation.reason}`;
64
+ const responseId = (routeKey) => `${routeKey} :: response-conformance`;
65
+ const typeKind = (value) => {
66
+ if (value === null)
67
+ return "null";
68
+ if (Array.isArray(value))
69
+ return "array";
70
+ return typeof value === "object" ? "object" : typeof value;
71
+ };
72
+ const replacementValues = (value) => {
73
+ const current = typeKind(value);
74
+ const values = [null, "", 0, false, [], {}];
75
+ return values.filter((candidate) => typeKind(candidate) !== current);
76
+ };
77
+ const replaceAt = (value, path, replacement, remove = false) => {
78
+ if (path.length === 0)
79
+ return replacement;
80
+ const [head, ...tail] = path;
81
+ if (Array.isArray(value)) {
82
+ const clone = value.slice();
83
+ if (typeof head !== "number")
84
+ return value;
85
+ if (tail.length === 0 && remove)
86
+ clone.splice(head, 1);
87
+ else
88
+ clone[head] = replaceAt(clone[head], tail, replacement, remove);
89
+ return clone;
90
+ }
91
+ const record = recordOf(value);
92
+ if (record === undefined || typeof head !== "string")
93
+ return value;
94
+ const clone = Object.create(null);
95
+ for (const [key, item] of Object.entries(record))
96
+ clone[key] = item;
97
+ if (tail.length === 0 && remove)
98
+ delete clone[head];
99
+ else
100
+ clone[head] = replaceAt(clone[head], tail, replacement, remove);
101
+ return clone;
102
+ };
103
+ const schemaRecord = (schema) => schema === false || schema === true ? undefined : schema;
104
+ function candidateMutations(root, schema, maxCandidates) {
105
+ const candidates = [];
106
+ const add = (path, reason, value) => {
107
+ if (candidates.length < maxCandidates * 6)
108
+ candidates.push({ path, reason, value });
109
+ };
110
+ const visit = (current, currentSchema, path, depth) => {
111
+ if (depth > 8 || candidates.length >= maxCandidates * 6)
112
+ return;
113
+ for (const replacement of replacementValues(current)) {
114
+ add(path, `wrong-type-${typeKind(replacement)}`, replaceAt(root, path, replacement));
115
+ }
116
+ const raw = schemaRecord(currentSchema);
117
+ if (raw !== undefined) {
118
+ if (own(raw, "const")) {
119
+ add(path, "const", replaceAt(root, path, "__nifra_not_const__"));
120
+ }
121
+ if (Array.isArray(raw.enum)) {
122
+ add(path, "enum", replaceAt(root, path, "__nifra_not_enum__"));
123
+ }
124
+ if (typeof current === "string") {
125
+ if (typeof raw.minLength === "number" && raw.minLength > 0 && raw.minLength <= 4096) {
126
+ add(path, "below-minLength", replaceAt(root, path, "a".repeat(Math.max(0, raw.minLength - 1))));
127
+ }
128
+ if (typeof raw.maxLength === "number" && raw.maxLength >= 0 && raw.maxLength < 4096) {
129
+ add(path, "above-maxLength", replaceAt(root, path, "a".repeat(raw.maxLength + 1)));
130
+ }
131
+ if (typeof raw.pattern === "string")
132
+ add(path, "pattern", replaceAt(root, path, ""));
133
+ if (typeof raw.format === "string") {
134
+ add(path, `format-${raw.format}`, replaceAt(root, path, "not-a-valid-format"));
135
+ }
136
+ }
137
+ if (typeof current === "number") {
138
+ if (typeof raw.minimum === "number") {
139
+ add(path, "below-minimum", replaceAt(root, path, raw.minimum - 1));
140
+ }
141
+ if (typeof raw.maximum === "number") {
142
+ add(path, "above-maximum", replaceAt(root, path, raw.maximum + 1));
143
+ }
144
+ if (typeof raw.exclusiveMinimum === "number") {
145
+ add(path, "exclusive-minimum", replaceAt(root, path, raw.exclusiveMinimum));
146
+ }
147
+ if (typeof raw.exclusiveMaximum === "number") {
148
+ add(path, "exclusive-maximum", replaceAt(root, path, raw.exclusiveMaximum));
149
+ }
150
+ if (raw.type === "integer")
151
+ add(path, "non-integer", replaceAt(root, path, current + 0.5));
152
+ }
153
+ if (Array.isArray(current)) {
154
+ if (typeof raw.minItems === "number" && raw.minItems > 0) {
155
+ add(path, "below-minItems", replaceAt(root, path, current.slice(0, raw.minItems - 1)));
156
+ }
157
+ if (typeof raw.maxItems === "number" && raw.maxItems < 128) {
158
+ const expanded = current.slice();
159
+ const item = current[0] ?? null;
160
+ while (expanded.length <= raw.maxItems)
161
+ expanded.push(item);
162
+ add(path, "above-maxItems", replaceAt(root, path, expanded));
163
+ }
164
+ if (raw.uniqueItems === true && current.length > 0) {
165
+ add(path, "duplicate-item", replaceAt(root, path, [current[0], current[0]]));
166
+ }
167
+ }
168
+ }
169
+ const currentRecord = recordOf(current);
170
+ if (currentRecord !== undefined) {
171
+ const properties = recordOf(raw?.properties);
172
+ const required = Array.isArray(raw?.required)
173
+ ? raw.required.filter((key) => typeof key === "string")
174
+ : [];
175
+ for (const key of required) {
176
+ if (own(currentRecord, key)) {
177
+ add([...path, key], "required-property", replaceAt(root, [...path, key], undefined, true));
178
+ }
179
+ }
180
+ add([...path, "__nifra_unexpected"], "additional-property", replaceAt(root, [...path, "__nifra_unexpected"], true));
181
+ for (const [key, item] of Object.entries(currentRecord)) {
182
+ if (!required.includes(key)) {
183
+ add([...path, key], "missing-property", replaceAt(root, [...path, key], undefined, true));
184
+ }
185
+ const propertySchema = properties?.[key];
186
+ visit(item, typeof propertySchema === "boolean" || recordOf(propertySchema) !== undefined
187
+ ? propertySchema
188
+ : undefined, [...path, key], depth + 1);
189
+ }
190
+ }
191
+ else if (Array.isArray(current) && current.length > 0) {
192
+ add(path, "missing-array-item", replaceAt(root, path, current.slice(0, -1)));
193
+ const itemSchema = raw?.items;
194
+ visit(current[0], typeof itemSchema === "boolean" || recordOf(itemSchema) !== undefined
195
+ ? itemSchema
196
+ : undefined, [...path, 0], depth + 1);
197
+ }
198
+ };
199
+ visit(root, schema, [], 0);
200
+ return candidates;
201
+ }
202
+ async function rejectedByValidator(reflection, value) {
203
+ const validator = reflection.standard;
204
+ if (validator === undefined)
205
+ return false;
206
+ const result = await validator["~standard"].validate(value);
207
+ return result.issues !== undefined;
208
+ }
209
+ async function provenMutations(input, target, maxMutations) {
210
+ const candidates = candidateMutations(input.value, input.reflection.jsonSchema, maxMutations);
211
+ const mutations = [];
212
+ const seen = new Set();
213
+ for (const candidate of candidates) {
214
+ let serialized;
215
+ let value;
216
+ try {
217
+ value = target === "query" ? queryInput(candidate.value) : jsonClone(candidate.value);
218
+ if (value === undefined)
219
+ continue;
220
+ serialized = JSON.stringify(value);
221
+ }
222
+ catch {
223
+ continue;
224
+ }
225
+ if (seen.has(serialized))
226
+ continue;
227
+ seen.add(serialized);
228
+ try {
229
+ if (await rejectedByValidator(input.reflection, value))
230
+ mutations.push({ ...candidate, value });
231
+ }
232
+ catch {
233
+ // A validator that throws for one hostile value violates Standard Schema, but do not let it
234
+ // abort discovery of other mutations. If none are usable the route gets a fail-closed gap.
235
+ }
236
+ if (mutations.length >= maxMutations)
237
+ break;
238
+ }
239
+ return mutations;
240
+ }
241
+ const queryPairs = (value) => {
242
+ const record = recordOf(value);
243
+ if (record === undefined)
244
+ return undefined;
245
+ const pairs = [];
246
+ for (const [key, item] of Object.entries(record)) {
247
+ if (item === undefined)
248
+ continue;
249
+ const values = Array.isArray(item) ? item : [item];
250
+ for (const entry of values) {
251
+ const encoded = entry !== null && typeof entry === "object" ? JSON.stringify(entry) : String(entry);
252
+ pairs.push([key, encoded]);
253
+ }
254
+ }
255
+ return pairs;
256
+ };
257
+ const queryInput = (value) => {
258
+ const pairs = queryPairs(value);
259
+ if (pairs === undefined)
260
+ return undefined;
261
+ const result = Object.create(null);
262
+ for (const [key, value] of pairs) {
263
+ const previous = result[key];
264
+ if (previous === undefined)
265
+ result[key] = value;
266
+ else if (Array.isArray(previous))
267
+ previous.push(value);
268
+ else
269
+ result[key] = [previous, value];
270
+ }
271
+ return result;
272
+ };
273
+ async function resolveInput(routeKey, target, reflection, witness, seed, maxWitnessBytes) {
274
+ if (reflection.standard === undefined) {
275
+ return {
276
+ ok: false,
277
+ gap: {
278
+ route: routeKey,
279
+ target,
280
+ code: "NO_VALIDATOR",
281
+ message: `${target} metadata has no Standard Schema validator`,
282
+ },
283
+ };
284
+ }
285
+ let value;
286
+ if (own(witness, target)) {
287
+ value = witness[target];
288
+ }
289
+ else if (reflection.jsonSchema !== undefined) {
290
+ try {
291
+ value = generateMockValue(reflection.jsonSchema, target, seededRandom(seed ^ hash(`${routeKey}:${target}`)));
292
+ }
293
+ catch (error) {
294
+ return {
295
+ ok: false,
296
+ gap: {
297
+ route: routeKey,
298
+ target,
299
+ code: "NO_WITNESS",
300
+ message: `could not synthesize a ${target} witness: ${error instanceof Error ? error.message : String(error)}`,
301
+ },
302
+ };
303
+ }
304
+ }
305
+ else {
306
+ return {
307
+ ok: false,
308
+ gap: {
309
+ route: routeKey,
310
+ target,
311
+ code: "NO_WITNESS",
312
+ message: `opaque ${target} schema needs witnesses[${JSON.stringify(routeKey)}].${target}`,
313
+ },
314
+ };
315
+ }
316
+ try {
317
+ value = target === "query" ? queryInput(value) : jsonClone(value);
318
+ if (value === undefined)
319
+ throw new TypeError(`${target} witness is not transport-serializable`);
320
+ if (valueSize(value) > maxWitnessBytes) {
321
+ return {
322
+ ok: false,
323
+ gap: {
324
+ route: routeKey,
325
+ target,
326
+ code: "WITNESS_TOO_LARGE",
327
+ message: `${target} witness exceeds maxWitnessBytes (${maxWitnessBytes})`,
328
+ },
329
+ };
330
+ }
331
+ if (await rejectedByValidator(reflection, value)) {
332
+ return {
333
+ ok: false,
334
+ gap: {
335
+ route: routeKey,
336
+ target,
337
+ code: "INVALID_WITNESS",
338
+ message: `${target} witness is rejected by its Standard Schema validator`,
339
+ },
340
+ };
341
+ }
342
+ return { ok: true, input: { value, reflection } };
343
+ }
344
+ catch (error) {
345
+ return {
346
+ ok: false,
347
+ gap: {
348
+ route: routeKey,
349
+ target,
350
+ code: "INVALID_WITNESS",
351
+ message: `could not validate ${target} witness: ${error instanceof Error ? error.message : String(error)}`,
352
+ },
353
+ };
354
+ }
355
+ }
356
+ const materializePath = (path, params) => path
357
+ .split("/")
358
+ .map((segment) => {
359
+ if (segment.startsWith(":")) {
360
+ const name = segment.slice(1);
361
+ return encodeURIComponent(params?.[name] ?? `${name || "param"}-contract`);
362
+ }
363
+ if (segment.startsWith("*")) {
364
+ const name = segment.slice(1);
365
+ const value = params?.[name] ?? `${name || "path"}/contract`;
366
+ return value.split("/").filter(Boolean).map(encodeURIComponent).join("/");
367
+ }
368
+ return segment;
369
+ })
370
+ .join("/");
371
+ function requestFor(laboratory, origin, body, query) {
372
+ const path = materializePath(laboratory.route.path, laboratory.witness.params);
373
+ const url = new URL(path, origin);
374
+ for (const [key, value] of queryPairs(query) ?? [])
375
+ url.searchParams.append(key, value);
376
+ const headers = new Headers(laboratory.witness.headers);
377
+ const init = { method: laboratory.route.method, headers };
378
+ if (laboratory.route.schema?.body !== undefined &&
379
+ !NO_BODY_METHODS.has(laboratory.route.method)) {
380
+ if (!headers.has("content-type"))
381
+ headers.set("content-type", "application/json");
382
+ init.body = JSON.stringify(body);
383
+ }
384
+ return new Request(url.toString(), init);
385
+ }
386
+ const onlySet = (only) => only === undefined ? undefined : new Set(typeof only === "string" ? [only] : only);
387
+ const simplerValues = (value) => {
388
+ if (typeof value === "string")
389
+ return value.length === 0 ? [] : ["", value.slice(0, value.length / 2)];
390
+ if (typeof value === "number")
391
+ return value === 0 ? [] : [0, Math.trunc(value / 2)];
392
+ if (Array.isArray(value))
393
+ return value.length === 0 ? [] : [[], value.slice(0, value.length / 2)];
394
+ const record = recordOf(value);
395
+ if (record !== undefined) {
396
+ const entries = Object.entries(record);
397
+ if (entries.length === 0)
398
+ return [];
399
+ return [
400
+ {},
401
+ ...entries.map(([removed]) => {
402
+ const clone = Object.create(null);
403
+ for (const [key, item] of entries)
404
+ if (key !== removed)
405
+ clone[key] = item;
406
+ return clone;
407
+ }),
408
+ ];
409
+ }
410
+ return value === null || value === false ? [] : [null, false];
411
+ };
412
+ async function preparedRequest(request, context, prepare) {
413
+ return prepare === undefined ? request : await prepare(request, context);
414
+ }
415
+ async function discardResponse(response) {
416
+ if (response.body === null || response.bodyUsed)
417
+ return;
418
+ try {
419
+ await response.body.cancel();
420
+ }
421
+ catch {
422
+ // A custom rejection predicate may have locked the stream. Disposal is best-effort only.
423
+ }
424
+ }
425
+ async function executeRejection(runtime, laboratory, target, mutation, id, options, seed, origin, statuses) {
426
+ const context = {
427
+ seed,
428
+ caseId: id,
429
+ route: laboratory.route,
430
+ runtime: runtime.name,
431
+ kind: "input-rejection",
432
+ target,
433
+ mutation: `${displayPath(target, mutation.path)} ${mutation.reason}`,
434
+ };
435
+ try {
436
+ const body = target === "body" ? mutation.value : laboratory.body?.value;
437
+ const query = target === "query" ? mutation.value : laboratory.query?.value;
438
+ const request = await preparedRequest(requestFor(laboratory, origin, body, query), context, options.prepareRequest);
439
+ const response = await runtime.fetch(request);
440
+ const rejected = options.isRejected === undefined
441
+ ? statuses.has(response.status)
442
+ : await options.isRejected(response, context);
443
+ await discardResponse(response);
444
+ return { rejected, status: response.status };
445
+ }
446
+ catch (error) {
447
+ return { rejected: false, error: error instanceof Error ? error.message : String(error) };
448
+ }
449
+ }
450
+ async function shrinkFailure(runtime, laboratory, target, mutation, id, options, seed, origin, statuses, maxAttempts) {
451
+ let current = mutation;
452
+ let steps = 0;
453
+ let attempts = 0;
454
+ let status;
455
+ while (attempts < maxAttempts) {
456
+ let improved = false;
457
+ for (const value of simplerValues(current.value)) {
458
+ attempts += 1;
459
+ if (attempts > maxAttempts)
460
+ break;
461
+ const reflection = target === "body" ? laboratory.body?.reflection : laboratory.query?.reflection;
462
+ const transported = target === "query" ? queryInput(value) : jsonClone(value);
463
+ if (transported === undefined)
464
+ continue;
465
+ if (reflection === undefined)
466
+ continue;
467
+ try {
468
+ if (!(await rejectedByValidator(reflection, transported)))
469
+ continue;
470
+ }
471
+ catch {
472
+ continue;
473
+ }
474
+ const candidate = { ...current, value: transported };
475
+ const result = await executeRejection(runtime, laboratory, target, candidate, id, options, seed, origin, statuses);
476
+ status = result.status;
477
+ if (!result.rejected && result.error === undefined) {
478
+ current = candidate;
479
+ steps += 1;
480
+ improved = true;
481
+ break;
482
+ }
483
+ }
484
+ if (!improved)
485
+ break;
486
+ }
487
+ return { mutation: current, steps, ...(status === undefined ? {} : { status }) };
488
+ }
489
+ const resultForFailure = (base, seed, message) => ({
490
+ ...base,
491
+ ok: false,
492
+ message,
493
+ replay: { seed, caseId: base.id, runtime: base.runtime },
494
+ });
495
+ async function buildLaboratories(routes, options, seed, gaps) {
496
+ const laboratories = [];
497
+ const maxMutations = positiveInteger(options.maxMutationsPerInput, DEFAULT_MAX_MUTATIONS);
498
+ const maxWitnessBytes = positiveInteger(options.maxWitnessBytes, DEFAULT_MAX_WITNESS_BYTES);
499
+ let targetCount = 0;
500
+ for (const route of routes) {
501
+ const routeKey = routeKeyOf(route);
502
+ const witness = options.witnesses?.[routeKey] ?? {};
503
+ let body;
504
+ let query;
505
+ if (route.schema?.body !== undefined) {
506
+ targetCount += 1;
507
+ if (NO_BODY_METHODS.has(route.method)) {
508
+ gaps.push({
509
+ route: routeKey,
510
+ target: "body",
511
+ code: "UNSUPPORTED_BODY_METHOD",
512
+ message: `${route.method} cannot carry a request body`,
513
+ });
514
+ }
515
+ else {
516
+ const resolved = await resolveInput(routeKey, "body", route.schema.body, witness, seed, maxWitnessBytes);
517
+ if (resolved.ok)
518
+ body = resolved.input;
519
+ else
520
+ gaps.push(resolved.gap);
521
+ }
522
+ }
523
+ if (route.schema?.query !== undefined) {
524
+ targetCount += 1;
525
+ const resolved = await resolveInput(routeKey, "query", route.schema.query, witness, seed, maxWitnessBytes);
526
+ if (resolved.ok)
527
+ query = resolved.input;
528
+ else
529
+ gaps.push(resolved.gap);
530
+ }
531
+ if (options.validateResponses !== false && route.schema?.response !== undefined)
532
+ targetCount += 1;
533
+ const allInputsResolved = (route.schema?.body === undefined || body !== undefined) &&
534
+ (route.schema?.query === undefined || query !== undefined);
535
+ const bodyMutations = body !== undefined && allInputsResolved
536
+ ? await provenMutations(body, "body", maxMutations)
537
+ : [];
538
+ const queryMutations = query !== undefined && allInputsResolved
539
+ ? await provenMutations(query, "query", maxMutations)
540
+ : [];
541
+ if (body !== undefined && allInputsResolved && bodyMutations.length === 0) {
542
+ gaps.push({
543
+ route: routeKey,
544
+ target: "body",
545
+ code: "NO_REJECTED_MUTATION",
546
+ message: "the body validator accepted every generated hostile mutation",
547
+ });
548
+ }
549
+ if (query !== undefined && allInputsResolved && queryMutations.length === 0) {
550
+ gaps.push({
551
+ route: routeKey,
552
+ target: "query",
553
+ code: "NO_REJECTED_MUTATION",
554
+ message: "the query validator accepted every generated hostile mutation",
555
+ });
556
+ }
557
+ laboratories.push({
558
+ route,
559
+ routeKey,
560
+ witness,
561
+ ...(body === undefined ? {} : { body }),
562
+ ...(query === undefined ? {} : { query }),
563
+ bodyMutations,
564
+ queryMutations,
565
+ });
566
+ }
567
+ return { laboratories, targetCount };
568
+ }
569
+ /**
570
+ * Execute contract-derived hostile inputs and declared-response conformance against a runtime matrix.
571
+ * Runtime/request failures are captured in the report; inspect `report.ok`, `failures`, and `gaps`
572
+ * (or use {@link assertAdversarialContract} for a throwing test assertion).
573
+ */
574
+ export async function runAdversarialContract(app, options = {}) {
575
+ const seed = normalizeSeed(options.seed);
576
+ const origin = options.origin ?? DEFAULT_ORIGIN;
577
+ const routes = reflectRoutes(app).filter((route) => options.include?.(route) ?? true);
578
+ const runtimes = options.runtimes ?? [
579
+ { name: "in-process", fetch: (request) => app.fetch(request) },
580
+ ];
581
+ const statuses = new Set(options.expectedValidationStatuses ?? [422]);
582
+ const gaps = [];
583
+ const results = [];
584
+ const only = onlySet(options.only);
585
+ const { laboratories, targetCount } = await buildLaboratories(routes, options, seed, gaps);
586
+ if (runtimes.length === 0) {
587
+ gaps.push({ code: "NO_RUNTIME", message: "at least one contract runtime is required" });
588
+ }
589
+ const runtimeNames = new Set();
590
+ for (const runtime of runtimes) {
591
+ const name = runtime.name.trim();
592
+ if (!name || runtimeNames.has(name)) {
593
+ gaps.push({
594
+ code: "INVALID_RUNTIME",
595
+ message: !name
596
+ ? "contract runtime names must be non-empty"
597
+ : `duplicate contract runtime name ${JSON.stringify(name)}`,
598
+ });
599
+ }
600
+ runtimeNames.add(name);
601
+ }
602
+ if (targetCount === 0) {
603
+ gaps.push({
604
+ code: "NO_CONTRACT_TARGETS",
605
+ message: "selected routes declare no body, query, or response contracts",
606
+ });
607
+ }
608
+ for (const laboratory of laboratories) {
609
+ for (const target of ["body", "query"]) {
610
+ const mutations = target === "body" ? laboratory.bodyMutations : laboratory.queryMutations;
611
+ for (const mutation of mutations) {
612
+ const id = mutationId(laboratory.routeKey, target, mutation);
613
+ if (only !== undefined && !only.has(id))
614
+ continue;
615
+ for (const runtime of runtimes) {
616
+ const base = {
617
+ id,
618
+ route: laboratory.routeKey,
619
+ runtime: runtime.name,
620
+ kind: "input-rejection",
621
+ target,
622
+ mutation: `${displayPath(target, mutation.path)} ${mutation.reason}`,
623
+ };
624
+ const outcome = await executeRejection(runtime, laboratory, target, mutation, id, options, seed, origin, statuses);
625
+ if (outcome.rejected) {
626
+ results.push({
627
+ ...base,
628
+ ok: true,
629
+ ...(outcome.status === undefined ? {} : { status: outcome.status }),
630
+ message: "hostile input rejected at the request boundary",
631
+ replay: { seed, caseId: id, runtime: runtime.name },
632
+ });
633
+ continue;
634
+ }
635
+ let shrinkSteps = 0;
636
+ let status = outcome.status;
637
+ if (options.shrinkFailures !== false && outcome.error === undefined) {
638
+ const shrunk = await shrinkFailure(runtime, laboratory, target, mutation, id, options, seed, origin, statuses, positiveInteger(options.maxShrinkAttempts, DEFAULT_MAX_SHRINK_ATTEMPTS));
639
+ shrinkSteps = shrunk.steps;
640
+ status = shrunk.status ?? status;
641
+ }
642
+ results.push(resultForFailure({
643
+ ...base,
644
+ ...(status === undefined ? {} : { status }),
645
+ ...(shrinkSteps === 0 ? {} : { shrinkSteps }),
646
+ }, seed, outcome.error === undefined
647
+ ? `expected validation status ${[...statuses].join("/")}, received ${status ?? "no response"}`
648
+ : `runtime threw: ${outcome.error}`));
649
+ }
650
+ }
651
+ }
652
+ const responseSchema = laboratory.route.schema?.response;
653
+ if (options.validateResponses === false || responseSchema === undefined)
654
+ continue;
655
+ const id = responseId(laboratory.routeKey);
656
+ if (only !== undefined && !only.has(id))
657
+ continue;
658
+ if (responseSchema.standard === undefined) {
659
+ gaps.push({
660
+ route: laboratory.routeKey,
661
+ target: "response",
662
+ code: "NO_VALIDATOR",
663
+ message: "response metadata has no Standard Schema validator",
664
+ });
665
+ continue;
666
+ }
667
+ const allInputsResolved = (laboratory.route.schema?.body === undefined || laboratory.body !== undefined) &&
668
+ (laboratory.route.schema?.query === undefined || laboratory.query !== undefined);
669
+ if (!allInputsResolved)
670
+ continue;
671
+ for (const runtime of runtimes) {
672
+ const context = {
673
+ seed,
674
+ caseId: id,
675
+ route: laboratory.route,
676
+ runtime: runtime.name,
677
+ kind: "response-conformance",
678
+ target: "response",
679
+ };
680
+ const base = {
681
+ id,
682
+ route: laboratory.routeKey,
683
+ runtime: runtime.name,
684
+ kind: "response-conformance",
685
+ target: "response",
686
+ };
687
+ try {
688
+ const request = await preparedRequest(requestFor(laboratory, origin, laboratory.body?.value, laboratory.query?.value), context, options.prepareRequest);
689
+ const response = await runtime.fetch(request);
690
+ const status = response.status;
691
+ if (!response.ok) {
692
+ await discardResponse(response);
693
+ results.push(resultForFailure({ ...base, status }, seed, `valid witness returned non-success status ${status}`));
694
+ continue;
695
+ }
696
+ const text = await response.text();
697
+ let value;
698
+ try {
699
+ value = text.length === 0 ? undefined : JSON.parse(text);
700
+ }
701
+ catch {
702
+ results.push(resultForFailure({ ...base, status }, seed, "declared response contract received a non-JSON response body"));
703
+ continue;
704
+ }
705
+ const validation = await responseSchema.standard["~standard"].validate(value);
706
+ if (validation.issues !== undefined) {
707
+ const first = validation.issues[0];
708
+ const at = first?.path?.map((segment) => typeof segment === "object" && segment !== null && "key" in segment
709
+ ? String(segment.key)
710
+ : String(segment));
711
+ results.push(resultForFailure({ ...base, status }, seed, `response violates its contract${at && at.length > 0 ? ` at ${at.join(".")}` : ""}${first?.message ? `: ${first.message}` : ""}`));
712
+ continue;
713
+ }
714
+ results.push({
715
+ ...base,
716
+ status,
717
+ ok: true,
718
+ message: "successful response satisfies its declared contract",
719
+ replay: { seed, caseId: id, runtime: runtime.name },
720
+ });
721
+ }
722
+ catch (error) {
723
+ results.push(resultForFailure(base, seed, `runtime threw: ${error instanceof Error ? error.message : String(error)}`));
724
+ }
725
+ }
726
+ }
727
+ const failures = results.filter((result) => !result.ok);
728
+ if (only !== undefined) {
729
+ for (const id of only) {
730
+ if (!results.some((result) => result.id === id)) {
731
+ gaps.push({
732
+ code: "CASE_NOT_FOUND",
733
+ message: `replay case ${JSON.stringify(id)} was not generated for the selected contract`,
734
+ });
735
+ }
736
+ }
737
+ }
738
+ const coverageRequired = options.requireCoverage ?? options.only === undefined;
739
+ const fatalGap = gaps.some((gap) => gap.code === "NO_RUNTIME" || gap.code === "INVALID_RUNTIME" || gap.code === "CASE_NOT_FOUND");
740
+ return {
741
+ ok: failures.length === 0 && !fatalGap && (!coverageRequired || gaps.length === 0),
742
+ seed,
743
+ routeCount: routes.length,
744
+ targetCount,
745
+ runtimeCount: runtimes.length,
746
+ results,
747
+ failures,
748
+ gaps,
749
+ counts: {
750
+ passed: results.length - failures.length,
751
+ failed: failures.length,
752
+ gaps: gaps.length,
753
+ },
754
+ };
755
+ }
756
+ /** Run the contract laboratory and throw an {@link AdversarialContractError} unless it is fully green. */
757
+ export async function assertAdversarialContract(app, options = {}) {
758
+ const report = await runAdversarialContract(app, options);
759
+ if (!report.ok)
760
+ throw new AdversarialContractError(report);
761
+ return report;
762
+ }
763
+ //# sourceMappingURL=adversarial.js.map