@jterrazz/test 10.1.0 → 12.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,6 +1,7 @@
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";
3
- import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
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, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { parse } from "yaml";
5
6
  import { execFile, execSync, spawn, spawnSync } from "node:child_process";
6
7
  import { Client } from "pg";
@@ -11,6 +12,7 @@ import { createServer } from "node:http";
11
12
  import { createServer as createServer$1 } from "node:net";
12
13
  import { promisify } from "node:util";
13
14
  import Database from "better-sqlite3";
15
+ import { createHash } from "node:crypto";
14
16
  import { mockDeep } from "vitest-mock-extended";
15
17
  import MockDatePackage from "mockdate";
16
18
  //#region src/core/specification/shared/registry.ts
@@ -520,298 +522,6 @@ function isJsonLike(text) {
520
522
  }
521
523
  }
522
524
  //#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
525
  //#region src/core/specification/shared/reporter.ts
816
526
  const GREEN = "\x1B[32m";
817
527
  const RED = "\x1B[31m";
@@ -1081,13 +791,55 @@ var FilesystemAccessor = class {
1081
791
  };
1082
792
  //#endregion
1083
793
  //#region src/core/specification/shared/caller.ts
794
+ /** Sibling module tests are callers, not internals (CONVENTIONS I2). */
795
+ const TEST_FILE = /\.test\.[cm]?[jt]s$/;
796
+ /** Where this very module sits inside the framework's SOURCE tree. */
797
+ const SOURCE_LOCATION = join("core", "specification", "shared");
798
+ /** The real path of `path`, or `path` itself when it cannot be resolved. */
799
+ function realPath(path) {
800
+ try {
801
+ return realpathSync(path);
802
+ } catch {
803
+ return path;
804
+ }
805
+ }
1084
806
  /**
1085
807
  * Directory of the running framework module itself. From the built package
1086
808
  * this is `<package root>/dist`, whatever the resolution path — including a
1087
809
  * `file:` link, where the real path carries neither `node_modules` nor
1088
810
  * `/src/…` and would otherwise be mistaken for a caller frame.
1089
811
  */
1090
- const FRAMEWORK_DIR = dirname(fileURLToPath(import.meta.url));
812
+ const FRAMEWORK_DIR = realPath(dirname(fileURLToPath(import.meta.url)));
813
+ /**
814
+ * The directory holding the framework's OWN modules: `<package>/dist` for a
815
+ * built install (everything is bundled there), `<repo>/src` when the framework
816
+ * runs from source — this module's known location inside that tree is what
817
+ * tells the two apart.
818
+ */
819
+ const FRAMEWORK_TREE = FRAMEWORK_DIR.endsWith(`${sep}${SOURCE_LOCATION}`) ? resolve(FRAMEWORK_DIR, "../../..") : FRAMEWORK_DIR;
820
+ /** Is `path` inside `directory` (strictly — a directory is not inside itself)? */
821
+ function isInside(directory, path) {
822
+ const rest = relative(directory, path);
823
+ return rest !== "" && !rest.startsWith("..") && !isAbsolute(rest);
824
+ }
825
+ /**
826
+ * Is this stack frame a module of the framework itself, rather than of the code
827
+ * calling it?
828
+ *
829
+ * Answered by IDENTITY: the frame is inside the framework's own directory.
830
+ * Answering it by substring — `/src/core/`, `/src/integrations/`, `/src/vitest/`
831
+ * — was answering a different question, "does this path look like the framework's
832
+ * layout", and a consumer that happens to have its own `src/core/` matched it.
833
+ * Its files were then skipped as framework internals, and fixture resolution
834
+ * anchored on the wrong directory (or fell through to the cwd) with nothing
835
+ * reported.
836
+ *
837
+ * @internal
838
+ */
839
+ function isFrameworkFrame(filePath, frameworkTree = FRAMEWORK_TREE) {
840
+ if (TEST_FILE.test(filePath)) return false;
841
+ return isInside(realPath(frameworkTree), realPath(filePath));
842
+ }
1091
843
  /**
1092
844
  * Detect the directory of the first stack frame that lives outside this
1093
845
  * package. Used to anchor fixture resolution (`seeds/`, `requests/`,
@@ -1104,7 +856,7 @@ function getCallerDir() {
1104
856
  if (!match?.groups?.filePath) continue;
1105
857
  const filePath = match.groups.filePath;
1106
858
  if (filePath.includes("node_modules")) continue;
1107
- if ((filePath.includes("/src/core/") || filePath.includes("/src/integrations/") || filePath.includes("/src/vitest/") || resolve(filePath, "..") === FRAMEWORK_DIR) && !/\.test\.[cm]?[jt]s$/.test(filePath)) continue;
859
+ if (isFrameworkFrame(filePath)) continue;
1108
860
  return resolve(filePath, "..");
1109
861
  }
1110
862
  return process.cwd();
@@ -1990,128 +1742,6 @@ async function registerMatchers() {
1990
1742
  }
1991
1743
  }
1992
1744
  //#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
1745
  //#region src/core/specification/api/result.ts
2116
1746
  /** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
2117
1747
  var HttpResult = class extends BaseResult {
@@ -2410,11 +2040,10 @@ function copyPlan(path, testDir, workDir) {
2410
2040
  * only surfaces the methods that make sense for it.
2411
2041
  */
