@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/README.md +34 -20
- package/dist/appium.adapter.js +23 -10
- package/dist/checker.js +0 -1
- package/dist/index.d.ts +227 -156
- package/dist/index.js +344 -684
- package/dist/intercept.js +37 -84
- package/dist/match.js +12 -1
- package/dist/oxlint.cjs +641 -238
- package/dist/oxlint.js +641 -238
- package/dist/queue.js +555 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { i as match, n as Matcher,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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(
|
|
2120
|
+
intercept(requestOrContracts, maybeResponse) {
|
|
2492
2121
|
if (this.config.interceptDisabledReason) throw new Error(`.intercept(): ${this.config.interceptDisabledReason}`);
|
|
2493
|
-
if (
|
|
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
|
|
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
|
-
|
|
2537
|
-
|
|
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
|
-
|
|
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.
|
|
2719
|
-
|
|
2720
|
-
|
|
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: (
|
|
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: (
|
|
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: (
|
|
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: (
|
|
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
|
|
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
|
|
3252
|
-
* if none,
|
|
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
|
-
|
|
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
|
-
*
|
|
3655
|
-
*
|
|
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
|
-
*
|
|
3659
|
-
* queue
|
|
3660
|
-
*
|
|
3661
|
-
*
|
|
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
|
|
3666
|
-
*
|
|
3667
|
-
*
|
|
3668
|
-
*
|
|
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
|
-
|
|
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
|
|
3715
|
-
* chain's wholesale,
|
|
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(
|
|
3719
|
-
this.
|
|
3720
|
-
this.
|
|
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
|
|
3726
|
-
* declared at least one
|
|
3727
|
-
*
|
|
3728
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
3745
|
-
|
|
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
|
|
3375
|
+
const observed = {
|
|
3376
|
+
body: await readBody(request),
|
|
3759
3377
|
headers,
|
|
3760
3378
|
method,
|
|
3761
3379
|
url
|
|
3762
|
-
}
|
|
3763
|
-
|
|
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.
|
|
3771
|
-
error: `@jterrazz/test declared backend: no
|
|
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
|
|
3776
|
-
if (
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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(),
|
|
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.
|
|
4316
|
-
|
|
4317
|
-
|
|
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(
|
|
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
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
4554
|
-
*
|
|
4555
|
-
*
|
|
4556
|
-
*
|
|
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
|
-
*
|
|
4560
|
-
*
|
|
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
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
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
|
-
|
|
4296
|
+
/** Response: a body-less reply (204 by default). */
|
|
4297
|
+
empty(status = 204) {
|
|
4591
4298
|
return {
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
method: "DELETE",
|
|
4595
|
-
url,
|
|
4596
|
-
wrap: wrapJson
|
|
4299
|
+
status,
|
|
4300
|
+
body: null
|
|
4597
4301
|
};
|
|
4598
4302
|
},
|
|
4599
|
-
|
|
4303
|
+
/** Response: an error status. Without a body, `{ error: 'HTTP <status>' }`. */
|
|
4304
|
+
error(status, body) {
|
|
4600
4305
|
return {
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
method: "*",
|
|
4604
|
-
url,
|
|
4605
|
-
wrap: wrapJson
|
|
4306
|
+
status,
|
|
4307
|
+
body: body === void 0 ? { error: `HTTP ${status}` } : body
|
|
4606
4308
|
};
|
|
4607
4309
|
},
|
|
4608
|
-
/** Response:
|
|
4609
|
-
json(
|
|
4310
|
+
/** Response: a JSON body (200 by default). */
|
|
4311
|
+
json(body, init) {
|
|
4610
4312
|
return {
|
|
4611
|
-
status,
|
|
4612
|
-
body
|
|
4313
|
+
status: init?.status ?? 200,
|
|
4314
|
+
body,
|
|
4315
|
+
delay: init?.delay,
|
|
4316
|
+
headers: init?.headers
|
|
4613
4317
|
};
|
|
4614
4318
|
},
|
|
4615
|
-
/** Response:
|
|
4616
|
-
|
|
4319
|
+
/** Response: a text body, served as `text/plain` (200 by default). */
|
|
4320
|
+
text(body, init) {
|
|
4617
4321
|
return {
|
|
4618
|
-
status,
|
|
4619
|
-
body
|
|
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
|
-
|
|
4630
|
-
|
|
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
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
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
|
-
*
|
|
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()
|
|
4735
|
-
* openai.chat({ model: 'gpt-4o' })
|
|
4736
|
-
* openai.chat({ system:
|
|
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
|
-
*
|
|
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 };
|