@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/index.js CHANGED
@@ -1,5 +1,6 @@
1
- import { i as match, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
2
- import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { i as match, n as Matcher, t as CaptureScope } from "./match.js";
2
+ import { a as isContract, c as mergeTextPreservingPlaceholders, d as structuralSubset, f as textEquals, i as defineContracts, l as renderExpected, n as contractsOf, o as isContracts, r as defineContract, s as mergePreservingPlaceholders, t as ContractQueue, u as structuralEquals } from "./queue.js";
3
+ import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
5
  import { parse } from "yaml";
5
6
  import { execFile, execSync, spawn, spawnSync } from "node:child_process";
@@ -520,298 +521,6 @@ function isJsonLike(text) {
520
521
  }
521
522
  }
522
523
  //#endregion
523
- //#region src/core/matching/structural.ts
524
- /**
525
- * Structural comparison engine shared by every fixture matcher.
526
- *
527
- * Handles three kinds of "expected" values:
528
- * - plain JSON values → strict deep equality
529
- * - {@link Matcher} instances (code-side `match.*`)
530
- * - strings containing `{{placeholder}}` forms (file-side fixtures)
531
- *
532
- * One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
533
- * works in `expected/*.http` (body and headers), `expected/*.json`, and
534
- * text snapshots (`expected/*.txt`).
535
- *
536
- * Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
537
- * {@link CaptureScope} supplied by the caller.
538
- */
539
- 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}";
540
- const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
541
- const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
542
- const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
543
- const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
544
- const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
545
- const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
546
- const INT_SOURCE = String.raw`-?\d+`;
547
- const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
548
- const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
549
- const SHA_SOURCE = "[0-9a-f]{7,64}";
550
- const HEX_SOURCE = "[0-9a-fA-F]+";
551
- const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
552
- 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})`;
553
- const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
554
- const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
555
- const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
556
- const EMBEDDED_SOURCES = {
557
- base64: BASE64_SOURCE,
558
- date: DATE_SOURCE,
559
- duration: DURATION_SOURCE,
560
- email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
561
- float: FLOAT_SOURCE,
562
- hex: HEX_SOURCE,
563
- int: INT_SOURCE,
564
- ip: IP_SOURCE,
565
- iso8601: ISO8601_SOURCE,
566
- number: NUMBER_SOURCE,
567
- path: String.raw`\.{0,2}\/[^\s"'<>]*`,
568
- port: PORT_SOURCE,
569
- semver: SEMVER_SOURCE,
570
- sha: SHA_SOURCE,
571
- time: TIME_SOURCE,
572
- ulid: ULID_SOURCE,
573
- url: URL_SOURCE,
574
- uuid: UUID_SOURCE
575
- };
576
- const wholeRe = (source) => new RegExp(`^(?:${source})$`);
577
- const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
578
- const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "g");
579
- /** Whether a fixture string contains at least one `{{placeholder}}`. */
580
- function hasPlaceholders(value) {
581
- PLACEHOLDER_RE.lastIndex = 0;
582
- return PLACEHOLDER_RE.test(value);
583
- }
584
- /** Key-order-independent stringification for captured-object comparison. */
585
- function stableStringify(value) {
586
- if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
587
- 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(",")}}`;
588
- return JSON.stringify(value) ?? "undefined";
589
- }
590
- function capturedEquals(a, b) {
591
- if (Object.is(a, b)) return true;
592
- if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
593
- if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
594
- return false;
595
- }
596
- function recordRef(name, actual, scope) {
597
- if (scope.has(name)) return capturedEquals(scope.get(name), actual);
598
- scope.set(name, actual);
599
- return true;
600
- }
601
- function isPortValue(value) {
602
- return Number.isInteger(value) && value >= 0 && value <= 65535;
603
- }
604
- function kindMatches(kind, actual, scope) {
605
- switch (kind) {
606
- case "any": return true;
607
- case "float":
608
- if (typeof actual === "number") return Number.isFinite(actual);
609
- return typeof actual === "string" && WHOLE_RES.float.test(actual);
610
- case "int":
611
- if (typeof actual === "number") return Number.isInteger(actual);
612
- return typeof actual === "string" && WHOLE_RES.int.test(actual);
613
- case "number":
614
- if (typeof actual === "number") return Number.isFinite(actual);
615
- return typeof actual === "string" && WHOLE_RES.number.test(actual);
616
- case "port":
617
- if (typeof actual === "number") return isPortValue(actual);
618
- return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
619
- case "string": return typeof actual === "string";
620
- case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
621
- default: {
622
- const re = WHOLE_RES[kind];
623
- return re !== void 0 && typeof actual === "string" && re.test(actual);
624
- }
625
- }
626
- }
627
- function matcherMatches(matcher, actual, scope) {
628
- if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
629
- if (matcher.kind === "ref") {
630
- if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
631
- return recordRef(matcher.refName, actual, scope);
632
- }
633
- return kindMatches(matcher.kind, actual, scope);
634
- }
635
- function placeholderSource(kind, scope) {
636
- if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
637
- const source = EMBEDDED_SOURCES[kind];
638
- if (source) return source;
639
- return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
640
- }
641
- function escapeRegExp(text) {
642
- return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
643
- }
644
- function parsePlaceholderString(expected, scope) {
645
- PLACEHOLDER_RE.lastIndex = 0;
646
- const refs = [];
647
- let source = "";
648
- let lastIndex = 0;
649
- let single = null;
650
- let count = 0;
651
- for (const found of expected.matchAll(PLACEHOLDER_RE)) {
652
- const kind = found.groups.kind;
653
- const ref = found.groups.ref;
654
- source += escapeRegExp(expected.slice(lastIndex, found.index));
655
- source += `(${placeholderSource(kind, scope)})`;
656
- refs.push({
657
- index: count,
658
- kind,
659
- ref
660
- });
661
- count++;
662
- lastIndex = found.index + found[0].length;
663
- if (found.index === 0 && found[0].length === expected.length) single = {
664
- kind,
665
- ref
666
- };
667
- }
668
- source += escapeRegExp(expected.slice(lastIndex));
669
- return {
670
- pattern: new RegExp(`^${source}$`),
671
- refs,
672
- single,
673
- source
674
- };
675
- }
676
- /**
677
- * Unanchored regex source matching a fixture string — plain text escaped
678
- * verbatim, each `{{token}}` expanded to its embedded grammar. Used to route
679
- * declared `.http` intercept paths (`/articles/{{uuid}}`) as URL patterns.
680
- */
681
- function placeholderPatternSource(expected) {
682
- return parsePlaceholderString(expected, new CaptureScope()).source;
683
- }
684
- function placeholderStringMatches(expected, actual, scope) {
685
- const parsed = parsePlaceholderString(expected, scope);
686
- if (parsed.single) {
687
- if (!kindMatches(parsed.single.kind, actual, scope)) return false;
688
- if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
689
- return true;
690
- }
691
- if (typeof actual !== "string" && typeof actual !== "number") return false;
692
- const text = String(actual);
693
- const found = parsed.pattern.exec(text);
694
- if (!found) return false;
695
- for (const entry of parsed.refs) {
696
- if (!entry.ref) continue;
697
- if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
698
- }
699
- return true;
700
- }
701
- function isPlainObject(value) {
702
- return value !== null && typeof value === "object" && !Array.isArray(value);
703
- }
704
- /**
705
- * Deep structural equality with matcher / placeholder support. Ref captures
706
- * are recorded in `scope` in traversal order (arrays left-to-right, object
707
- * keys in expected-key order).
708
- */
709
- function structuralEquals(expected, actual, scope) {
710
- if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
711
- if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
712
- if (Array.isArray(expected)) {
713
- if (!Array.isArray(actual) || actual.length !== expected.length) return false;
714
- return expected.every((item, i) => structuralEquals(item, actual[i], scope));
715
- }
716
- if (isPlainObject(expected)) {
717
- if (!isPlainObject(actual)) return false;
718
- const expectedKeys = Object.keys(expected);
719
- const actualKeys = Object.keys(actual);
720
- if (expectedKeys.length !== actualKeys.length) return false;
721
- return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
722
- }
723
- return Object.is(expected, actual);
724
- }
725
- /**
726
- * Deep structural SUBSET match (toMatchObject-style) with matcher /
727
- * placeholder support. Plain objects match when every expected key is present
728
- * and recursively subset-matches — the actual value may carry extra keys.
729
- * Arrays require equal length with each element subset-matched. Leaves fall
730
- * back to {@link structuralEquals} semantics (matchers, placeholders, strict
731
- * equality). Used by request-body filters on intercept triggers.
732
- */
733
- function structuralSubset(expected, actual, scope) {
734
- if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
735
- if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
736
- if (Array.isArray(expected)) {
737
- if (!Array.isArray(actual) || actual.length !== expected.length) return false;
738
- return expected.every((item, i) => structuralSubset(item, actual[i], scope));
739
- }
740
- if (isPlainObject(expected)) {
741
- if (!isPlainObject(actual)) return false;
742
- return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
743
- }
744
- return Object.is(expected, actual);
745
- }
746
- /**
747
- * Multi-line text comparison with `{{token}}` support — used by text
748
- * snapshots (`expected/*.txt`). Without placeholders this is strict equality.
749
- */
750
- function textEquals(expected, actual, scope) {
751
- if (!hasPlaceholders(expected)) return expected === actual;
752
- return placeholderStringMatches(expected, actual, scope);
753
- }
754
- /**
755
- * Render an expected value for failure diffs and serialization: Matcher
756
- * instances become their placeholder text, everything else is untouched.
757
- */
758
- function renderExpected(value) {
759
- if (value instanceof Matcher) return value.toString();
760
- if (Array.isArray(value)) return value.map((item) => renderExpected(item));
761
- if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
762
- return value;
763
- }
764
- /**
765
- * Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
766
- * every string leaf of a structured value. The structural mirror of the text
767
- * path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
768
- * `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
769
- * D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
770
- */
771
- function substituteWorkdirDeep(value, workdir) {
772
- if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
773
- if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
774
- if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
775
- return value;
776
- }
777
- /**
778
- * Update-mode merge: rewrite a fixture from the actual output while
779
- * preserving every segment of the previous fixture that was covered by a
780
- * placeholder (and still matches the actual value).
781
- *
782
- * Token preservation is symmetric with the text path (CONVENTIONS D5): a
783
- * previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
784
- * RAW actual value is kept; every other leaf is taken from the actual output
785
- * with the framework-known cwd substituted back to `{{workdir}}`.
786
- */
787
- function mergePreservingPlaceholders(previous, actual, workdir) {
788
- const scope = new CaptureScope(workdir);
789
- const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
790
- function merge(prev, act) {
791
- if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
792
- if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
793
- if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
794
- return subst(act);
795
- }
796
- return merge(previous, actual);
797
- }
798
- /**
799
- * Update-mode merge for text snapshots: line-by-line, a previous line whose
800
- * placeholders still match the actual line is preserved; every other line is
801
- * taken from the actual output. Values the framework knows to be dynamic
802
- * (`{{workdir}}`) are substituted automatically (CONVENTIONS D5).
803
- */
804
- function mergeTextPreservingPlaceholders(previous, actual, scope) {
805
- const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
806
- if (previous === null) return substituted;
807
- const prevLines = previous.split("\n");
808
- return substituted.split("\n").map((line, i) => {
809
- const prev = prevLines[i];
810
- if (prev === void 0 || !hasPlaceholders(prev)) return line;
811
- return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, actual.split("\n")[i] ?? line, new CaptureScope(scope.workdir)) ? prev : line;
812
- }).join("\n");
813
- }
814
- //#endregion
815
524
  //#region src/core/specification/shared/reporter.ts