2412
2042
  var SpecificationBuilder = class {
2413
- backendExchanges = [];
2414
2043
  commandEnv = {};
2415
2044
  config;
2045
+ contracts = [];
2416
2046
  fixtures = [];
2417
- intercepts = [];
2418
2047
  requestHeaders = {};
2419
2048
  seeds = [];
2420
2049
  testDir;
@@ -2488,57 +2117,21 @@ var SpecificationBuilder = class {
2488
2117
  };
2489
2118
  return this;
2490
2119
  }
2491
- intercept(triggerOrContracts, maybeResponse) {
2120
+ intercept(requestOrContracts, maybeResponse) {
2492
2121
  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) {
2122
+ if ((this.config.baseUrl !== void 0 || this.config.device !== void 0) && !this.config.backend) {
2533
2123
  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.`);
2124
+ 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
2125
  }
2536
- const exchanges = parseInterceptFile(readFileSync(resolve(this.testDir, "intercepts", file), "utf8"), `intercepts/${file}`);
2537
- if (stubFacet) {
2538
- this.backendExchanges.push(...exchanges);
2126
+ if (Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts)) {
2127
+ this.contracts.push(...contractsOf(requestOrContracts));
2539
2128
  return this;
2540
2129
  }
2541
- this.intercepts.push(...interceptEntriesOf(exchanges));
2130
+ 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).");
2131
+ this.contracts.push({
2132
+ request: requestOrContracts,
2133
+ response: maybeResponse
2134
+ });
2542
2135
  return this;
2543
2136
  }
2544
2137
  /**
@@ -2715,11 +2308,11 @@ var SpecificationBuilder = class {
2715
2308
  cpSync(src, dest, { recursive: true });
2716
2309
  }
2717
2310
  let registration = null;
2718
- if (this.intercepts.length > 0) {
2719
- const { registerIntercepts } = await import("./intercept.js");
2720
- registration = await registerIntercepts(this.intercepts);
2311
+ if (this.config.backend) this.config.backend.beginChain(this.contracts);
2312
+ else if (this.contracts.length > 0) {
2313
+ const { registerContracts } = await import("./intercept.js");
2314
+ registration = await registerContracts(this.contracts);
2721
2315
  }
2722
- this.config.backend?.beginChain(this.backendExchanges);
2723
2316
  try {
2724
2317
  const value = await action();
2725
2318
  const violation = registration?.violation() ?? this.config.backend?.violation();
@@ -2895,6 +2488,14 @@ var SpecificationBuilder = class {
2895
2488
  });
2896
2489
  }
2897
2490
  };
2491
+ /**
2492
+ * The facet-level `.intercept()`: both overloads forwarded to a fresh chain.
2493
+ * The pair form is recognised by its second argument — a bare
2494
+ * {@link ContractRequest} never arrives alone.
2495
+ */
2496
+ function interceptOn(start) {
2497
+ return (contractsOrRequest, response) => response === void 0 ? start().intercept(contractsOrRequest) : start().intercept(contractsOrRequest, response);
2498
+ }
2898
2499
  function withDockerTestRunId(config) {
2899
2500
  if (config.dockerConfig && !config.dockerTestRunId) return {
2900
2501
  ...config,
@@ -2912,7 +2513,7 @@ function createApiFacet(config) {
2912
2513
  delete: (path) => start().delete(path),
2913
2514
  get: (path) => start().get(path),
2914
2515
  headers: (headers) => start().headers(headers),
2915
- intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) || typeof triggerOrContracts === "string" ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2516
+ intercept: interceptOn(start),
2916
2517
  post: (path, body) => start().post(path, body),
2917
2518
  put: (path, body) => start().put(path, body),
2918
2519
  request: (file) => start().request(file),
@@ -2925,7 +2526,7 @@ function createApiFacet(config) {
2925
2526
  function createJobsFacet(config) {
2926
2527
  const start = () => new SpecificationBuilder(config, getCallerDir());
2927
2528
  return {
2928
- intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) || typeof triggerOrContracts === "string" ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2529
+ intercept: interceptOn(start),
2929
2530
  seed: (file, options) => start().seed(file, options),
2930
2531
  trigger: (name) => start().trigger(name)
2931
2532
  };
@@ -2938,7 +2539,7 @@ function createWebsiteFacet(config) {
2938
2539
  return {
2939
2540
  fetch: (path) => start().fetch(path),
2940
2541
  headers: (headers) => start().headers(headers),
2941
- intercept: (file) => start().intercept(file),
2542
+ intercept: interceptOn(start),
2942
2543
  visit: (path, scenario) => start().visit(path, scenario)
2943
2544
  };
2944
2545
  }
@@ -2948,7 +2549,7 @@ function createWebsiteFacet(config) {
2948
2549
  function createMobileFacet(config) {
2949
2550
  const start = () => new SpecificationBuilder(config, getCallerDir());
2950
2551
  return {
2951
- intercept: (file) => start().intercept(file),
2552
+ intercept: interceptOn(start),
2952
2553
  open: (deepLink, scenario) => start().open(deepLink, scenario)
2953
2554
  };
2954
2555
  }
@@ -3025,7 +2626,7 @@ var Orchestrator = class {
3025
2626
  constructor(options) {
3026
2627
  this.services = options.services;
3027
2628
  this.mode = options.mode;
3028
- this.root = options.root ?? process.cwd();
2629
+ this.root = options.root;
3029
2630
  this.projectName = options.projectName;
3030
2631
  }
3031
2632
  /**
@@ -3246,23 +2847,39 @@ var Orchestrator = class {
3246
2847
  };
3247
2848
  //#endregion
3248
2849
  //#region src/core/specification/shared/resolve.ts
2850
+ /** What makes a directory a project root — a manifest, or a test stack. */
2851
+ const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
2852
+ /**
2853
+ * The NEAREST ancestor of `startDir` (itself included) carrying a root marker,
2854
+ * `undefined` when none does.
2855
+ *
2856
+ * Both markers are probed at each ancestor, in ONE walk. Two walks — the
2857
+ * compose file all the way up, then `package.json` all the way up — made the
2858
+ * further directory win: in a workspace, a `docker/compose.test.yaml` at the
2859
+ * repository root outranked the `package.json` of the very package being
2860
+ * tested, and every path the runner resolved was measured from the wrong unit.
2861
+ * The unit is the nearest thing that declares itself a project.
2862
+ *
2863
+ * The existence probe is injected so the lint layer can pass its cached one
2864
+ * (A9's `a9w-redundant-root` derives the same root, and must derive it the
2865
+ * same way).
2866
+ */
2867
+ function findRoot(startDir, exists) {
2868
+ let dir = startDir;
2869
+ for (;;) {
2870
+ if (ROOT_MARKERS.some((marker) => exists(resolve(dir, marker)))) return dir;
2871
+ const parent = dirname(dir);
2872
+ if (parent === dir) return;
2873
+ dir = parent;
2874
+ }
2875
+ }
3249
2876
  /**
3250
2877
  * Auto-discover the project root from a starting directory (CONVENTIONS A9):
3251
- * walk up to the first directory containing `docker/compose.test.yaml`;
3252
- * if none, walk up to the first directory containing `package.json`;
3253
- * if none, the starting directory itself.
2878
+ * walk up to the first directory that carries `package.json` or
2879
+ * `docker/compose.test.yaml`; if none does, the starting directory itself.
3254
2880
  */
3255
2881
  function discoverRoot(startDir) {
3256
- for (const marker of ["docker/compose.test.yaml", "package.json"]) {
3257
- let dir = startDir;
3258
- for (;;) {
3259
- if (existsSync(resolve(dir, marker))) return dir;
3260
- const parent = dirname(dir);
3261
- if (parent === dir) break;
3262
- dir = parent;
3263
- }
3264
- }
3265
- return startDir;
2882
+ return findRoot(startDir, existsSync) ?? startDir;
3266
2883
  }
3267
2884
  /**
3268
2885
  * Resolve the project root for a specification. An explicit `root` option is
@@ -3651,21 +3268,20 @@ async function startJobs(options) {
3651
3268
  /**
3652
3269
  * The declared stub backend — a small `node:http` server the website and
3653
3270
  * 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.
3271
+ * contracts the CURRENT chain declared via `.intercept(...)`: one chain = one
3272
+ * terminal action, and the stub resets between chains the way databases do.
3657
3273
  *
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.
3274
+ * Selection is the shared {@link ContractQueue} — the same first-matching,
3275
+ * `times`-aware queue the MSW engine runs on api/jobs chains. A contract
3276
+ * without `times` keeps replying (re-render, retry); `times: n` is spent after
3277
+ * n serves.
3663
3278
  *
3664
3279
  * 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.
3280
+ * request matching no contract is answered 501 and RECORDED; once the chain
3281
+ * declared at least one contract, the terminal action throws an error
3282
+ * enumerating every unmatched request — and every `required` contract that was
3283
+ * never requested. Chains with zero contracts leave the stub unguarded —
3284
+ * mirroring how MSW stays off without them.
3669
3285
  *
3670
3286
  * Every response (including the 501 and the OPTIONS preflight) carries
3671
3287
  * permissive CORS headers — a website's client-side fetches are cross-origin
@@ -3674,11 +3290,10 @@ async function startJobs(options) {
3674
3290
  const CORS_METHODS = "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS";
3675
3291
  const PREFLIGHT_MAX_AGE = "600";
3676
3292
  var StubBackend = class {
3677
- consumed = [];
3678
- entries = [];
3679
- /** Guarded once the current chain declared at least one intercept. */
3293
+ /** Guarded once the current chain declared at least one contract. */
3680
3294
  guarded = false;
3681
3295
  options;
3296
+ queue = new ContractQueue([]);
3682
3297
  server = null;
3683
3298
  unmatchedRequests = /* @__PURE__ */ new Map();
3684
3299
  url = "";
@@ -3711,26 +3326,27 @@ var StubBackend = class {
3711
3326
  });
3712
3327
  }
3713
3328
  /**
3714
- * Arm the stub for one chain: the declared exchanges replace the previous
3715
- * chain's wholesale, consumption restarts, and the unmatched log clears —
3329
+ * Arm the stub for one chain: the declared contracts replace the previous
3330
+ * chain's wholesale, the queue restarts, and the unmatched log clears —
3716
3331
  * the reset-between-chains databases already follow.
3717
3332
  */
3718
- beginChain(exchanges) {
3719
- this.consumed = exchanges.map(() => false);
3720
- this.entries = exchanges;
3721
- this.guarded = exchanges.length > 0;
3333
+ beginChain(contracts) {
3334
+ this.guarded = contracts.length > 0;
3335
+ this.queue = new ContractQueue(contracts);
3722
3336
  this.unmatchedRequests.clear();
3723
3337
  }
3724
3338
  /**
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.
3339
+ * The strict failure for the chain, or null. Non-null when the chain
3340
+ * declared at least one contract AND either an unmatched request was
3341
+ * recorded, or a `required` contract was never satisfied. Enumerates every
3342
+ * unmatched request (method, path, count) plus the declared routes, so the
3343
+ * missing contract writes itself.
3729
3344
  */
3730
3345
  violation() {
3731
- if (!this.guarded || this.unmatchedRequests.size === 0) return null;
3346
+ if (!this.guarded) return null;
3347
+ if (this.unmatchedRequests.size === 0) return this.queue.requiredError();
3732
3348
  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.`);
3349
+ 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
3350
  }
3735
3351
  corsHeaders(request) {
3736
3352
  return {
@@ -3741,10 +3357,11 @@ var StubBackend = class {
3741
3357
  };
3742
3358
  }
3743
3359
  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");
3360
+ const routes = this.queue.declaredRoutes();
3361
+ if (routes.length === 0) return " (no contracts declared)";
3362
+ return routes.map((route) => ` - ${route}`).join("\n");
3746
3363
  }
3747
- handle(request, response) {
3364
+ async handle(request, response) {
3748
3365
  const method = (request.method ?? "GET").toUpperCase();
3749
3366
  const cors = this.corsHeaders(request);
3750
3367
  if (method === "OPTIONS") {
@@ -3755,28 +3372,33 @@ var StubBackend = class {
3755
3372
  const headers = {};
3756
3373
  for (const [name, value] of Object.entries(request.headers)) if (typeof value === "string") headers[name.toLowerCase()] = value;
3757
3374
  const url = request.url ?? "/";
3758
- const entry = this.take({
3375
+ const observed = {
3376
+ body: await readBody(request),
3759
3377
  headers,
3760
3378
  method,
3761
3379
  url
3762
- });
3763
- if (!entry) {
3380
+ };
3381
+ const contract = this.queue.take(observed);
3382
+ if (!contract) {
3764
3383
  this.record(method, url);
3765
3384
  response.writeHead(501, {
3766
3385
  ...cors,
3767
3386
  "content-type": "application/json"
3768
3387
  });
3769
3388
  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}`
3389
+ declared: this.queue.declaredRoutes(),
3390
+ error: `@jterrazz/test declared backend: no contract matches ${method} ${url}`
3772
3391
  }));
3773
3392
  return;
3774
3393
  }
3775
- const { body, hasBody, status } = entry.response;
3776
- if (!hasBody) {
3394
+ const reply = typeof contract.response === "function" ? contract.response(observed) : contract.response;
3395
+ if (reply.delay) await new Promise((resolve) => setTimeout(resolve, reply.delay));
3396
+ const status = reply.status ?? 200;
3397
+ const { body } = reply;
3398
+ if (body === null || body === void 0) {
3777
3399
  response.writeHead(status, {
3778
3400
  ...cors,
3779
- ...entry.response.headers
3401
+ ...reply.headers
3780
3402
  });
3781
3403
  response.end();
3782
3404
  return;
@@ -3786,7 +3408,7 @@ var StubBackend = class {
3786
3408
  response.writeHead(status, {
3787
3409
  ...cors,
3788
3410
  "content-type": contentType,
3789
- ...entry.response.headers
3411
+ ...reply.headers
3790
3412
  });
3791
3413
  response.end(payload);
3792
3414
  }
@@ -3800,25 +3422,19 @@ var StubBackend = class {
3800
3422
  path
3801
3423
  });
3802
3424
  }
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
3425
  };
3426
+ /** Read the request body — parsed JSON when it is JSON, raw text otherwise. */
3427
+ async function readBody(request) {
3428
+ const chunks = [];
3429
+ for await (const chunk of request) chunks.push(chunk);
3430
+ const raw = Buffer.concat(chunks).toString("utf8");
3431
+ if (raw.length === 0) return null;
3432
+ try {
3433
+ return JSON.parse(raw);
3434
+ } catch {
3435
+ return raw;
3436
+ }
3437
+ }
3822
3438
  //#endregion
3823
3439
  //#region src/core/specification/website/serve.adapter.ts
3824
3440
  /** Grace period between SIGTERM and the SIGKILL escalation. */
@@ -4280,6 +3896,88 @@ const within = (scope, target) => ({
4280
3896
  });
4281
3897
  //#endregion
4282
3898
  //#region src/integrations/sqlite/sqlite.ts
3899
+ const SQLITE_FILE_HEADER = Buffer.from("SQLite format 3\0");
3900
+ /**
3901
+ * Whether `path` looks like a usable SQLite database file: it exists, is at
3902
+ * least as long as the format header, and begins with the SQLite magic
3903
+ * bytes. Does not validate anything beyond the header — good enough to
3904
+ * reject a 0-byte or truncated leftover without opening the file.
3905
+ */
3906
+ function isValidSqliteTemplate(path) {
3907
+ if (!existsSync(path)) return false;
3908
+ let fd;
3909
+ try {
3910
+ fd = openSync(path, "r");
3911
+ } catch {
3912
+ return false;
3913
+ }
3914
+ try {
3915
+ const header = Buffer.alloc(SQLITE_FILE_HEADER.length);
3916
+ return readSync(fd, header, 0, header.length, 0) === SQLITE_FILE_HEADER.length && header.equals(SQLITE_FILE_HEADER);
3917
+ } catch {
3918
+ return false;
3919
+ } finally {
3920
+ closeSync(fd);
3921
+ }
3922
+ }
3923
+ /** The invocation used when no `prismaSchema` is declared — Prisma discovers its own schema. */
3924
+ const PRISMA_PUSH = "npx prisma db push --force-reset";
3925
+ /**
3926
+ * The `prisma db push` command line for a `prismaSchema` option.
3927
+ *
3928
+ * The declared path is resolved against `process.cwd()` and handed to the CLI
3929
+ * as `--schema`. Without the flag Prisma falls back to its own discovery
3930
+ * (`prisma.config.ts` / `package.json#prisma` at the cwd), which silently
3931
+ * ignores the option: a repo whose config does not sit at the cwd then fails
3932
+ * with "Could not find Prisma Schema that is required for this command" — but
3933
+ * only on a machine without a cached template, so the divergence hides until CI.
3934
+ *
3935
+ * `null` (no option) keeps the bare invocation, discovery and all.
3936
+ */
3937
+ function prismaPushCommand(prismaSchema) {
3938
+ if (!prismaSchema) return PRISMA_PUSH;
3939
+ return `${PRISMA_PUSH} --schema ${JSON.stringify(resolve(prismaSchema))}`;
3940
+ }
3941
+ /** Every template file this package writes carries that prefix, then its key. */
3942
+ const TEMPLATE_PREFIX = "jterrazz-test-sqlite-template";
3943
+ /**
3944
+ * The schema source as bytes: a file's content, or every file under a
3945
+ * directory in a stable order (a Prisma `schema/` folder is a directory). Each
3946
+ * entry carries its own path, so moving a schema changes the fingerprint.
3947
+ * Anything unreadable degrades to a marker rather than throwing — the path is
3948
+ * already in the key, so two projects still never collide.
3949
+ */
3950
+ function schemaBytes(path) {
3951
+ try {
3952
+ if (statSync(path).isDirectory()) return readdirSync(path).sort().map((entry) => schemaBytes(join(path, entry))).join("\0");
3953
+ return `${path}\0${readFileSync(path, "utf8")}`;
3954
+ } catch {
3955
+ return `${path}\0<unreadable>`;
3956
+ }
3957
+ }
3958
+ /**
3959
+ * The name of the template database for a set of options — `<prefix>-<sha8>`,
3960
+ * the digest taken over the schema KIND, its resolved path and its content.
3961
+ *
3962
+ * The template lives in the machine-global OS tmpdir, so the name is the only
3963
+ * thing keeping two projects apart. A single fixed name meant the first project
3964
+ * to run on a machine built the template and every other project that day
3965
+ * silently inherited its tables — the file is header-valid, so the staleness
3966
+ * check accepts it and the schema never gets built. Keying on the schema makes
3967
+ * reuse mean "the same schema", which is what the cache was ever for.
3968
+ */
3969
+ function sqliteTemplateName(options = {}) {
3970
+ let fingerprint;
3971
+ if (options.prismaSchema) {
3972
+ const path = resolve(options.prismaSchema);
3973
+ fingerprint = `prisma\0${path}\0${schemaBytes(path)}`;
3974
+ } else if (options.init) {
3975
+ const path = resolve(options.init);
3976
+ fingerprint = `init\0${path}\0${schemaBytes(path)}`;
3977
+ } else fingerprint = "empty";
3978
+ const key = createHash("sha256").update(fingerprint).digest("hex").slice(0, 8);
3979
+ return `${TEMPLATE_PREFIX}-${key}.sqlite`;
3980
+ }
4283
3981
  var SqliteHandle = class {
4284
3982
  type = "sqlite";
4285
3983
  composeName = null;
@@ -4293,7 +3991,9 @@ var SqliteHandle = class {
4293
3991
  workerDbPath = "";
4294
3992
  initSql;
4295
3993
  prismaSchema;
3994
+ options;
4296
3995
  constructor(options = {}) {
3996
+ this.options = options;
4297
3997
  this.initSql = options.init ?? null;
4298
3998
  this.prismaSchema = options.prismaSchema ?? null;
4299
3999
  }
@@ -4305,22 +4005,27 @@ var SqliteHandle = class {
4305
4005
  }
4306
4006
  async healthcheck() {}
4307
4007
  async initialize() {
4308
- this.templatePath = resolve(tmpdir(), "jterrazz-test-sqlite-template.sqlite");
4008
+ this.templatePath = resolve(tmpdir(), sqliteTemplateName(this.options));
4309
4009
  const lockPath = `${this.templatePath}.lock`;
4310
4010
  if (existsSync(lockPath)) {
4311
4011
  const start = Date.now();
4312
4012
  while (existsSync(lockPath) && Date.now() - start < 3e4) await new Promise((r) => setTimeout(r, 100));
4313
4013
  }
4314
4014
  if (existsSync(this.templatePath)) {
4315
- this.connectionString = `file:${this.templatePath}`;
4316
- this.started = true;
4317
- return;
4015
+ if (isValidSqliteTemplate(this.templatePath)) {
4016
+ this.connectionString = `file:${this.templatePath}`;
4017
+ this.started = true;
4018
+ return;
4019
+ }
4020
+ try {
4021
+ unlinkSync(this.templatePath);
4022
+ } catch {}
4318
4023
  }
4319
4024
  const { writeFileSync } = await import("node:fs");
4320
4025
  writeFileSync(lockPath, process.pid.toString());
4321
4026
  if (this.prismaSchema) {
4322
4027
  const { execSync } = await import("node:child_process");
4323
- execSync("npx prisma db push --force-reset", {
4028
+ execSync(prismaPushCommand(this.prismaSchema), {
4324
4029
  env: {
4325
4030
  ...process.env,
4326
4031
  DATABASE_URL: `file:${this.templatePath}`,
@@ -4406,25 +4111,22 @@ function sqlite(options = {}) {
4406
4111
  return new SqliteHandle(options);
4407
4112
  }
4408
4113
  //#endregion
4114
+ //#region src/core/contracts/filters.ts
4115
+ /** Does an observed text satisfy a declared {@link TextFilter}? */
4116
+ function matchesText(filter, actual) {
4117
+ if (filter === void 0) return true;
4118
+ if (typeof filter === "string") return actual === filter;
4119
+ if (filter instanceof RegExp) return filter.test(actual);
4120
+ return structuralEquals(filter, actual, new CaptureScope());
4121
+ }
4122
+ //#endregion
4409
4123
  //#region src/integrations/anthropic/anthropic.ts
4410
4124
  const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
4411
4125
  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
- }
4126
+ if (!matchesText(filter.model, body?.model ?? "")) return false;
4127
+ if (!matchesText(filter.system, typeof body?.system === "string" ? body.system : "")) return false;
4128
+ const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
4129
+ if (!matchesText(filter.user, typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg))) return false;
4428
4130
  if (filter.tools) {
4429
4131
  const names = body?.tools?.map((t) => t.name).filter(Boolean) ?? [];
4430
4132
  if (!filter.tools.every((t) => names.includes(t))) return false;
@@ -4456,7 +4158,7 @@ function buildReply(data) {
4456
4158
  */
4457
4159
  const anthropic = {
4458
4160
  /**
4459
- * Trigger: match Messages API requests, optionally routed through a
4161
+ * Request: match Messages API calls, optionally routed through a
4460
4162
  * custom gateway URL. When used with a JSON fixture file, the data is
4461
4163
  * returned as-is (no wrapping) because Anthropic fixtures are typically
4462
4164
  * already in the Messages API response shape.
@@ -4464,6 +4166,7 @@ const anthropic = {
4464
4166
  * @example
4465
4167
  * anthropic.messages()
4466
4168
  * anthropic.messages({ system: /classify/ })
4169
+ * anthropic.messages({ user: buildPrompt() }) // string = EXACT equality
4467
4170
  * anthropic.messages({ user: /classify/ }, GATEWAY)
4468
4171
  */
4469
4172
  messages(filter, url) {
@@ -4513,6 +4216,7 @@ function wrapJson(data) {
4513
4216
  body: data
4514
4217
  };
4515
4218
  }
4219
+ const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
4516
4220
  function matchesBody(body, expected) {
4517
4221
  if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
4518
4222
  if (expected instanceof RegExp) {
@@ -4529,7 +4233,7 @@ function matchesEntries(expected, lookup) {
4529
4233
  });
4530
4234
  }
4531
4235
  /**
4532
- * Build the `match` predicate for an HTTP trigger filter, or `undefined` when
4236
+ * Build the `match` predicate for an HTTP request filter, or `undefined` when
4533
4237
  * no filter is supplied (fires on any URL/method match).
4534
4238
  */
4535
4239
  function buildMatch(filter) {
@@ -4540,7 +4244,7 @@ function buildMatch(filter) {
4540
4244
  if (filter.query) {
4541
4245
  let params;
4542
4246
  try {
4543
- params = new URL(request.url).searchParams;
4247
+ params = new URL(request.url, "http://contract.invalid").searchParams;
4544
4248
  } catch {
4545
4249
  return false;
4546
4250
  }
@@ -4549,74 +4253,79 @@ function buildMatch(filter) {
4549
4253
  return true;
4550
4254
  };
4551
4255
  }
4256
+ function declare(method, url, filter) {
4257
+ return {
4258
+ adapter: "http",
4259
+ match: buildMatch(filter),
4260
+ method,
4261
+ url,
4262
+ wrap: wrapJson
4263
+ };
4264
+ }
4552
4265
  /**
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).
4266
+ * Generic HTTP contract helpers for any URL. The url is absolute (string or
4267
+ * RegExp), or a PATH FORM starting with `/` — `http.get('/articles/{{uuid}}')`
4268
+ * matches that path on ANY origin, which is what an app calling its own
4269
+ * backend needs. An optional {@link HttpContractFilter} narrows matching by
4270
+ * body, headers, or query — a request that hits the URL/method but fails the
4271
+ * filter counts as unmatched (strict contracts, CONVENTIONS D7).
4557
4272
  *
4558
4273
  * @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 }))
4274
+ * defineContract({ request: http.get('/articles/{{uuid}}'), response: http.json(article) })
4275
+ * defineContract({ request: http.post(URL, { body: { user: 'alice' } }), response: http.empty() })
4561
4276
  */
4562
4277
  const http = {
4278
+ any(url, filter) {
4279
+ return declare("*", url, filter);
4280
+ },
4281
+ delete(url, filter) {
4282
+ return declare("DELETE", url, filter);
4283
+ },
4563
4284
  get(url, filter) {
4564
- return {
4565
- adapter: "http",
4566
- match: buildMatch(filter),
4567
- method: "GET",
4568
- url,
4569
- wrap: wrapJson
4570
- };
4285
+ return declare("GET", url, filter);
4286
+ },
4287
+ patch(url, filter) {
4288
+ return declare("PATCH", url, filter);
4571
4289
  },
4572
4290
  post(url, filter) {
4573
- return {
4574
- adapter: "http",
4575
- match: buildMatch(filter),
4576
- method: "POST",
4577
- url,
4578
- wrap: wrapJson
4579
- };
4291
+ return declare("POST", url, filter);
4580
4292
  },
4581
4293
  put(url, filter) {
4582
- return {
4583
- adapter: "http",
4584
- match: buildMatch(filter),
4585
- method: "PUT",
4586
- url,
4587
- wrap: wrapJson
4588
- };
4294
+ return declare("PUT", url, filter);
4589
4295
  },
4590
- delete(url, filter) {
4296
+ /** Response: a body-less reply (204 by default). */
4297
+ empty(status = 204) {
4591
4298
  return {
4592
- adapter: "http",
4593
- match: buildMatch(filter),
4594
- method: "DELETE",
4595
- url,
4596
- wrap: wrapJson
4299
+ status,
4300
+ body: null
4597
4301
  };
4598
4302
  },
4599
- any(url, filter) {
4303
+ /** Response: an error status. Without a body, `{ error: 'HTTP <status>' }`. */
4304
+ error(status, body) {
4600
4305
  return {
4601
- adapter: "http",
4602
- match: buildMatch(filter),
4603
- method: "*",
4604
- url,
4605
- wrap: wrapJson
4306
+ status,
4307
+ body: body === void 0 ? { error: `HTTP ${status}` } : body
4606
4308
  };
4607
4309
  },
4608
- /** Response: simple JSON success. */
4609
- json(data, status = 200) {
4310
+ /** Response: a JSON body (200 by default). */
4311
+ json(body, init) {
4610
4312
  return {
4611
- status,
4612
- body: data
4313
+ status: init?.status ?? 200,
4314
+ body,
4315
+ delay: init?.delay,
4316
+ headers: init?.headers
4613
4317
  };
4614
4318
  },
4615
- /** Response: error with message. */
4616
- error(status, message) {
4319
+ /** Response: a text body, served as `text/plain` (200 by default). */
4320
+ text(body, init) {
4617
4321
  return {
4618
- status,
4619
- body: { error: message ?? `HTTP ${status}` }
4322
+ status: init?.status ?? 200,
4323
+ body,
4324
+ delay: init?.delay,
4325
+ headers: {
4326
+ "content-type": TEXT_CONTENT_TYPE,
4327
+ ...init?.headers
4328
+ }
4620
4329
  };
4621
4330
  }
4622
4331
  };
@@ -4625,21 +4334,9 @@ const http = {
4625
4334
  const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
4626
4335
  const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
4627
4336
  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
- }
4337
+ if (!matchesText(filter.model, body?.model ?? "")) return false;
4338
+ if (!matchesText(filter.system, body?.messages?.find((m) => m.role === "system")?.content ?? "")) return false;
4339
+ if (!matchesText(filter.user, body?.messages?.find((m) => m.role === "user")?.content ?? "")) return false;
4643
4340
  if (filter.tools) {
4644
4341
  const names = body?.tools?.map((t) => t.function?.name).filter(Boolean) ?? [];
4645
4342
  if (!filter.tools.every((t) => names.includes(t))) return false;
@@ -4648,23 +4345,11 @@ function matchesChatFilter(body, filter) {
4648
4345
  return true;
4649
4346
  }
4650
4347
  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
- }
4348
+ if (!matchesText(filter.model, body?.model ?? "")) return false;
4349
+ const systemInput = body?.input?.find?.((m) => m.role === "system")?.content ?? "";
4350
+ if (!matchesText(filter.system, body?.instructions || systemInput)) return false;
4351
+ const userText = (body?.input ?? []).filter((m) => m.role === "user").map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join(" ");
4352
+ if (!matchesText(filter.user, userText)) return false;
4668
4353
  if (filter.tools) {
4669
4354
  const names = body?.tools?.map((t) => t.name ?? t.function?.name).filter(Boolean) ?? [];
4670
4355
  if (!filter.tools.every((t) => names.includes(t))) return false;
@@ -4728,12 +4413,15 @@ function buildResponsesReply(data) {
4728
4413
  */
4729
4414
  const openai = {
4730
4415
  /**
4731
- * Trigger: match Chat Completions API requests.
4416
+ * Request: match Chat Completions API calls. STRING filters mean EXACT
4417
+ * equality (pass the app's own prompt builder); loosen deliberately with a
4418
+ * RegExp or `match.includes(...)`.
4732
4419
  *
4733
4420
  * @example
4734
- * openai.chat() // any chat call
4735
- * openai.chat({ model: 'gpt-4o' }) // specific model
4736
- * openai.chat({ system: /classify/ }) // system prompt match
4421
+ * openai.chat() // any chat call
4422
+ * openai.chat({ model: 'gpt-4o' }) // exact model
4423
+ * openai.chat({ system: buildPrompt() }) // exact system prompt
4424
+ * openai.chat({ system: /classify/ }) // pattern
4737
4425
  */
4738
4426
  chat(filter) {
4739
4427
  return {
@@ -4745,7 +4433,8 @@ const openai = {
4745
4433
  };
4746
4434
  },
4747
4435
  /**
4748
- * Trigger: match Responses API requests (AI SDK v5+) with auto-wrapping.
4436
+ * Request: match Responses API calls (AI SDK v5+) with auto-wrapping.
4437
+ * String filters mean EXACT equality (see {@link openai.chat}).
4749
4438
  * When used with a JSON file, the data is automatically wrapped in the
4750
4439
  * Responses API envelope.
4751
4440
  *
@@ -4796,35 +4485,6 @@ const openai = {
4796
4485
  }
4797
4486
  };
4798
4487
  //#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
4488
  //#region src/vitest/mock-of.ts
4829
4489
  /**
4830
4490
  * Create a deep mock proxy for a given type.
@@ -4851,4 +4511,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
4851
4511
  }));
4852
4512
  registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
4853
4513
  //#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 };
4514
+ 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 };