816
525
  const GREEN = "\x1B[32m";
817
526
  const RED = "\x1B[31m";
@@ -1990,128 +1699,6 @@ async function registerMatchers() {
1990
1699
  }
1991
1700
  }
1992
1701
  //#endregion
1993
- //#region src/core/http-files/intercept-file.ts
1994
- /** Split a declared path into its pathname and query halves — never through
1995
- * `new URL()`, which would percent-encode `{{token}}` braces away. */
1996
- function splitPath(path) {
1997
- const separator = path.indexOf("?");
1998
- if (separator === -1) return {
1999
- pathname: path,
2000
- query: new URLSearchParams()
2001
- };
2002
- return {
2003
- pathname: path.slice(0, separator),
2004
- query: new URLSearchParams(path.slice(separator + 1))
2005
- };
2006
- }
2007
- /**
2008
- * Parse an `intercepts/*.http` file into its declared exchanges. Malformed
2009
- * files refuse with the exchange number and the expected shape.
2010
- */
2011
- function parseInterceptFile(content, fileName) {
2012
- const segments = content.split(/^###.*$/m).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
2013
- if (segments.length === 0) throw new Error(`${fileName}: no exchanges found — expected "###"-separated request/response pairs`);
2014
- return segments.map((segment, index) => {
2015
- const ordinal = index + 1;
2016
- const lines = segment.split(/\r?\n/);
2017
- const statusIndex = lines.findIndex((line) => /^HTTP\/1\.1\s/.test(line.trim()));
2018
- if (statusIndex === -1) throw new Error(`${fileName}: exchange ${ordinal} has no response block — expected an "HTTP/1.1 <status>" line after the request`);
2019
- const request = parseRequestFile(lines.slice(0, statusIndex).join("\n"), fileName);
2020
- if (request.body !== void 0) throw new Error(`${fileName}: exchange ${ordinal} declares a request body — matching is method + path + headers only`);
2021
- const response = parseResponseFile(lines.slice(statusIndex).join("\n"), fileName);
2022
- const status = Number(response.status);
2023
- if (!Number.isInteger(status)) throw new Error(`${fileName}: exchange ${ordinal} has a non-numeric status "${response.status}" — an intercept file states the stubbed reply, not an expectation`);
2024
- return {
2025
- request: {
2026
- headers: request.headers,
2027
- method: request.method,
2028
- path: request.path
2029
- },
2030
- response: {
2031
- body: response.body,
2032
- hasBody: response.hasBody,
2033
- headers: response.headers,
2034
- status
2035
- }
2036
- };
2037
- });
2038
- }
2039
- /** Token-aware comparison of one declared value against an observed one. */
2040
- function valueMatches(declared, observed) {
2041
- return textEquals(declared, observed, new CaptureScope());
2042
- }
2043
- /**
2044
- * Does an observed request satisfy a declared exchange request? Method exact,
2045
- * pathname exact (`{{token}}` segments match structurally), declared query
2046
- * params and headers subset-match — extras on the observed side are ignored.
2047
- */
2048
- function exchangeMatches(declared, observed) {
2049
- if (declared.method !== observed.method.toUpperCase()) return false;
2050
- const { pathname, query } = splitPath(declared.path);
2051
- let observedUrl;
2052
- try {
2053
- observedUrl = new URL(observed.url, "http://stub.invalid");
2054
- } catch {
2055
- return false;
2056
- }
2057
- if (!valueMatches(pathname, observedUrl.pathname) && !valueMatches(pathname, safeDecode(observedUrl.pathname))) return false;
2058
- for (const key of new Set(query.keys())) {
2059
- const observedValues = observedUrl.searchParams.getAll(key);
2060
- if (!query.getAll(key).every((value) => observedValues.some((actual) => valueMatches(value, actual)))) return false;
2061
- }
2062
- return Object.entries(declared.headers).every(([name, value]) => {
2063
- const actual = observed.headers[name.toLowerCase()];
2064
- return actual !== void 0 && valueMatches(value, actual);
2065
- });
2066
- }
2067
- /** Decode percent-escapes, falling back to the raw text on malformed input. */
2068
- function safeDecode(text) {
2069
- try {
2070
- return decodeURIComponent(text);
2071
- } catch {
2072
- return text;
2073
- }
2074
- }
2075
- /**
2076
- * A URL pattern routing any-origin requests whose pathname is the declared
2077
- * one — `{{token}}` segments expanded to their grammars. Deliberately no
2078
- * wider than {@link exchangeMatches}: the predicate stays the authority.
2079
- */
2080
- function urlPatternOf(path) {
2081
- const { pathname } = splitPath(path);
2082
- return new RegExp(String.raw`^https?:\/\/[^/?#]+${placeholderPatternSource(pathname)}(?:\?[^#]*)?(?:#.*)?$`);
2083
- }
2084
- /**
2085
- * Convert parsed exchanges into generic-http intercept entries for the MSW
2086
- * engine — each exchange becomes one FIFO-consumed contract (CONVENTIONS D7
2087
- * strictness applies unchanged on api/jobs chains).
2088
- */
2089
- function interceptEntriesOf(exchanges) {
2090
- return exchanges.map((exchange) => {
2091
- return {
2092
- response: {
2093
- body: exchange.response.hasBody ? exchange.response.body : null,
2094
- headers: Object.keys(exchange.response.headers).length > 0 ? exchange.response.headers : void 0,
2095
- status: exchange.response.status
2096
- },
2097
- trigger: {
2098
- adapter: "http",
2099
- match: (request) => exchangeMatches(exchange.request, {
2100
- headers: request.headers,
2101
- method: exchange.request.method,
2102
- url: request.url
2103
- }),
2104
- method: exchange.request.method,
2105
- url: urlPatternOf(exchange.request.path),
2106
- wrap: (data) => ({
2107
- body: data,
2108
- status: 200
2109
- })
2110
- }
2111
- };
2112
- });
2113
- }
2114
- //#endregion
2115
1702
  //#region src/core/specification/api/result.ts
2116
1703
  /** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
2117
1704
  var HttpResult = class extends BaseResult {
@@ -2410,11 +1997,10 @@ function copyPlan(path, testDir, workDir) {
2410
1997
  * only surfaces the methods that make sense for it.
2411
1998
  */
2412
1999
  var SpecificationBuilder = class {
2413
- backendExchanges = [];
2414
2000
  commandEnv = {};
2415
2001
  config;
2002
+ contracts = [];
2416
2003
  fixtures = [];
2417
- intercepts = [];
2418
2004
  requestHeaders = {};
2419
2005
  seeds = [];
2420
2006
  testDir;
@@ -2488,57 +2074,21 @@ var SpecificationBuilder = class {
2488
2074
  };
2489
2075
  return this;
2490
2076
  }
2491
- intercept(triggerOrContracts, maybeResponse) {
2077
+ intercept(requestOrContracts, maybeResponse) {
2492
2078
  if (this.config.interceptDisabledReason) throw new Error(`.intercept(): ${this.config.interceptDisabledReason}`);
2493
- if (typeof triggerOrContracts === "string") return this.interceptFile(triggerOrContracts);
2494
- if (Array.isArray(triggerOrContracts)) {
2495
- for (const contract of triggerOrContracts) this.registerIntercept(contract);
2496
- return this;
2497
- }
2498
- this.registerIntercept(triggerOrContracts, maybeResponse);
2499
- return this;
2500
- }
2501
- /** Register a single intercept — a contract, or a trigger + response pair. */
2502
- registerIntercept(triggerOrContract, maybeResponse) {
2503
- const isContract = "trigger" in triggerOrContract && "response" in triggerOrContract;
2504
- const trigger = isContract ? triggerOrContract.trigger : triggerOrContract;
2505
- const response = isContract ? triggerOrContract.response : maybeResponse;
2506
- if (typeof response === "string") {
2507
- const slashIndex = response.indexOf("/");
2508
- if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
2509
- const adapterName = response.slice(0, slashIndex);
2510
- if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
2511
- const filePath = resolve(this.testDir, "intercepts", response);
2512
- const data = JSON.parse(readFileSync(filePath, "utf8"));
2513
- const resolved = trigger.wrap(data);
2514
- this.intercepts.push({
2515
- trigger,
2516
- response: resolved
2517
- });
2518
- } else this.intercepts.push({
2519
- trigger,
2520
- response
2521
- });
2522
- }
2523
- /**
2524
- * Register the exchanges of an `intercepts/<name>.http` file — flat in
2525
- * the calling test's `intercepts/` directory, the same resolution
2526
- * `.seed()` / `.request()` use. Website/mobile chains feed the declared
2527
- * stub backend; api/jobs chains feed the MSW engine (same contract list).
2528
- */
2529
- interceptFile(file) {
2530
- if (!file.endsWith(".http")) throw new Error(`.intercept(): a single string argument must name an intercepts/*.http file (e.g. 'two-events.http'), got '${file}'`);
2531
- const stubFacet = this.config.baseUrl !== void 0 || this.config.device !== void 0;
2532
- if (stubFacet && !this.config.backend) {
2079
+ if ((this.config.baseUrl !== void 0 || this.config.device !== void 0) && !this.config.backend) {
2533
2080
  const facet = this.config.device === void 0 ? "website" : "mobile";
2534
- throw new Error(`.intercept(): this runner has no declared backend — add \`backend\` to the specification options (specification.${facet}({ …, backend: { … } })) so the chain's .http intercepts have a stub to serve them.`);
2081
+ throw new Error(`.intercept(): this runner has no declared backend — add \`backend\` to the specification options (specification.${facet}({ …, backend: { … } })) so the chain's contracts have a stub to serve them.`);
2535
2082
  }
2536
- const exchanges = parseInterceptFile(readFileSync(resolve(this.testDir, "intercepts", file), "utf8"), `intercepts/${file}`);
2537
- if (stubFacet) {
2538
- this.backendExchanges.push(...exchanges);
2083
+ if (Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts)) {
2084
+ this.contracts.push(...contractsOf(requestOrContracts));
2539
2085
  return this;
2540
2086
  }
2541
- this.intercepts.push(...interceptEntriesOf(exchanges));
2087
+ if (maybeResponse === void 0) throw new Error(".intercept(): a bare request needs its response — pass a contract (defineContract({ request, response })) or the inline pair .intercept(request, response).");
2088
+ this.contracts.push({
2089
+ request: requestOrContracts,
2090
+ response: maybeResponse
2091
+ });
2542
2092
  return this;
2543
2093
  }
2544
2094
  /**
@@ -2715,11 +2265,11 @@ var SpecificationBuilder = class {
2715
2265
  cpSync(src, dest, { recursive: true });
2716
2266
  }
2717
2267
  let registration = null;
2718
- if (this.intercepts.length > 0) {
2719
- const { registerIntercepts } = await import("./intercept.js");
2720
- registration = await registerIntercepts(this.intercepts);
2268
+ if (this.config.backend) this.config.backend.beginChain(this.contracts);
2269
+ else if (this.contracts.length > 0) {
2270
+ const { registerContracts } = await import("./intercept.js");
2271
+ registration = await registerContracts(this.contracts);
2721
2272
  }
2722
- this.config.backend?.beginChain(this.backendExchanges);
2723
2273
  try {
2724
2274
  const value = await action();
2725
2275
  const violation = registration?.violation() ?? this.config.backend?.violation();
@@ -2895,6 +2445,14 @@ var SpecificationBuilder = class {
2895
2445
  });
2896
2446
  }
2897
2447
  };
2448
+ /**
2449
+ * The facet-level `.intercept()`: both overloads forwarded to a fresh chain.
2450
+ * The pair form is recognised by its second argument — a bare
2451
+ * {@link ContractRequest} never arrives alone.
2452
+ */
2453
+ function interceptOn(start) {
2454
+ return (contractsOrRequest, response) => response === void 0 ? start().intercept(contractsOrRequest) : start().intercept(contractsOrRequest, response);
2455
+ }
2898
2456
  function withDockerTestRunId(config) {
2899
2457
  if (config.dockerConfig && !config.dockerTestRunId) return {
2900
2458
  ...config,
@@ -2912,7 +2470,7 @@ function createApiFacet(config) {
2912
2470
  delete: (path) => start().delete(path),
2913
2471
  get: (path) => start().get(path),
2914
2472
  headers: (headers) => start().headers(headers),
2915
- intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) || typeof triggerOrContracts === "string" ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2473
+ intercept: interceptOn(start),
2916
2474
  post: (path, body) => start().post(path, body),
2917
2475
  put: (path, body) => start().put(path, body),
2918
2476
  request: (file) => start().request(file),
@@ -2925,7 +2483,7 @@ function createApiFacet(config) {
2925
2483
  function createJobsFacet(config) {
2926
2484
  const start = () => new SpecificationBuilder(config, getCallerDir());
2927
2485
  return {
2928
- intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) || typeof triggerOrContracts === "string" ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2486
+ intercept: interceptOn(start),
2929
2487
  seed: (file, options) => start().seed(file, options),
2930
2488
  trigger: (name) => start().trigger(name)
2931
2489
  };
@@ -2938,7 +2496,7 @@ function createWebsiteFacet(config) {
2938
2496
  return {
2939
2497
  fetch: (path) => start().fetch(path),
2940
2498
  headers: (headers) => start().headers(headers),
2941
- intercept: (file) => start().intercept(file),
2499
+ intercept: interceptOn(start),
2942
2500
  visit: (path, scenario) => start().visit(path, scenario)
2943
2501
  };
2944
2502
  }
@@ -2948,7 +2506,7 @@ function createWebsiteFacet(config) {
2948
2506
  function createMobileFacet(config) {
2949
2507
  const start = () => new SpecificationBuilder(config, getCallerDir());
2950
2508
  return {
2951
- intercept: (file) => start().intercept(file),
2509
+ intercept: interceptOn(start),
2952
2510
  open: (deepLink, scenario) => start().open(deepLink, scenario)
2953
2511
  };
2954
2512
  }
@@ -3651,21 +3209,20 @@ async function startJobs(options) {
3651
3209
  /**
3652
3210
  * The declared stub backend — a small `node:http` server the website and
3653
3211
  * mobile runners start when their `backend` option is present. It serves the
3654
- * exchanges the CURRENT chain declared via `.intercept('<name>.http')`:
3655
- * one chain = one terminal action, and the stub resets between chains the
3656
- * way databases do.
3212
+ * contracts the CURRENT chain declared via `.intercept(...)`: one chain = one
3213
+ * terminal action, and the stub resets between chains the way databases do.
3657
3214
  *
3658
- * Matching is the `intercepts/*.http` grammar (`exchangeMatches`), with the
3659
- * queue semantics a rendered app needs: same-route entries consume FIFO, and
3660
- * once a route's queue is exhausted its LAST entry stays sticky — a page
3661
- * re-fetching the same endpoint (re-render, retry) replays the final reply
3662
- * instead of failing an honest screen.
3215
+ * Selection is the shared {@link ContractQueue} the same first-matching,
3216
+ * `times`-aware queue the MSW engine runs on api/jobs chains. A contract
3217
+ * without `times` keeps replying (re-render, retry); `times: n` is spent after
3218
+ * n serves.
3663
3219
  *
3664
3220
  * Strictness is the `external: 'block'` analog (CONVENTIONS D7 in spirit): a
3665
- * request matching no declared exchange is answered 501 and RECORDED; once
3666
- * the chain declared at least one intercept, the terminal action throws an
3667
- * error enumerating every unmatched request. Chains with zero intercepts
3668
- * leave the stub unguarded mirroring how MSW stays off without them.
3221
+ * request matching no contract is answered 501 and RECORDED; once the chain
3222
+ * declared at least one contract, the terminal action throws an error
3223
+ * enumerating every unmatched request and every `required` contract that was
3224
+ * never requested. Chains with zero contracts leave the stub unguarded
3225
+ * mirroring how MSW stays off without them.
3669
3226
  *
3670
3227
  * Every response (including the 501 and the OPTIONS preflight) carries
3671
3228
  * permissive CORS headers — a website's client-side fetches are cross-origin
@@ -3674,11 +3231,10 @@ async function startJobs(options) {
3674
3231
  const CORS_METHODS = "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS";
3675
3232
  const PREFLIGHT_MAX_AGE = "600";
3676
3233
  var StubBackend = class {
3677
- consumed = [];
3678
- entries = [];
3679
- /** Guarded once the current chain declared at least one intercept. */
3234
+ /** Guarded once the current chain declared at least one contract. */
3680
3235
  guarded = false;
3681
3236
  options;
3237
+ queue = new ContractQueue([]);
3682
3238
  server = null;
3683
3239
  unmatchedRequests = /* @__PURE__ */ new Map();
3684
3240
  url = "";
@@ -3711,26 +3267,27 @@ var StubBackend = class {
3711
3267
  });
3712
3268
  }
3713
3269
  /**
3714
- * Arm the stub for one chain: the declared exchanges replace the previous
3715
- * chain's wholesale, consumption restarts, and the unmatched log clears —
3270
+ * Arm the stub for one chain: the declared contracts replace the previous
3271
+ * chain's wholesale, the queue restarts, and the unmatched log clears —
3716
3272
  * the reset-between-chains databases already follow.
3717
3273
  */
3718
- beginChain(exchanges) {
3719
- this.consumed = exchanges.map(() => false);
3720
- this.entries = exchanges;
3721
- this.guarded = exchanges.length > 0;
3274
+ beginChain(contracts) {
3275
+ this.guarded = contracts.length > 0;
3276
+ this.queue = new ContractQueue(contracts);
3722
3277
  this.unmatchedRequests.clear();
3723
3278
  }
3724
3279
  /**
3725
- * The strict failure for the chain, or null — non-null when the chain
3726
- * declared at least one intercept AND an unmatched request was recorded.
3727
- * Enumerates every unmatched request (method, path, count) plus the
3728
- * declared routes, so the missing exchange writes itself.
3280
+ * The strict failure for the chain, or null. Non-null when the chain
3281
+ * declared at least one contract AND either an unmatched request was
3282
+ * recorded, or a `required` contract was never satisfied. Enumerates every
3283
+ * unmatched request (method, path, count) plus the declared routes, so the
3284
+ * missing contract writes itself.
3729
3285
  */
3730
3286
  violation() {
3731
- if (!this.guarded || this.unmatchedRequests.size === 0) return null;
3287
+ if (!this.guarded) return null;
3288
+ if (this.unmatchedRequests.size === 0) return this.queue.requiredError();
3732
3289
  const unmatched = [...this.unmatchedRequests.values()].map((entry) => ` - ${entry.method} ${entry.path}${entry.count > 1 ? ` (${entry.count} times)` : ""}`).join("\n");
3733
- return /* @__PURE__ */ new Error(`Unmatched request(s) hit the declared backend during the chain:\n${unmatched}\nDeclared exchanges:\n${this.describeRoutes()}\nEvery backend request of a chain that declares intercepts must match one — add an exchange for it to the chain's intercepts/*.http file.`);
3290
+ return /* @__PURE__ */ new Error(`Unmatched request(s) hit the declared backend during the chain:\n${unmatched}\nDeclared contracts:\n${this.describeRoutes()}\nEvery backend request of a chain that declares contracts must match one — add a contract for it to the chain's composite.`);
3734
3291
  }
3735
3292
  corsHeaders(request) {
3736
3293
  return {
@@ -3741,10 +3298,11 @@ var StubBackend = class {
3741
3298
  };
3742
3299
  }
3743
3300
  describeRoutes() {
3744
- if (this.entries.length === 0) return " (no exchanges declared)";
3745
- return this.entries.map((entry, i) => ` - ${entry.request.method} ${entry.request.path}${this.consumed[i] ? " (consumed)" : ""}`).join("\n");
3301
+ const routes = this.queue.declaredRoutes();
3302
+ if (routes.length === 0) return " (no contracts declared)";
3303
+ return routes.map((route) => ` - ${route}`).join("\n");
3746
3304
  }
3747
- handle(request, response) {
3305
+ async handle(request, response) {
3748
3306
  const method = (request.method ?? "GET").toUpperCase();
3749
3307
  const cors = this.corsHeaders(request);
3750
3308
  if (method === "OPTIONS") {
@@ -3755,28 +3313,33 @@ var StubBackend = class {
3755
3313
  const headers = {};
3756
3314
  for (const [name, value] of Object.entries(request.headers)) if (typeof value === "string") headers[name.toLowerCase()] = value;
3757
3315
  const url = request.url ?? "/";
3758
- const entry = this.take({
3316
+ const observed = {
3317
+ body: await readBody(request),
3759
3318
  headers,
3760
3319
  method,
3761
3320
  url
3762
- });
3763
- if (!entry) {
3321
+ };
3322
+ const contract = this.queue.take(observed);
3323
+ if (!contract) {
3764
3324
  this.record(method, url);
3765
3325
  response.writeHead(501, {
3766
3326
  ...cors,
3767
3327
  "content-type": "application/json"
3768
3328
  });
3769
3329
  response.end(JSON.stringify({
3770
- declared: this.entries.map((declared) => `${declared.request.method} ${declared.request.path}`),
3771
- error: `@jterrazz/test declared backend: no exchange matches ${method} ${url}`
3330
+ declared: this.queue.declaredRoutes(),
3331
+ error: `@jterrazz/test declared backend: no contract matches ${method} ${url}`
3772
3332
  }));
3773
3333
  return;
3774
3334
  }
3775
- const { body, hasBody, status } = entry.response;
3776
- if (!hasBody) {
3335
+ const reply = typeof contract.response === "function" ? contract.response(observed) : contract.response;
3336
+ if (reply.delay) await new Promise((resolve) => setTimeout(resolve, reply.delay));
3337
+ const status = reply.status ?? 200;
3338
+ const { body } = reply;
3339
+ if (body === null || body === void 0) {
3777
3340
  response.writeHead(status, {
3778
3341
  ...cors,
3779
- ...entry.response.headers
3342
+ ...reply.headers
3780
3343
  });
3781
3344
  response.end();
3782
3345
  return;
@@ -3786,7 +3349,7 @@ var StubBackend = class {
3786
3349
  response.writeHead(status, {
3787
3350
  ...cors,
3788
3351
  "content-type": contentType,
3789
- ...entry.response.headers
3352
+ ...reply.headers
3790
3353
  });
3791
3354
  response.end(payload);
3792
3355
  }
@@ -3800,25 +3363,19 @@ var StubBackend = class {
3800
3363
  path
3801
3364
  });
3802
3365
  }
3803
- /**
3804
- * FIFO with a sticky tail: the first unconsumed matching exchange is
3805
- * consumed; when every matching exchange is spent, the LAST one keeps
3806
- * replying.
3807
- */
3808
- take(observed) {
3809
- let sticky = null;
3810
- for (let i = 0; i < this.entries.length; i++) {
3811
- const entry = this.entries[i];
3812
- if (!exchangeMatches(entry.request, observed)) continue;
3813
- if (!this.consumed[i]) {
3814
- this.consumed[i] = true;
3815
- return entry;
3816
- }
3817
- sticky = entry;
3818
- }
3819
- return sticky;
3820
- }
3821
3366
  };
3367
+ /** Read the request body — parsed JSON when it is JSON, raw text otherwise. */
3368
+ async function readBody(request) {
3369
+ const chunks = [];
3370
+ for await (const chunk of request) chunks.push(chunk);
3371
+ const raw = Buffer.concat(chunks).toString("utf8");
3372
+ if (raw.length === 0) return null;
3373
+ try {
3374
+ return JSON.parse(raw);
3375
+ } catch {
3376
+ return raw;
3377
+ }
3378
+ }
3822
3379
  //#endregion
3823
3380
  //#region src/core/specification/website/serve.adapter.ts
3824
3381
  /** Grace period between SIGTERM and the SIGKILL escalation. */
@@ -4280,6 +3837,30 @@ const within = (scope, target) => ({
4280
3837
  });
4281
3838
  //#endregion
4282
3839
  //#region src/integrations/sqlite/sqlite.ts
3840
+ const SQLITE_FILE_HEADER = Buffer.from("SQLite format 3\0");
3841
+ /**
3842
+ * Whether `path` looks like a usable SQLite database file: it exists, is at
3843
+ * least as long as the format header, and begins with the SQLite magic
3844
+ * bytes. Does not validate anything beyond the header — good enough to
3845
+ * reject a 0-byte or truncated leftover without opening the file.
3846
+ */
3847
+ function isValidSqliteTemplate(path) {
3848
+ if (!existsSync(path)) return false;
3849
+ let fd;
3850
+ try {
3851
+ fd = openSync(path, "r");
3852
+ } catch {
3853
+ return false;
3854
+ }
3855
+ try {
3856
+ const header = Buffer.alloc(SQLITE_FILE_HEADER.length);
3857
+ return readSync(fd, header, 0, header.length, 0) === SQLITE_FILE_HEADER.length && header.equals(SQLITE_FILE_HEADER);
3858
+ } catch {
3859
+ return false;
3860
+ } finally {
3861
+ closeSync(fd);
3862
+ }
3863
+ }
4283
3864
  var SqliteHandle = class {
4284
3865
  type = "sqlite";
4285
3866
  composeName = null;
@@ -4312,9 +3893,14 @@ var SqliteHandle = class {
4312
3893
  while (existsSync(lockPath) && Date.now() - start < 3e4) await new Promise((r) => setTimeout(r, 100));
4313
3894
  }
4314
3895
  if (existsSync(this.templatePath)) {
4315
- this.connectionString = `file:${this.templatePath}`;
4316
- this.started = true;
4317
- return;
3896
+ if (isValidSqliteTemplate(this.templatePath)) {
3897
+ this.connectionString = `file:${this.templatePath}`;
3898
+ this.started = true;
3899
+ return;
3900
+ }
3901
+ try {
3902
+ unlinkSync(this.templatePath);
3903
+ } catch {}
4318
3904
  }
4319
3905
  const { writeFileSync } = await import("node:fs");
4320
3906
  writeFileSync(lockPath, process.pid.toString());
@@ -4406,25 +3992,22 @@ function sqlite(options = {}) {
4406
3992
  return new SqliteHandle(options);
4407
3993
  }
4408
3994
  //#endregion
3995
+ //#region src/core/contracts/filters.ts
3996
+ /** Does an observed text satisfy a declared {@link TextFilter}? */
3997
+ function matchesText(filter, actual) {
3998
+ if (filter === void 0) return true;
3999
+ if (typeof filter === "string") return actual === filter;
4000
+ if (filter instanceof RegExp) return filter.test(actual);
4001
+ return structuralEquals(filter, actual, new CaptureScope());
4002
+ }
4003
+ //#endregion
4409
4004
  //#region src/integrations/anthropic/anthropic.ts
4410
4005
  const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
4411
4006
  function matchesFilter(body, filter) {
4412
- if (filter.model) {
4413
- const model = body?.model;
4414
- if (typeof filter.model === "string" && model !== filter.model) return false;
4415
- if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
4416
- }
4417
- if (filter.system) {
4418
- const system = typeof body?.system === "string" ? body.system : "";
4419
- if (typeof filter.system === "string" && !system.includes(filter.system)) return false;
4420
- if (filter.system instanceof RegExp && !filter.system.test(system)) return false;
4421
- }
4422
- if (filter.user) {
4423
- const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
4424
- const text = typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg);
4425
- if (typeof filter.user === "string" && !text.includes(filter.user)) return false;
4426
- if (filter.user instanceof RegExp && !filter.user.test(text)) return false;
4427
- }
4007
+ if (!matchesText(filter.model, body?.model ?? "")) return false;
4008
+ if (!matchesText(filter.system, typeof body?.system === "string" ? body.system : "")) return false;
4009
+ const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
4010
+ if (!matchesText(filter.user, typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg))) return false;
4428
4011
  if (filter.tools) {
4429
4012
  const names = body?.tools?.map((t) => t.name).filter(Boolean) ?? [];
4430
4013
  if (!filter.tools.every((t) => names.includes(t))) return false;
@@ -4456,7 +4039,7 @@ function buildReply(data) {
4456
4039
  */
4457
4040
  const anthropic = {
4458
4041
  /**
4459
- * Trigger: match Messages API requests, optionally routed through a
4042
+ * Request: match Messages API calls, optionally routed through a
4460
4043
  * custom gateway URL. When used with a JSON fixture file, the data is
4461
4044
  * returned as-is (no wrapping) because Anthropic fixtures are typically
4462
4045
  * already in the Messages API response shape.
@@ -4464,6 +4047,7 @@ const anthropic = {
4464
4047
  * @example
4465
4048
  * anthropic.messages()
4466
4049
  * anthropic.messages({ system: /classify/ })
4050
+ * anthropic.messages({ user: buildPrompt() }) // string = EXACT equality
4467
4051
  * anthropic.messages({ user: /classify/ }, GATEWAY)
4468
4052
  */
4469
4053
  messages(filter, url) {
@@ -4513,6 +4097,7 @@ function wrapJson(data) {
4513
4097
  body: data
4514
4098
  };
4515
4099
  }
4100
+ const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
4516
4101
  function matchesBody(body, expected) {
4517
4102
  if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
4518
4103
  if (expected instanceof RegExp) {
@@ -4529,7 +4114,7 @@ function matchesEntries(expected, lookup) {
4529
4114
  });
4530
4115
  }
4531
4116
  /**
4532
- * Build the `match` predicate for an HTTP trigger filter, or `undefined` when
4117
+ * Build the `match` predicate for an HTTP request filter, or `undefined` when
4533
4118
  * no filter is supplied (fires on any URL/method match).
4534
4119
  */
4535
4120
  function buildMatch(filter) {
@@ -4540,7 +4125,7 @@ function buildMatch(filter) {
4540
4125
  if (filter.query) {
4541
4126
  let params;
4542
4127
  try {
4543
- params = new URL(request.url).searchParams;
4128
+ params = new URL(request.url, "http://contract.invalid").searchParams;
4544
4129
  } catch {
4545
4130
  return false;
4546
4131
  }
@@ -4549,74 +4134,79 @@ function buildMatch(filter) {
4549
4134
  return true;
4550
4135
  };
4551
4136
  }
4137
+ function declare(method, url, filter) {
4138
+ return {
4139
+ adapter: "http",
4140
+ match: buildMatch(filter),
4141
+ method,
4142
+ url,
4143
+ wrap: wrapJson
4144
+ };
4145
+ }
4552
4146
  /**
4553
- * Generic HTTP intercept helpers for any URL. An optional {@link
4554
- * HttpInterceptFilter} narrows matching by request body, headers, or query
4555
- * a request that hits the URL/method but fails the filter counts as unmatched
4556
- * (strict intercepts, CONVENTIONS D7).
4147
+ * Generic HTTP contract helpers for any URL. The url is absolute (string or
4148
+ * RegExp), or a PATH FORM starting with `/` `http.get('/articles/{{uuid}}')`
4149
+ * matches that path on ANY origin, which is what an app calling its own
4150
+ * backend needs. An optional {@link HttpContractFilter} narrows matching by
4151
+ * body, headers, or query — a request that hits the URL/method but fails the
4152
+ * filter counts as unmatched (strict contracts, CONVENTIONS D7).
4557
4153
  *
4558
4154
  * @example
4559
- * .intercept(http.get('https://api.example.com/data'), 'http/response.json')
4560
- * .intercept(http.post(URL, { body: { user: 'alice' } }), http.json({ ok: true }))
4155
+ * defineContract({ request: http.get('/articles/{{uuid}}'), response: http.json(article) })
4156
+ * defineContract({ request: http.post(URL, { body: { user: 'alice' } }), response: http.empty() })
4561
4157
  */
4562
4158
  const http = {
4159
+ any(url, filter) {
4160
+ return declare("*", url, filter);
4161
+ },
4162
+ delete(url, filter) {
4163
+ return declare("DELETE", url, filter);
4164
+ },
4563
4165
  get(url, filter) {
4564
- return {
4565
- adapter: "http",
4566
- match: buildMatch(filter),
4567
- method: "GET",
4568
- url,
4569
- wrap: wrapJson
4570
- };
4166
+ return declare("GET", url, filter);
4167
+ },
4168
+ patch(url, filter) {
4169
+ return declare("PATCH", url, filter);
4571
4170
  },
4572
4171
  post(url, filter) {
4573
- return {
4574
- adapter: "http",
4575
- match: buildMatch(filter),
4576
- method: "POST",
4577
- url,
4578
- wrap: wrapJson
4579
- };
4172
+ return declare("POST", url, filter);
4580
4173
  },
4581
4174
  put(url, filter) {
4582
- return {
4583
- adapter: "http",
4584
- match: buildMatch(filter),
4585
- method: "PUT",
4586
- url,
4587
- wrap: wrapJson
4588
- };
4175
+ return declare("PUT", url, filter);
4589
4176
  },
4590
- delete(url, filter) {
4177
+ /** Response: a body-less reply (204 by default). */
4178
+ empty(status = 204) {
4591
4179
  return {
4592
- adapter: "http",
4593
- match: buildMatch(filter),
4594
- method: "DELETE",
4595
- url,
4596
- wrap: wrapJson
4180
+ status,
4181
+ body: null
4597
4182
  };
4598
4183
  },
4599
- any(url, filter) {
4184
+ /** Response: an error status. Without a body, `{ error: 'HTTP <status>' }`. */
4185
+ error(status, body) {
4600
4186
  return {
4601
- adapter: "http",
4602
- match: buildMatch(filter),
4603
- method: "*",
4604
- url,
4605
- wrap: wrapJson
4187
+ status,
4188
+ body: body === void 0 ? { error: `HTTP ${status}` } : body
4606
4189
  };
4607
4190
  },
4608
- /** Response: simple JSON success. */
4609
- json(data, status = 200) {
4191
+ /** Response: a JSON body (200 by default). */
4192
+ json(body, init) {
4610
4193
  return {
4611
- status,
4612
- body: data
4194
+ status: init?.status ?? 200,
4195
+ body,
4196
+ delay: init?.delay,
4197
+ headers: init?.headers
4613
4198
  };
4614
4199
  },
4615
- /** Response: error with message. */
4616
- error(status, message) {
4200
+ /** Response: a text body, served as `text/plain` (200 by default). */
4201
+ text(body, init) {
4617
4202
  return {
4618
- status,
4619
- body: { error: message ?? `HTTP ${status}` }
4203
+ status: init?.status ?? 200,
4204
+ body,
4205
+ delay: init?.delay,
4206
+ headers: {
4207
+ "content-type": TEXT_CONTENT_TYPE,
4208
+ ...init?.headers
4209
+ }
4620
4210
  };
4621
4211
  }
4622
4212
  };
@@ -4625,21 +4215,9 @@ const http = {
4625
4215
  const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
4626
4216
  const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
4627
4217
  function matchesChatFilter(body, filter) {
4628
- if (filter.model) {
4629
- const model = body?.model;
4630
- if (typeof filter.model === "string" && model !== filter.model) return false;
4631
- if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
4632
- }
4633
- if (filter.system) {
4634
- const msg = body?.messages?.find((m) => m.role === "system")?.content ?? "";
4635
- if (typeof filter.system === "string" && !msg.includes(filter.system)) return false;
4636
- if (filter.system instanceof RegExp && !filter.system.test(msg)) return false;
4637
- }
4638
- if (filter.user) {
4639
- const msg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
4640
- if (typeof filter.user === "string" && !msg.includes(filter.user)) return false;
4641
- if (filter.user instanceof RegExp && !filter.user.test(msg)) return false;
4642
- }
4218
+ if (!matchesText(filter.model, body?.model ?? "")) return false;
4219
+ if (!matchesText(filter.system, body?.messages?.find((m) => m.role === "system")?.content ?? "")) return false;
4220
+ if (!matchesText(filter.user, body?.messages?.find((m) => m.role === "user")?.content ?? "")) return false;
4643
4221
  if (filter.tools) {
4644
4222
  const names = body?.tools?.map((t) => t.function?.name).filter(Boolean) ?? [];
4645
4223
  if (!filter.tools.every((t) => names.includes(t))) return false;
@@ -4648,23 +4226,11 @@ function matchesChatFilter(body, filter) {
4648
4226
  return true;
4649
4227
  }
4650
4228
  function matchesResponsesFilter(body, filter) {
4651
- if (filter.model) {
4652
- const model = body?.model;
4653
- if (typeof filter.model === "string" && model !== filter.model) return false;
4654
- if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
4655
- }
4656
- if (filter.system) {
4657
- const instructions = body?.instructions ?? "";
4658
- const systemInput = body?.input?.find?.((m) => m.role === "system")?.content ?? "";
4659
- const text = instructions || systemInput;
4660
- if (typeof filter.system === "string" && !text.includes(filter.system)) return false;
4661
- if (filter.system instanceof RegExp && !filter.system.test(text)) return false;
4662
- }
4663
- if (filter.user) {
4664
- const msgs = (body?.input ?? []).filter((m) => m.role === "user").map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join(" ");
4665
- if (typeof filter.user === "string" && !msgs.includes(filter.user)) return false;
4666
- if (filter.user instanceof RegExp && !filter.user.test(msgs)) return false;
4667
- }
4229
+ if (!matchesText(filter.model, body?.model ?? "")) return false;
4230
+ const systemInput = body?.input?.find?.((m) => m.role === "system")?.content ?? "";
4231
+ if (!matchesText(filter.system, body?.instructions || systemInput)) return false;
4232
+ const userText = (body?.input ?? []).filter((m) => m.role === "user").map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join(" ");
4233
+ if (!matchesText(filter.user, userText)) return false;
4668
4234
  if (filter.tools) {
4669
4235
  const names = body?.tools?.map((t) => t.name ?? t.function?.name).filter(Boolean) ?? [];
4670
4236
  if (!filter.tools.every((t) => names.includes(t))) return false;
@@ -4728,12 +4294,15 @@ function buildResponsesReply(data) {
4728
4294
  */
4729
4295
  const openai = {
4730
4296
  /**
4731
- * Trigger: match Chat Completions API requests.
4297
+ * Request: match Chat Completions API calls. STRING filters mean EXACT
4298
+ * equality (pass the app's own prompt builder); loosen deliberately with a
4299
+ * RegExp or `match.includes(...)`.
4732
4300
  *
4733
4301
  * @example
4734
- * openai.chat() // any chat call
4735
- * openai.chat({ model: 'gpt-4o' }) // specific model
4736
- * openai.chat({ system: /classify/ }) // system prompt match
4302
+ * openai.chat() // any chat call
4303
+ * openai.chat({ model: 'gpt-4o' }) // exact model
4304
+ * openai.chat({ system: buildPrompt() }) // exact system prompt
4305
+ * openai.chat({ system: /classify/ }) // pattern
4737
4306
  */
4738
4307
  chat(filter) {
4739
4308
  return {
@@ -4745,7 +4314,8 @@ const openai = {
4745
4314
  };
4746
4315
  },
4747
4316
  /**
4748
- * Trigger: match Responses API requests (AI SDK v5+) with auto-wrapping.
4317
+ * Request: match Responses API calls (AI SDK v5+) with auto-wrapping.
4318
+ * String filters mean EXACT equality (see {@link openai.chat}).
4749
4319
  * When used with a JSON file, the data is automatically wrapped in the
4750
4320
  * Responses API envelope.
4751
4321
  *
@@ -4796,35 +4366,6 @@ const openai = {
4796
4366
  }
4797
4367
  };
4798
4368
  //#endregion
4799
- //#region src/core/contracts/contract.ts
4800
- /**
4801
- * Declare an intercept contract. Identity function — its value is the
4802
- * enforced shape and the naming convention:
4803
- *
4804
- * @example
4805
- * // specs/api/reports/contracts/classify-article.openai.ts
4806
- * import { defineContract, openai } from '@jterrazz/test';
4807
- *
4808
- * export default defineContract({
4809
- * trigger: openai.responses({ user: /Report Ingestion/, tools: ['classify'] }),
4810
- * response: openai.reply({ categories: ['TECH'] }),
4811
- * });
4812
- *
4813
- * // Dynamic — the response is computed from the observed request:
4814
- * export default defineContract({
4815
- * trigger: http.post('https://api.example.com/echo'),
4816
- * response: (request) => http.json({ received: request.body }),
4817
- * });
4818
- *
4819
- * // specs/api/reports/reports.test.ts
4820
- * import classifyArticle from '../../spec/intercept/contracts/classify-article.openai.js';
4821
- *
4822
- * const result = await jobs.intercept(classifyArticle).trigger('report-ingestion');
4823
- */
4824
- function defineContract(contract) {
4825
- return contract;
4826
- }
4827
- //#endregion
4828
4369
  //#region src/vitest/mock-of.ts
4829
4370
  /**
4830
4371
  * Create a deep mock proxy for a given type.
@@ -4851,4 +4392,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
4851
4392
  }));
4852
4393
  registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
4853
4394
  //#endregion
4854
- export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, complementary, content, contentinfo, defineContract, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };
4395
+ export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, complementary, content, contentinfo, defineContract, defineContracts, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };