@jterrazz/test 10.0.0 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -35
- package/dist/ambiguity.js +126 -0
- package/dist/appium.adapter.js +399 -0
- package/dist/checker.js +0 -1
- package/dist/index.d.ts +500 -101
- package/dist/index.js +587 -464
- package/dist/intercept.js +37 -84
- package/dist/match.js +12 -1
- package/dist/oxlint.cjs +420 -117
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +420 -117
- package/dist/playwright.adapter.js +3 -127
- package/dist/queue.js +555 -0
- package/package.json +13 -4
package/dist/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { i as match, n as Matcher,
|
|
2
|
-
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, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { parse } from "yaml";
|
|
5
|
-
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { execFile, execSync, spawn, spawnSync } from "node:child_process";
|
|
6
7
|
import { Client } from "pg";
|
|
7
8
|
import { readdir } from "node:fs/promises";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { tmpdir } from "node:os";
|
|
10
|
-
import { createServer } from "node:
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { createServer as createServer$1 } from "node:net";
|
|
13
|
+
import { promisify } from "node:util";
|
|
11
14
|
import Database from "better-sqlite3";
|
|
12
15
|
import { mockDeep } from "vitest-mock-extended";
|
|
13
16
|
import MockDatePackage from "mockdate";
|
|
@@ -518,289 +521,6 @@ function isJsonLike(text) {
|
|
|
518
521
|
}
|
|
519
522
|
}
|
|
520
523
|
//#endregion
|
|
521
|
-
//#region src/core/matching/structural.ts
|
|
522
|
-
/**
|
|
523
|
-
* Structural comparison engine shared by every fixture matcher.
|
|
524
|
-
*
|
|
525
|
-
* Handles three kinds of "expected" values:
|
|
526
|
-
* - plain JSON values → strict deep equality
|
|
527
|
-
* - {@link Matcher} instances (code-side `match.*`)
|
|
528
|
-
* - strings containing `{{placeholder}}` forms (file-side fixtures)
|
|
529
|
-
*
|
|
530
|
-
* One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
|
|
531
|
-
* works in `expected/*.http` (body and headers), `expected/*.json`, and
|
|
532
|
-
* text snapshots (`expected/*.txt`).
|
|
533
|
-
*
|
|
534
|
-
* Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
|
|
535
|
-
* {@link CaptureScope} supplied by the caller.
|
|
536
|
-
*/
|
|
537
|
-
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}";
|
|
538
|
-
const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
|
|
539
|
-
const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
|
|
540
|
-
const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
|
|
541
|
-
const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
|
|
542
|
-
const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
|
|
543
|
-
const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
|
|
544
|
-
const INT_SOURCE = String.raw`-?\d+`;
|
|
545
|
-
const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
|
|
546
|
-
const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
|
|
547
|
-
const SHA_SOURCE = "[0-9a-f]{7,64}";
|
|
548
|
-
const HEX_SOURCE = "[0-9a-fA-F]+";
|
|
549
|
-
const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
|
|
550
|
-
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})`;
|
|
551
|
-
const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
|
|
552
|
-
const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
|
|
553
|
-
const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
|
|
554
|
-
const EMBEDDED_SOURCES = {
|
|
555
|
-
base64: BASE64_SOURCE,
|
|
556
|
-
date: DATE_SOURCE,
|
|
557
|
-
duration: DURATION_SOURCE,
|
|
558
|
-
email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
|
|
559
|
-
float: FLOAT_SOURCE,
|
|
560
|
-
hex: HEX_SOURCE,
|
|
561
|
-
int: INT_SOURCE,
|
|
562
|
-
ip: IP_SOURCE,
|
|
563
|
-
iso8601: ISO8601_SOURCE,
|
|
564
|
-
number: NUMBER_SOURCE,
|
|
565
|
-
path: String.raw`\.{0,2}\/[^\s"'<>]*`,
|
|
566
|
-
port: PORT_SOURCE,
|
|
567
|
-
semver: SEMVER_SOURCE,
|
|
568
|
-
sha: SHA_SOURCE,
|
|
569
|
-
time: TIME_SOURCE,
|
|
570
|
-
ulid: ULID_SOURCE,
|
|
571
|
-
url: URL_SOURCE,
|
|
572
|
-
uuid: UUID_SOURCE
|
|
573
|
-
};
|
|
574
|
-
const wholeRe = (source) => new RegExp(`^(?:${source})$`);
|
|
575
|
-
const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
|
|
576
|
-
const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "g");
|
|
577
|
-
/** Whether a fixture string contains at least one `{{placeholder}}`. */
|
|
578
|
-
function hasPlaceholders(value) {
|
|
579
|
-
PLACEHOLDER_RE.lastIndex = 0;
|
|
580
|
-
return PLACEHOLDER_RE.test(value);
|
|
581
|
-
}
|
|
582
|
-
/** Key-order-independent stringification for captured-object comparison. */
|
|
583
|
-
function stableStringify(value) {
|
|
584
|
-
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
585
|
-
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(",")}}`;
|
|
586
|
-
return JSON.stringify(value) ?? "undefined";
|
|
587
|
-
}
|
|
588
|
-
function capturedEquals(a, b) {
|
|
589
|
-
if (Object.is(a, b)) return true;
|
|
590
|
-
if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
|
|
591
|
-
if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
|
|
592
|
-
return false;
|
|
593
|
-
}
|
|
594
|
-
function recordRef(name, actual, scope) {
|
|
595
|
-
if (scope.has(name)) return capturedEquals(scope.get(name), actual);
|
|
596
|
-
scope.set(name, actual);
|
|
597
|
-
return true;
|
|
598
|
-
}
|
|
599
|
-
function isPortValue(value) {
|
|
600
|
-
return Number.isInteger(value) && value >= 0 && value <= 65535;
|
|
601
|
-
}
|
|
602
|
-
function kindMatches(kind, actual, scope) {
|
|
603
|
-
switch (kind) {
|
|
604
|
-
case "any": return true;
|
|
605
|
-
case "float":
|
|
606
|
-
if (typeof actual === "number") return Number.isFinite(actual);
|
|
607
|
-
return typeof actual === "string" && WHOLE_RES.float.test(actual);
|
|
608
|
-
case "int":
|
|
609
|
-
if (typeof actual === "number") return Number.isInteger(actual);
|
|
610
|
-
return typeof actual === "string" && WHOLE_RES.int.test(actual);
|
|
611
|
-
case "number":
|
|
612
|
-
if (typeof actual === "number") return Number.isFinite(actual);
|
|
613
|
-
return typeof actual === "string" && WHOLE_RES.number.test(actual);
|
|
614
|
-
case "port":
|
|
615
|
-
if (typeof actual === "number") return isPortValue(actual);
|
|
616
|
-
return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
|
|
617
|
-
case "string": return typeof actual === "string";
|
|
618
|
-
case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
|
|
619
|
-
default: {
|
|
620
|
-
const re = WHOLE_RES[kind];
|
|
621
|
-
return re !== void 0 && typeof actual === "string" && re.test(actual);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
function matcherMatches(matcher, actual, scope) {
|
|
626
|
-
if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
|
|
627
|
-
if (matcher.kind === "ref") {
|
|
628
|
-
if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
|
|
629
|
-
return recordRef(matcher.refName, actual, scope);
|
|
630
|
-
}
|
|
631
|
-
return kindMatches(matcher.kind, actual, scope);
|
|
632
|
-
}
|
|
633
|
-
function placeholderSource(kind, scope) {
|
|
634
|
-
if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
|
|
635
|
-
const source = EMBEDDED_SOURCES[kind];
|
|
636
|
-
if (source) return source;
|
|
637
|
-
return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
|
|
638
|
-
}
|
|
639
|
-
function escapeRegExp(text) {
|
|
640
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
641
|
-
}
|
|
642
|
-
function parsePlaceholderString(expected, scope) {
|
|
643
|
-
PLACEHOLDER_RE.lastIndex = 0;
|
|
644
|
-
const refs = [];
|
|
645
|
-
let pattern = "^";
|
|
646
|
-
let lastIndex = 0;
|
|
647
|
-
let single = null;
|
|
648
|
-
let count = 0;
|
|
649
|
-
for (const found of expected.matchAll(PLACEHOLDER_RE)) {
|
|
650
|
-
const kind = found.groups.kind;
|
|
651
|
-
const ref = found.groups.ref;
|
|
652
|
-
pattern += escapeRegExp(expected.slice(lastIndex, found.index));
|
|
653
|
-
pattern += `(${placeholderSource(kind, scope)})`;
|
|
654
|
-
refs.push({
|
|
655
|
-
index: count,
|
|
656
|
-
kind,
|
|
657
|
-
ref
|
|
658
|
-
});
|
|
659
|
-
count++;
|
|
660
|
-
lastIndex = found.index + found[0].length;
|
|
661
|
-
if (found.index === 0 && found[0].length === expected.length) single = {
|
|
662
|
-
kind,
|
|
663
|
-
ref
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
pattern += `${escapeRegExp(expected.slice(lastIndex))}$`;
|
|
667
|
-
return {
|
|
668
|
-
pattern: new RegExp(pattern),
|
|
669
|
-
refs,
|
|
670
|
-
single
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
function placeholderStringMatches(expected, actual, scope) {
|
|
674
|
-
const parsed = parsePlaceholderString(expected, scope);
|
|
675
|
-
if (parsed.single) {
|
|
676
|
-
if (!kindMatches(parsed.single.kind, actual, scope)) return false;
|
|
677
|
-
if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
|
|
678
|
-
return true;
|
|
679
|
-
}
|
|
680
|
-
if (typeof actual !== "string" && typeof actual !== "number") return false;
|
|
681
|
-
const text = String(actual);
|
|
682
|
-
const found = parsed.pattern.exec(text);
|
|
683
|
-
if (!found) return false;
|
|
684
|
-
for (const entry of parsed.refs) {
|
|
685
|
-
if (!entry.ref) continue;
|
|
686
|
-
if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
|
|
687
|
-
}
|
|
688
|
-
return true;
|
|
689
|
-
}
|
|
690
|
-
function isPlainObject(value) {
|
|
691
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
692
|
-
}
|
|
693
|
-
/**
|
|
694
|
-
* Deep structural equality with matcher / placeholder support. Ref captures
|
|
695
|
-
* are recorded in `scope` in traversal order (arrays left-to-right, object
|
|
696
|
-
* keys in expected-key order).
|
|
697
|
-
*/
|
|
698
|
-
function structuralEquals(expected, actual, scope) {
|
|
699
|
-
if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
|
|
700
|
-
if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
|
|
701
|
-
if (Array.isArray(expected)) {
|
|
702
|
-
if (!Array.isArray(actual) || actual.length !== expected.length) return false;
|
|
703
|
-
return expected.every((item, i) => structuralEquals(item, actual[i], scope));
|
|
704
|
-
}
|
|
705
|
-
if (isPlainObject(expected)) {
|
|
706
|
-
if (!isPlainObject(actual)) return false;
|
|
707
|
-
const expectedKeys = Object.keys(expected);
|
|
708
|
-
const actualKeys = Object.keys(actual);
|
|
709
|
-
if (expectedKeys.length !== actualKeys.length) return false;
|
|
710
|
-
return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
|
|
711
|
-
}
|
|
712
|
-
return Object.is(expected, actual);
|
|
713
|
-
}
|
|
714
|
-
/**
|
|
715
|
-
* Deep structural SUBSET match (toMatchObject-style) with matcher /
|
|
716
|
-
* placeholder support. Plain objects match when every expected key is present
|
|
717
|
-
* and recursively subset-matches — the actual value may carry extra keys.
|
|
718
|
-
* Arrays require equal length with each element subset-matched. Leaves fall
|
|
719
|
-
* back to {@link structuralEquals} semantics (matchers, placeholders, strict
|
|
720
|
-
* equality). Used by request-body filters on intercept triggers.
|
|
721
|
-
*/
|
|
722
|
-
function structuralSubset(expected, actual, scope) {
|
|
723
|
-
if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
|
|
724
|
-
if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
|
|
725
|
-
if (Array.isArray(expected)) {
|
|
726
|
-
if (!Array.isArray(actual) || actual.length !== expected.length) return false;
|
|
727
|
-
return expected.every((item, i) => structuralSubset(item, actual[i], scope));
|
|
728
|
-
}
|
|
729
|
-
if (isPlainObject(expected)) {
|
|
730
|
-
if (!isPlainObject(actual)) return false;
|
|
731
|
-
return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
|
|
732
|
-
}
|
|
733
|
-
return Object.is(expected, actual);
|
|
734
|
-
}
|
|
735
|
-
/**
|
|
736
|
-
* Multi-line text comparison with `{{token}}` support — used by text
|
|
737
|
-
* snapshots (`expected/*.txt`). Without placeholders this is strict equality.
|
|
738
|
-
*/
|
|
739
|
-
function textEquals(expected, actual, scope) {
|
|
740
|
-
if (!hasPlaceholders(expected)) return expected === actual;
|
|
741
|
-
return placeholderStringMatches(expected, actual, scope);
|
|
742
|
-
}
|
|
743
|
-
/**
|
|
744
|
-
* Render an expected value for failure diffs and serialization: Matcher
|
|
745
|
-
* instances become their placeholder text, everything else is untouched.
|
|
746
|
-
*/
|
|
747
|
-
function renderExpected(value) {
|
|
748
|
-
if (value instanceof Matcher) return value.toString();
|
|
749
|
-
if (Array.isArray(value)) return value.map((item) => renderExpected(item));
|
|
750
|
-
if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
|
|
751
|
-
return value;
|
|
752
|
-
}
|
|
753
|
-
/**
|
|
754
|
-
* Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
|
|
755
|
-
* every string leaf of a structured value. The structural mirror of the text
|
|
756
|
-
* path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
|
|
757
|
-
* `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
|
|
758
|
-
* D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
|
|
759
|
-
*/
|
|
760
|
-
function substituteWorkdirDeep(value, workdir) {
|
|
761
|
-
if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
|
|
762
|
-
if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
|
|
763
|
-
if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
|
|
764
|
-
return value;
|
|
765
|
-
}
|
|
766
|
-
/**
|
|
767
|
-
* Update-mode merge: rewrite a fixture from the actual output while
|
|
768
|
-
* preserving every segment of the previous fixture that was covered by a
|
|
769
|
-
* placeholder (and still matches the actual value).
|
|
770
|
-
*
|
|
771
|
-
* Token preservation is symmetric with the text path (CONVENTIONS D5): a
|
|
772
|
-
* previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
|
|
773
|
-
* RAW actual value is kept; every other leaf is taken from the actual output
|
|
774
|
-
* with the framework-known cwd substituted back to `{{workdir}}`.
|
|
775
|
-
*/
|
|
776
|
-
function mergePreservingPlaceholders(previous, actual, workdir) {
|
|
777
|
-
const scope = new CaptureScope(workdir);
|
|
778
|
-
const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
|
|
779
|
-
function merge(prev, act) {
|
|
780
|
-
if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
|
|
781
|
-
if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
|
|
782
|
-
if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
|
|
783
|
-
return subst(act);
|
|
784
|
-
}
|
|
785
|
-
return merge(previous, actual);
|
|
786
|
-
}
|
|
787
|
-
/**
|
|
788
|
-
* Update-mode merge for text snapshots: line-by-line, a previous line whose
|
|
789
|
-
* placeholders still match the actual line is preserved; every other line is
|
|
790
|
-
* taken from the actual output. Values the framework knows to be dynamic
|
|
791
|
-
* (`{{workdir}}`) are substituted automatically (CONVENTIONS D5).
|
|
792
|
-
*/
|
|
793
|
-
function mergeTextPreservingPlaceholders(previous, actual, scope) {
|
|
794
|
-
const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
|
|
795
|
-
if (previous === null) return substituted;
|
|
796
|
-
const prevLines = previous.split("\n");
|
|
797
|
-
return substituted.split("\n").map((line, i) => {
|
|
798
|
-
const prev = prevLines[i];
|
|
799
|
-
if (prev === void 0 || !hasPlaceholders(prev)) return line;
|
|
800
|
-
return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, actual.split("\n")[i] ?? line, new CaptureScope(scope.workdir)) ? prev : line;
|
|
801
|
-
}).join("\n");
|
|
802
|
-
}
|
|
803
|
-
//#endregion
|
|
804
524
|
//#region src/core/specification/shared/reporter.ts
|
|
805
525
|
const GREEN = "\x1B[32m";
|
|
806
526
|
const RED = "\x1B[31m";
|
|
@@ -1997,6 +1717,30 @@ var HttpResult = class extends BaseResult {
|
|
|
1997
1717
|
}
|
|
1998
1718
|
};
|
|
1999
1719
|
//#endregion
|
|
1720
|
+
//#region src/core/specification/mobile/result.ts
|
|
1721
|
+
/** Result from a `.open()` action — the screen as the device saw it, final state. */
|
|
1722
|
+
var ScreenResult = class extends BaseResult {
|
|
1723
|
+
capturedScreen;
|
|
1724
|
+
constructor(options) {
|
|
1725
|
+
super(options);
|
|
1726
|
+
this.capturedScreen = options.screen;
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* The visible screen texts as one stream, in reading order — the scalpel
|
|
1730
|
+
* for a targeted probe: `expect(result.content).toContain('Bookmarked')`.
|
|
1731
|
+
*/
|
|
1732
|
+
get content() {
|
|
1733
|
+
return new TextAccessor(this.capturedScreen.texts.join("\n"), "content", this.testDir, { captures: this.captures });
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* The projected accessibility tree as a JSON accessor — the one golden
|
|
1737
|
+
* per screen: `expect(result.screen).toMatch('events.screen.json')`.
|
|
1738
|
+
*/
|
|
1739
|
+
get screen() {
|
|
1740
|
+
return new JsonAccessor(JSON.stringify(this.capturedScreen.tree), this.testDir, void 0, this.captures);
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1743
|
+
//#endregion
|
|
2000
1744
|
//#region src/core/specification/website/result.ts
|
|
2001
1745
|
/** Result from a raw `.fetch()` action (robots.txt, sitemaps, redirects). */
|
|
2002
1746
|
var FetchResult = class extends BaseResult {
|
|
@@ -2255,8 +1999,8 @@ function copyPlan(path, testDir, workDir) {
|
|
|
2255
1999
|
var SpecificationBuilder = class {
|
|
2256
2000
|
commandEnv = {};
|
|
2257
2001
|
config;
|
|
2002
|
+
contracts = [];
|
|
2258
2003
|
fixtures = [];
|
|
2259
|
-
intercepts = [];
|
|
2260
2004
|
requestHeaders = {};
|
|
2261
2005
|
seeds = [];
|
|
2262
2006
|
testDir;
|
|
@@ -2330,36 +2074,22 @@ var SpecificationBuilder = class {
|
|
|
2330
2074
|
};
|
|
2331
2075
|
return this;
|
|
2332
2076
|
}
|
|
2333
|
-
intercept(
|
|
2077
|
+
intercept(requestOrContracts, maybeResponse) {
|
|
2334
2078
|
if (this.config.interceptDisabledReason) throw new Error(`.intercept(): ${this.config.interceptDisabledReason}`);
|
|
2335
|
-
if (
|
|
2336
|
-
|
|
2079
|
+
if ((this.config.baseUrl !== void 0 || this.config.device !== void 0) && !this.config.backend) {
|
|
2080
|
+
const facet = this.config.device === void 0 ? "website" : "mobile";
|
|
2081
|
+
throw new Error(`.intercept(): this runner has no declared backend — add \`backend\` to the specification options (specification.${facet}({ …, backend: { … } })) so the chain's contracts have a stub to serve them.`);
|
|
2082
|
+
}
|
|
2083
|
+
if (Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts)) {
|
|
2084
|
+
this.contracts.push(...contractsOf(requestOrContracts));
|
|
2337
2085
|
return this;
|
|
2338
2086
|
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
registerIntercept(triggerOrContract, maybeResponse) {
|
|
2344
|
-
const isContract = "trigger" in triggerOrContract && "response" in triggerOrContract;
|
|
2345
|
-
const trigger = isContract ? triggerOrContract.trigger : triggerOrContract;
|
|
2346
|
-
const response = isContract ? triggerOrContract.response : maybeResponse;
|
|
2347
|
-
if (typeof response === "string") {
|
|
2348
|
-
const slashIndex = response.indexOf("/");
|
|
2349
|
-
if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
|
|
2350
|
-
const adapterName = response.slice(0, slashIndex);
|
|
2351
|
-
if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
|
|
2352
|
-
const filePath = resolve(this.testDir, "intercepts", response);
|
|
2353
|
-
const data = JSON.parse(readFileSync(filePath, "utf8"));
|
|
2354
|
-
const resolved = trigger.wrap(data);
|
|
2355
|
-
this.intercepts.push({
|
|
2356
|
-
trigger,
|
|
2357
|
-
response: resolved
|
|
2358
|
-
});
|
|
2359
|
-
} else this.intercepts.push({
|
|
2360
|
-
trigger,
|
|
2361
|
-
response
|
|
2087
|
+
if (maybeResponse === void 0) throw new Error(".intercept(): a bare request needs its response — pass a contract (defineContract({ request, response })) or the inline pair .intercept(request, response).");
|
|
2088
|
+
this.contracts.push({
|
|
2089
|
+
request: requestOrContracts,
|
|
2090
|
+
response: maybeResponse
|
|
2362
2091
|
});
|
|
2092
|
+
return this;
|
|
2363
2093
|
}
|
|
2364
2094
|
/**
|
|
2365
2095
|
* Send the complete request described by `requests/<file>` — first line
|
|
@@ -2479,6 +2209,24 @@ var SpecificationBuilder = class {
|
|
|
2479
2209
|
return this.executeSetup(null, () => this.runVisitAction(path, scenario));
|
|
2480
2210
|
}
|
|
2481
2211
|
/**
|
|
2212
|
+
* Terminate and relaunch the app on the simulator, apply the deep link,
|
|
2213
|
+
* run the scenario, and resolve with the captured final screen — the
|
|
2214
|
+
* projected accessibility tree and the visible texts. One driver session
|
|
2215
|
+
* per runner; every open starts from a deterministic fresh app state.
|
|
2216
|
+
*
|
|
2217
|
+
* @example
|
|
2218
|
+
* const result = await mobile.open('news://events');
|
|
2219
|
+
* expect(result.screen).toMatch('events.screen.json');
|
|
2220
|
+
*
|
|
2221
|
+
* const result = await mobile.open('news://events', async (visitor) => {
|
|
2222
|
+
* await visitor.tap(button('Enquête Fauci COVID-19'));
|
|
2223
|
+
* await visitor.see(content('rapports'));
|
|
2224
|
+
* });
|
|
2225
|
+
*/
|
|
2226
|
+
open(deepLink, scenario) {
|
|
2227
|
+
return this.executeSetup(null, () => this.runOpenAction(deepLink, scenario));
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2482
2230
|
* Execute the named job registered via the `jobs` option of
|
|
2483
2231
|
* `specification.jobs()` and resolve with the result.
|
|
2484
2232
|
*
|
|
@@ -2517,17 +2265,18 @@ var SpecificationBuilder = class {
|
|
|
2517
2265
|
cpSync(src, dest, { recursive: true });
|
|
2518
2266
|
}
|
|
2519
2267
|
let registration = null;
|
|
2520
|
-
if (this.
|
|
2521
|
-
|
|
2522
|
-
|
|
2268
|
+
if (this.config.backend) this.config.backend.beginChain(this.contracts);
|
|
2269
|
+
else if (this.contracts.length > 0) {
|
|
2270
|
+
const { registerContracts } = await import("./intercept.js");
|
|
2271
|
+
registration = await registerContracts(this.contracts);
|
|
2523
2272
|
}
|
|
2524
2273
|
try {
|
|
2525
2274
|
const value = await action();
|
|
2526
|
-
const violation = registration?.violation();
|
|
2275
|
+
const violation = registration?.violation() ?? this.config.backend?.violation();
|
|
2527
2276
|
if (violation) throw violation;
|
|
2528
2277
|
return value;
|
|
2529
2278
|
} catch (error) {
|
|
2530
|
-
throw registration?.violation() ?? error;
|
|
2279
|
+
throw registration?.violation() ?? this.config.backend?.violation() ?? error;
|
|
2531
2280
|
} finally {
|
|
2532
2281
|
registration?.cleanup();
|
|
2533
2282
|
}
|
|
@@ -2595,6 +2344,7 @@ var SpecificationBuilder = class {
|
|
|
2595
2344
|
const browser = await this.config.browser();
|
|
2596
2345
|
const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
|
|
2597
2346
|
const page = await browser.open(`${baseUrl}${path}`, {
|
|
2347
|
+
allowedOrigins: this.config.backendUrl ? [new URL(this.config.backendUrl).origin] : void 0,
|
|
2598
2348
|
baseUrl,
|
|
2599
2349
|
external: this.config.external ?? "allow",
|
|
2600
2350
|
headers,
|
|
@@ -2606,6 +2356,19 @@ var SpecificationBuilder = class {
|
|
|
2606
2356
|
testDir: this.testDir
|
|
2607
2357
|
});
|
|
2608
2358
|
}
|
|
2359
|
+
async runOpenAction(deepLink, scenario) {
|
|
2360
|
+
if (!this.config.device || this.config.bundleId === void 0) throw new Error(".open() requires a device adapter (use specification.mobile())");
|
|
2361
|
+
const screen = await (await this.config.device()).open({
|
|
2362
|
+
bundleId: this.config.bundleId,
|
|
2363
|
+
deepLink,
|
|
2364
|
+
scenario
|
|
2365
|
+
});
|
|
2366
|
+
return new ScreenResult({
|
|
2367
|
+
config: this.config,
|
|
2368
|
+
screen,
|
|
2369
|
+
testDir: this.testDir
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2609
2372
|
requireBaseUrl(method) {
|
|
2610
2373
|
if (!this.config.baseUrl) throw new Error(`.${method}() requires a website under test (use specification.website())`);
|
|
2611
2374
|
return this.config.baseUrl;
|
|
@@ -2682,6 +2445,14 @@ var SpecificationBuilder = class {
|
|
|
2682
2445
|
});
|
|
2683
2446
|
}
|
|
2684
2447
|
};
|
|
2448
|
+
/**
|
|
2449
|
+
* The facet-level `.intercept()`: both overloads forwarded to a fresh chain.
|
|
2450
|
+
* The pair form is recognised by its second argument — a bare
|
|
2451
|
+
* {@link ContractRequest} never arrives alone.
|
|
2452
|
+
*/
|
|
2453
|
+
function interceptOn(start) {
|
|
2454
|
+
return (contractsOrRequest, response) => response === void 0 ? start().intercept(contractsOrRequest) : start().intercept(contractsOrRequest, response);
|
|
2455
|
+
}
|
|
2685
2456
|
function withDockerTestRunId(config) {
|
|
2686
2457
|
if (config.dockerConfig && !config.dockerTestRunId) return {
|
|
2687
2458
|
...config,
|
|
@@ -2699,7 +2470,7 @@ function createApiFacet(config) {
|
|
|
2699
2470
|
delete: (path) => start().delete(path),
|
|
2700
2471
|
get: (path) => start().get(path),
|
|
2701
2472
|
headers: (headers) => start().headers(headers),
|
|
2702
|
-
intercept: (
|
|
2473
|
+
intercept: interceptOn(start),
|
|
2703
2474
|
post: (path, body) => start().post(path, body),
|
|
2704
2475
|
put: (path, body) => start().put(path, body),
|
|
2705
2476
|
request: (file) => start().request(file),
|
|
@@ -2712,7 +2483,7 @@ function createApiFacet(config) {
|
|
|
2712
2483
|
function createJobsFacet(config) {
|
|
2713
2484
|
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2714
2485
|
return {
|
|
2715
|
-
intercept: (
|
|
2486
|
+
intercept: interceptOn(start),
|
|
2716
2487
|
seed: (file, options) => start().seed(file, options),
|
|
2717
2488
|
trigger: (name) => start().trigger(name)
|
|
2718
2489
|
};
|
|
@@ -2725,10 +2496,21 @@ function createWebsiteFacet(config) {
|
|
|
2725
2496
|
return {
|
|
2726
2497
|
fetch: (path) => start().fetch(path),
|
|
2727
2498
|
headers: (headers) => start().headers(headers),
|
|
2499
|
+
intercept: interceptOn(start),
|
|
2728
2500
|
visit: (path, scenario) => start().visit(path, scenario)
|
|
2729
2501
|
};
|
|
2730
2502
|
}
|
|
2731
2503
|
/**
|
|
2504
|
+
* Create the `mobile` facet bound to the given adapter configuration.
|
|
2505
|
+
*/
|
|
2506
|
+
function createMobileFacet(config) {
|
|
2507
|
+
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2508
|
+
return {
|
|
2509
|
+
intercept: interceptOn(start),
|
|
2510
|
+
open: (deepLink, scenario) => start().open(deepLink, scenario)
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
2732
2514
|
* Create the `cli` facet bound to the given adapter configuration.
|
|
2733
2515
|
*/
|
|
2734
2516
|
function createCliFacet(config) {
|
|
@@ -3423,6 +3205,178 @@ async function startJobs(options) {
|
|
|
3423
3205
|
};
|
|
3424
3206
|
}
|
|
3425
3207
|
//#endregion
|
|
3208
|
+
//#region src/core/specification/shared/stub-backend.ts
|
|
3209
|
+
/**
|
|
3210
|
+
* The declared stub backend — a small `node:http` server the website and
|
|
3211
|
+
* mobile runners start when their `backend` option is present. It serves the
|
|
3212
|
+
* contracts the CURRENT chain declared via `.intercept(...)`: one chain = one
|
|
3213
|
+
* terminal action, and the stub resets between chains the way databases do.
|
|
3214
|
+
*
|
|
3215
|
+
* Selection is the shared {@link ContractQueue} — the same first-matching,
|
|
3216
|
+
* `times`-aware queue the MSW engine runs on api/jobs chains. A contract
|
|
3217
|
+
* without `times` keeps replying (re-render, retry); `times: n` is spent after
|
|
3218
|
+
* n serves.
|
|
3219
|
+
*
|
|
3220
|
+
* Strictness is the `external: 'block'` analog (CONVENTIONS D7 in spirit): a
|
|
3221
|
+
* request matching no contract is answered 501 and RECORDED; once the chain
|
|
3222
|
+
* declared at least one contract, the terminal action throws an error
|
|
3223
|
+
* enumerating every unmatched request — and every `required` contract that was
|
|
3224
|
+
* never requested. Chains with zero contracts leave the stub unguarded —
|
|
3225
|
+
* mirroring how MSW stays off without them.
|
|
3226
|
+
*
|
|
3227
|
+
* Every response (including the 501 and the OPTIONS preflight) carries
|
|
3228
|
+
* permissive CORS headers — a website's client-side fetches are cross-origin
|
|
3229
|
+
* to the stub by construction.
|
|
3230
|
+
*/
|
|
3231
|
+
const CORS_METHODS = "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS";
|
|
3232
|
+
const PREFLIGHT_MAX_AGE = "600";
|
|
3233
|
+
var StubBackend = class {
|
|
3234
|
+
/** Guarded once the current chain declared at least one contract. */
|
|
3235
|
+
guarded = false;
|
|
3236
|
+
options;
|
|
3237
|
+
queue = new ContractQueue([]);
|
|
3238
|
+
server = null;
|
|
3239
|
+
unmatchedRequests = /* @__PURE__ */ new Map();
|
|
3240
|
+
url = "";
|
|
3241
|
+
constructor(options = {}) {
|
|
3242
|
+
this.options = options;
|
|
3243
|
+
}
|
|
3244
|
+
/** Start the server and resolve with its base URL. */
|
|
3245
|
+
async start() {
|
|
3246
|
+
const server = createServer((request, response) => {
|
|
3247
|
+
this.handle(request, response);
|
|
3248
|
+
});
|
|
3249
|
+
this.server = server;
|
|
3250
|
+
await new Promise((resolve, reject) => {
|
|
3251
|
+
server.once("error", reject);
|
|
3252
|
+
server.listen(this.options.port ?? 0, "127.0.0.1", () => resolve());
|
|
3253
|
+
});
|
|
3254
|
+
const address = server.address();
|
|
3255
|
+
if (address === null || typeof address === "string") throw new Error("stub backend: could not determine the listening port");
|
|
3256
|
+
this.url = `http://127.0.0.1:${address.port}`;
|
|
3257
|
+
return this.url;
|
|
3258
|
+
}
|
|
3259
|
+
/** Stop the server (idempotent). */
|
|
3260
|
+
async stop() {
|
|
3261
|
+
const server = this.server;
|
|
3262
|
+
if (!server) return;
|
|
3263
|
+
this.server = null;
|
|
3264
|
+
server.closeAllConnections();
|
|
3265
|
+
await new Promise((resolve) => {
|
|
3266
|
+
server.close(() => resolve());
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3269
|
+
/**
|
|
3270
|
+
* Arm the stub for one chain: the declared contracts replace the previous
|
|
3271
|
+
* chain's wholesale, the queue restarts, and the unmatched log clears —
|
|
3272
|
+
* the reset-between-chains databases already follow.
|
|
3273
|
+
*/
|
|
3274
|
+
beginChain(contracts) {
|
|
3275
|
+
this.guarded = contracts.length > 0;
|
|
3276
|
+
this.queue = new ContractQueue(contracts);
|
|
3277
|
+
this.unmatchedRequests.clear();
|
|
3278
|
+
}
|
|
3279
|
+
/**
|
|
3280
|
+
* The strict failure for the chain, or null. Non-null when the chain
|
|
3281
|
+
* declared at least one contract AND either an unmatched request was
|
|
3282
|
+
* recorded, or a `required` contract was never satisfied. Enumerates every
|
|
3283
|
+
* unmatched request (method, path, count) plus the declared routes, so the
|
|
3284
|
+
* missing contract writes itself.
|
|
3285
|
+
*/
|
|
3286
|
+
violation() {
|
|
3287
|
+
if (!this.guarded) return null;
|
|
3288
|
+
if (this.unmatchedRequests.size === 0) return this.queue.requiredError();
|
|
3289
|
+
const unmatched = [...this.unmatchedRequests.values()].map((entry) => ` - ${entry.method} ${entry.path}${entry.count > 1 ? ` (${entry.count} times)` : ""}`).join("\n");
|
|
3290
|
+
return /* @__PURE__ */ new Error(`Unmatched request(s) hit the declared backend during the chain:\n${unmatched}\nDeclared contracts:\n${this.describeRoutes()}\nEvery backend request of a chain that declares contracts must match one — add a contract for it to the chain's composite.`);
|
|
3291
|
+
}
|
|
3292
|
+
corsHeaders(request) {
|
|
3293
|
+
return {
|
|
3294
|
+
"access-control-allow-headers": request.headers["access-control-request-headers"] ?? "*",
|
|
3295
|
+
"access-control-allow-methods": CORS_METHODS,
|
|
3296
|
+
"access-control-allow-origin": "*",
|
|
3297
|
+
"access-control-max-age": PREFLIGHT_MAX_AGE
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
describeRoutes() {
|
|
3301
|
+
const routes = this.queue.declaredRoutes();
|
|
3302
|
+
if (routes.length === 0) return " (no contracts declared)";
|
|
3303
|
+
return routes.map((route) => ` - ${route}`).join("\n");
|
|
3304
|
+
}
|
|
3305
|
+
async handle(request, response) {
|
|
3306
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
3307
|
+
const cors = this.corsHeaders(request);
|
|
3308
|
+
if (method === "OPTIONS") {
|
|
3309
|
+
response.writeHead(204, cors);
|
|
3310
|
+
response.end();
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
const headers = {};
|
|
3314
|
+
for (const [name, value] of Object.entries(request.headers)) if (typeof value === "string") headers[name.toLowerCase()] = value;
|
|
3315
|
+
const url = request.url ?? "/";
|
|
3316
|
+
const observed = {
|
|
3317
|
+
body: await readBody(request),
|
|
3318
|
+
headers,
|
|
3319
|
+
method,
|
|
3320
|
+
url
|
|
3321
|
+
};
|
|
3322
|
+
const contract = this.queue.take(observed);
|
|
3323
|
+
if (!contract) {
|
|
3324
|
+
this.record(method, url);
|
|
3325
|
+
response.writeHead(501, {
|
|
3326
|
+
...cors,
|
|
3327
|
+
"content-type": "application/json"
|
|
3328
|
+
});
|
|
3329
|
+
response.end(JSON.stringify({
|
|
3330
|
+
declared: this.queue.declaredRoutes(),
|
|
3331
|
+
error: `@jterrazz/test declared backend: no contract matches ${method} ${url}`
|
|
3332
|
+
}));
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
const reply = typeof contract.response === "function" ? contract.response(observed) : contract.response;
|
|
3336
|
+
if (reply.delay) await new Promise((resolve) => setTimeout(resolve, reply.delay));
|
|
3337
|
+
const status = reply.status ?? 200;
|
|
3338
|
+
const { body } = reply;
|
|
3339
|
+
if (body === null || body === void 0) {
|
|
3340
|
+
response.writeHead(status, {
|
|
3341
|
+
...cors,
|
|
3342
|
+
...reply.headers
|
|
3343
|
+
});
|
|
3344
|
+
response.end();
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
|
3348
|
+
const contentType = typeof body === "string" ? "text/plain; charset=utf-8" : "application/json";
|
|
3349
|
+
response.writeHead(status, {
|
|
3350
|
+
...cors,
|
|
3351
|
+
"content-type": contentType,
|
|
3352
|
+
...reply.headers
|
|
3353
|
+
});
|
|
3354
|
+
response.end(payload);
|
|
3355
|
+
}
|
|
3356
|
+
record(method, path) {
|
|
3357
|
+
const key = `${method} ${path}`;
|
|
3358
|
+
const existing = this.unmatchedRequests.get(key);
|
|
3359
|
+
if (existing) existing.count += 1;
|
|
3360
|
+
else this.unmatchedRequests.set(key, {
|
|
3361
|
+
count: 1,
|
|
3362
|
+
method,
|
|
3363
|
+
path
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
};
|
|
3367
|
+
/** Read the request body — parsed JSON when it is JSON, raw text otherwise. */
|
|
3368
|
+
async function readBody(request) {
|
|
3369
|
+
const chunks = [];
|
|
3370
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
3371
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
3372
|
+
if (raw.length === 0) return null;
|
|
3373
|
+
try {
|
|
3374
|
+
return JSON.parse(raw);
|
|
3375
|
+
} catch {
|
|
3376
|
+
return raw;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
//#endregion
|
|
3426
3380
|
//#region src/core/specification/website/serve.adapter.ts
|
|
3427
3381
|
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
3428
3382
|
const KILL_GRACE_MS = 2e3;
|
|
@@ -3432,7 +3386,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
3432
3386
|
/** Ask the OS for a free TCP port. */
|
|
3433
3387
|
function findFreePort() {
|
|
3434
3388
|
return new Promise((resolve, reject) => {
|
|
3435
|
-
const server = createServer();
|
|
3389
|
+
const server = createServer$1();
|
|
3436
3390
|
server.listen(0, () => {
|
|
3437
3391
|
const address = server.address();
|
|
3438
3392
|
if (address === null || typeof address === "string") {
|
|
@@ -3458,10 +3412,16 @@ function findFreePort() {
|
|
|
3458
3412
|
*/
|
|
3459
3413
|
var ServeAdapter = class {
|
|
3460
3414
|
child = null;
|
|
3415
|
+
/** Extra environment injected into the child (e.g. the stub backend URL). */
|
|
3416
|
+
extraEnv;
|
|
3417
|
+
/** The constructor named in error messages — `website`, or `mobile` (appium server). */
|
|
3418
|
+
facet;
|
|
3461
3419
|
options;
|
|
3462
3420
|
output = "";
|
|
3463
3421
|
root;
|
|
3464
|
-
constructor(options, root) {
|
|
3422
|
+
constructor(options, root, facet = "website", extraEnv = {}) {
|
|
3423
|
+
this.extraEnv = extraEnv;
|
|
3424
|
+
this.facet = facet;
|
|
3465
3425
|
this.options = options;
|
|
3466
3426
|
this.root = root;
|
|
3467
3427
|
}
|
|
@@ -3476,6 +3436,7 @@ var ServeAdapter = class {
|
|
|
3476
3436
|
detached: process.platform !== "win32",
|
|
3477
3437
|
env: {
|
|
3478
3438
|
...process.env,
|
|
3439
|
+
...this.extraEnv,
|
|
3479
3440
|
PORT: String(port)
|
|
3480
3441
|
},
|
|
3481
3442
|
shell: true,
|
|
@@ -3497,14 +3458,14 @@ var ServeAdapter = class {
|
|
|
3497
3458
|
});
|
|
3498
3459
|
const deadline = Date.now() + timeout;
|
|
3499
3460
|
for (;;) {
|
|
3500
|
-
if (exited) throw new Error(`specification.
|
|
3461
|
+
if (exited) throw new Error(`specification.${this.facet}(): server command exited before answering HTTP.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
3501
3462
|
try {
|
|
3502
3463
|
await fetch(readyUrl, { redirect: "manual" });
|
|
3503
3464
|
return baseUrl;
|
|
3504
3465
|
} catch {
|
|
3505
3466
|
if (Date.now() >= deadline) {
|
|
3506
3467
|
await this.stop();
|
|
3507
|
-
throw new Error(`specification.
|
|
3468
|
+
throw new Error(`specification.${this.facet}(): server did not answer on ${readyUrl} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
3508
3469
|
}
|
|
3509
3470
|
await delay(READY_POLL_INTERVAL_MS);
|
|
3510
3471
|
}
|
|
@@ -3544,16 +3505,184 @@ var ServeAdapter = class {
|
|
|
3544
3505
|
}
|
|
3545
3506
|
};
|
|
3546
3507
|
//#endregion
|
|
3508
|
+
//#region src/core/specification/mobile/appium-server.ts
|
|
3509
|
+
const APPIUM_READY_TIMEOUT_MS = 6e4;
|
|
3510
|
+
/**
|
|
3511
|
+
* The guidance thrown when the optional peers are missing — the exact fix,
|
|
3512
|
+
* mirroring the playwright message on `.visit()`.
|
|
3513
|
+
*/
|
|
3514
|
+
const APPIUM_INSTALL_HINT = "specification.mobile() requires appium and webdriverio (optional peer dependencies): npm install -D appium webdriverio && npx appium driver install xcuitest";
|
|
3515
|
+
/**
|
|
3516
|
+
* Start the caller project's appium server as a child process on a free port
|
|
3517
|
+
* and wait until `/status` answers. The binary is resolved from the project
|
|
3518
|
+
* root's `node_modules/.bin` — appium is an optional peer, so its absence
|
|
3519
|
+
* refuses with the exact install command. Process lifecycle (free port via
|
|
3520
|
+
* `PORT`, readiness poll, SIGTERM→SIGKILL group escalation on stop) is the
|
|
3521
|
+
* serve adapter's — one implementation for every child the framework spawns.
|
|
3522
|
+
*/
|
|
3523
|
+
async function startAppiumServer(root) {
|
|
3524
|
+
const bin = resolve(root, "node_modules", ".bin", "appium");
|
|
3525
|
+
if (!existsSync(bin)) throw new Error(APPIUM_INSTALL_HINT);
|
|
3526
|
+
const serve = new ServeAdapter({
|
|
3527
|
+
command: `"${bin}" --port "$PORT"`,
|
|
3528
|
+
ready: "/status",
|
|
3529
|
+
timeout: APPIUM_READY_TIMEOUT_MS
|
|
3530
|
+
}, root, "mobile");
|
|
3531
|
+
return {
|
|
3532
|
+
stop: () => serve.stop(),
|
|
3533
|
+
url: await serve.start()
|
|
3534
|
+
};
|
|
3535
|
+
}
|
|
3536
|
+
//#endregion
|
|
3537
|
+
//#region src/core/specification/mobile/simulator.ts
|
|
3538
|
+
/**
|
|
3539
|
+
* Simulator resolution — `device: { name, os? }` → one booted UDID.
|
|
3540
|
+
*
|
|
3541
|
+
* The selection logic is pure (`simctl list` JSON in, one device out) so the
|
|
3542
|
+
* refusals are unit-testable; only the thin wrappers below shell out to
|
|
3543
|
+
* `xcrun`. Zero matches and several matches both refuse with the full device
|
|
3544
|
+
* listing — the framework never guesses which simulator a spec meant.
|
|
3545
|
+
*/
|
|
3546
|
+
const run = promisify(execFile);
|
|
3547
|
+
const BOOT_TIMEOUT_MS = 12e4;
|
|
3548
|
+
/** `com.apple.CoreSimulator.SimRuntime.iOS-26-5` → `iOS 26.5`. */
|
|
3549
|
+
function runtimeLabel(runtime) {
|
|
3550
|
+
const match = /SimRuntime\.(?<platform>[A-Za-z]+)-(?<version>[\d-]+)$/.exec(runtime);
|
|
3551
|
+
return match?.groups ? `${match.groups["platform"]} ${match.groups["version"].replaceAll("-", ".")}` : runtime;
|
|
3552
|
+
}
|
|
3553
|
+
/** Flatten the per-runtime record into one available-device list. */
|
|
3554
|
+
function listSimulators(listJson) {
|
|
3555
|
+
const parsed = JSON.parse(listJson);
|
|
3556
|
+
return Object.entries(parsed.devices).flatMap(([runtime, devices]) => devices.filter((device) => device.isAvailable !== false).map((device) => ({
|
|
3557
|
+
name: device.name,
|
|
3558
|
+
os: runtimeLabel(runtime),
|
|
3559
|
+
state: device.state,
|
|
3560
|
+
udid: device.udid
|
|
3561
|
+
})));
|
|
3562
|
+
}
|
|
3563
|
+
/** `iOS 26.5` matches `os: 'iOS 26.5'` and the bare version `'26.5'`, case-insensitive. */
|
|
3564
|
+
function osMatches(deviceOs, wanted) {
|
|
3565
|
+
const normalized = wanted.toLowerCase();
|
|
3566
|
+
return deviceOs.toLowerCase() === normalized || deviceOs.split(" ")[1] === wanted;
|
|
3567
|
+
}
|
|
3568
|
+
function formatListing(devices) {
|
|
3569
|
+
return devices.map((device) => ` ${device.name} — ${device.os} (${device.state}) ${device.udid}`).join("\n");
|
|
3570
|
+
}
|
|
3571
|
+
/**
|
|
3572
|
+
* Pick exactly one simulator by name (and OS when given). Zero or several
|
|
3573
|
+
* matches refuse with the listing needed to fix the options without opening
|
|
3574
|
+
* Xcode.
|
|
3575
|
+
*/
|
|
3576
|
+
function selectSimulator(listJson, criteria) {
|
|
3577
|
+
const available = listSimulators(listJson);
|
|
3578
|
+
const matches = available.filter((device) => device.name === criteria.name && (criteria.os === void 0 || osMatches(device.os, criteria.os)));
|
|
3579
|
+
const wanted = criteria.os === void 0 ? `"${criteria.name}"` : `"${criteria.name}" on ${criteria.os}`;
|
|
3580
|
+
if (matches.length === 0) throw new Error(`specification.mobile(): no simulator matches ${wanted}.\nAvailable devices:\n${formatListing(available)}`);
|
|
3581
|
+
if (matches.length > 1) throw new Error(`specification.mobile(): ${matches.length} simulators match ${wanted} — add \`os:\` or \`udid:\` to pick one.\nMatched:\n${formatListing(matches)}`);
|
|
3582
|
+
return matches[0];
|
|
3583
|
+
}
|
|
3584
|
+
/** Resolve `device: { name, os? }` to a UDID via `xcrun simctl`. */
|
|
3585
|
+
async function resolveSimulatorUdid(criteria) {
|
|
3586
|
+
const { stdout } = await run("xcrun", [
|
|
3587
|
+
"simctl",
|
|
3588
|
+
"list",
|
|
3589
|
+
"devices",
|
|
3590
|
+
"--json"
|
|
3591
|
+
], { maxBuffer: 16 * 1024 * 1024 });
|
|
3592
|
+
return selectSimulator(stdout, criteria).udid;
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Boot the simulator when it is shut down and wait until it reports Booted.
|
|
3596
|
+
* Idempotent: booting an already-booted device is tolerated, and
|
|
3597
|
+
* `bootstatus -b` returns immediately when the device is up.
|
|
3598
|
+
*/
|
|
3599
|
+
async function ensureBooted(udid) {
|
|
3600
|
+
try {
|
|
3601
|
+
await run("xcrun", [
|
|
3602
|
+
"simctl",
|
|
3603
|
+
"boot",
|
|
3604
|
+
udid
|
|
3605
|
+
]);
|
|
3606
|
+
} catch (error) {
|
|
3607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3608
|
+
if (!message.includes("current state: Booted")) throw new Error(`specification.mobile(): could not boot simulator ${udid}.\n${message}`, { cause: error });
|
|
3609
|
+
}
|
|
3610
|
+
await run("xcrun", [
|
|
3611
|
+
"simctl",
|
|
3612
|
+
"bootstatus",
|
|
3613
|
+
udid,
|
|
3614
|
+
"-b"
|
|
3615
|
+
], { timeout: BOOT_TIMEOUT_MS });
|
|
3616
|
+
}
|
|
3617
|
+
//#endregion
|
|
3618
|
+
//#region src/core/specification/mobile/start-mobile.ts
|
|
3619
|
+
async function startMobile(options) {
|
|
3620
|
+
const callerDir = getCallerDir();
|
|
3621
|
+
await registerMatchers();
|
|
3622
|
+
const root = resolveRoot(options.root, callerDir);
|
|
3623
|
+
const udid = options.device.udid ?? await resolveSimulatorUdid(options.device);
|
|
3624
|
+
await ensureBooted(udid);
|
|
3625
|
+
const appium = await startAppiumServer(root);
|
|
3626
|
+
let backend = null;
|
|
3627
|
+
let backendUrl;
|
|
3628
|
+
if (options.backend) {
|
|
3629
|
+
backend = new StubBackend({ port: options.backend.port });
|
|
3630
|
+
backendUrl = await backend.start();
|
|
3631
|
+
}
|
|
3632
|
+
let device = null;
|
|
3633
|
+
const getDevice = async () => {
|
|
3634
|
+
if (!device) {
|
|
3635
|
+
const { AppiumAdapter } = await import("./appium.adapter.js");
|
|
3636
|
+
device = new AppiumAdapter({
|
|
3637
|
+
serverUrl: appium.url,
|
|
3638
|
+
udid
|
|
3639
|
+
});
|
|
3640
|
+
}
|
|
3641
|
+
return device;
|
|
3642
|
+
};
|
|
3643
|
+
const config = {
|
|
3644
|
+
backend: backend ?? void 0,
|
|
3645
|
+
backendUrl,
|
|
3646
|
+
bundleId: options.app.bundleId,
|
|
3647
|
+
device: getDevice
|
|
3648
|
+
};
|
|
3649
|
+
return {
|
|
3650
|
+
backendUrl,
|
|
3651
|
+
cleanup: async () => {
|
|
3652
|
+
if (device) {
|
|
3653
|
+
await device.close();
|
|
3654
|
+
device = null;
|
|
3655
|
+
}
|
|
3656
|
+
await appium.stop();
|
|
3657
|
+
if (backend) await backend.stop();
|
|
3658
|
+
},
|
|
3659
|
+
mobile: createMobileFacet(config),
|
|
3660
|
+
udid
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3663
|
+
//#endregion
|
|
3547
3664
|
//#region src/core/specification/website/start-website.ts
|
|
3548
3665
|
async function startWebsite(options) {
|
|
3549
3666
|
const callerDir = getCallerDir();
|
|
3550
3667
|
await registerMatchers();
|
|
3668
|
+
if (options.backend && !options.server) throw new Error("specification.website(): `backend` requires `server` mode — a deployed `url` cannot be pointed at a local stub.");
|
|
3669
|
+
let backend = null;
|
|
3670
|
+
let backendUrl;
|
|
3671
|
+
if (options.backend) {
|
|
3672
|
+
backend = new StubBackend({ port: options.backend.port });
|
|
3673
|
+
backendUrl = await backend.start();
|
|
3674
|
+
}
|
|
3551
3675
|
let serve = null;
|
|
3552
3676
|
let baseUrl;
|
|
3553
3677
|
if (options.server) {
|
|
3554
3678
|
const root = resolveRoot(options.root, callerDir);
|
|
3555
|
-
serve = new ServeAdapter(options.server, root);
|
|
3556
|
-
|
|
3679
|
+
serve = new ServeAdapter(options.server, root, "website", options.backend && backendUrl !== void 0 ? { [options.backend.env]: backendUrl } : {});
|
|
3680
|
+
try {
|
|
3681
|
+
baseUrl = await serve.start();
|
|
3682
|
+
} catch (error) {
|
|
3683
|
+
await backend?.stop();
|
|
3684
|
+
throw error;
|
|
3685
|
+
}
|
|
3557
3686
|
} else baseUrl = options.url.replace(/\/$/, "");
|
|
3558
3687
|
let browser = null;
|
|
3559
3688
|
const getBrowser = async () => {
|
|
@@ -3564,6 +3693,8 @@ async function startWebsite(options) {
|
|
|
3564
3693
|
return browser;
|
|
3565
3694
|
};
|
|
3566
3695
|
const config = {
|
|
3696
|
+
backend: backend ?? void 0,
|
|
3697
|
+
backendUrl,
|
|
3567
3698
|
baseUrl,
|
|
3568
3699
|
browser: getBrowser,
|
|
3569
3700
|
external: options.external ?? (options.server ? "block" : "allow")
|
|
@@ -3575,6 +3706,7 @@ async function startWebsite(options) {
|
|
|
3575
3706
|
browser = null;
|
|
3576
3707
|
}
|
|
3577
3708
|
if (serve) await serve.stop();
|
|
3709
|
+
if (backend) await backend.stop();
|
|
3578
3710
|
},
|
|
3579
3711
|
url: baseUrl,
|
|
3580
3712
|
website: createWebsiteFacet(config)
|
|
@@ -3583,7 +3715,7 @@ async function startWebsite(options) {
|
|
|
3583
3715
|
//#endregion
|
|
3584
3716
|
//#region src/core/specification/shared/specification.ts
|
|
3585
3717
|
/**
|
|
3586
|
-
* The
|
|
3718
|
+
* The five specification constructors (CONVENTIONS A2) — created in a
|
|
3587
3719
|
* `*.specification.ts` file under `specs/`, destructured with canonical
|
|
3588
3720
|
* names, and cleaned up via `afterAll(cleanup)` (A1/A3/A4).
|
|
3589
3721
|
*
|
|
@@ -3627,6 +3759,13 @@ const specification = {
|
|
|
3627
3759
|
*/
|
|
3628
3760
|
jobs: startJobs,
|
|
3629
3761
|
/**
|
|
3762
|
+
* Test a native app on the iOS simulator. `device` names the simulator
|
|
3763
|
+
* (resolved and booted via `simctl`); `app` names the bundle under
|
|
3764
|
+
* test. `.open(deepLink?, scenario?)` relaunches the app fresh, runs
|
|
3765
|
+
* the scenario, and captures the final screen.
|
|
3766
|
+
*/
|
|
3767
|
+
mobile: startMobile,
|
|
3768
|
+
/**
|
|
3630
3769
|
* Test a deployed or locally-served website. `server` starts the site
|
|
3631
3770
|
* (a shell command receiving `PORT`); `url` targets a running one.
|
|
3632
3771
|
* `.visit(path)` renders the page in a single shared browser instance;
|
|
@@ -3698,6 +3837,30 @@ const within = (scope, target) => ({
|
|
|
3698
3837
|
});
|
|
3699
3838
|
//#endregion
|
|
3700
3839
|
//#region src/integrations/sqlite/sqlite.ts
|
|
3840
|
+
const SQLITE_FILE_HEADER = Buffer.from("SQLite format 3\0");
|
|
3841
|
+
/**
|
|
3842
|
+
* Whether `path` looks like a usable SQLite database file: it exists, is at
|
|
3843
|
+
* least as long as the format header, and begins with the SQLite magic
|
|
3844
|
+
* bytes. Does not validate anything beyond the header — good enough to
|
|
3845
|
+
* reject a 0-byte or truncated leftover without opening the file.
|
|
3846
|
+
*/
|
|
3847
|
+
function isValidSqliteTemplate(path) {
|
|
3848
|
+
if (!existsSync(path)) return false;
|
|
3849
|
+
let fd;
|
|
3850
|
+
try {
|
|
3851
|
+
fd = openSync(path, "r");
|
|
3852
|
+
} catch {
|
|
3853
|
+
return false;
|
|
3854
|
+
}
|
|
3855
|
+
try {
|
|
3856
|
+
const header = Buffer.alloc(SQLITE_FILE_HEADER.length);
|
|
3857
|
+
return readSync(fd, header, 0, header.length, 0) === SQLITE_FILE_HEADER.length && header.equals(SQLITE_FILE_HEADER);
|
|
3858
|
+
} catch {
|
|
3859
|
+
return false;
|
|
3860
|
+
} finally {
|
|
3861
|
+
closeSync(fd);
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3701
3864
|
var SqliteHandle = class {
|
|
3702
3865
|
type = "sqlite";
|
|
3703
3866
|
composeName = null;
|
|
@@ -3730,9 +3893,14 @@ var SqliteHandle = class {
|
|
|
3730
3893
|
while (existsSync(lockPath) && Date.now() - start < 3e4) await new Promise((r) => setTimeout(r, 100));
|
|
3731
3894
|
}
|
|
3732
3895
|
if (existsSync(this.templatePath)) {
|
|
3733
|
-
this.
|
|
3734
|
-
|
|
3735
|
-
|
|
3896
|
+
if (isValidSqliteTemplate(this.templatePath)) {
|
|
3897
|
+
this.connectionString = `file:${this.templatePath}`;
|
|
3898
|
+
this.started = true;
|
|
3899
|
+
return;
|
|
3900
|
+
}
|
|
3901
|
+
try {
|
|
3902
|
+
unlinkSync(this.templatePath);
|
|
3903
|
+
} catch {}
|
|
3736
3904
|
}
|
|
3737
3905
|
const { writeFileSync } = await import("node:fs");
|
|
3738
3906
|
writeFileSync(lockPath, process.pid.toString());
|
|
@@ -3824,25 +3992,22 @@ function sqlite(options = {}) {
|
|
|
3824
3992
|
return new SqliteHandle(options);
|
|
3825
3993
|
}
|
|
3826
3994
|
//#endregion
|
|
3995
|
+
//#region src/core/contracts/filters.ts
|
|
3996
|
+
/** Does an observed text satisfy a declared {@link TextFilter}? */
|
|
3997
|
+
function matchesText(filter, actual) {
|
|
3998
|
+
if (filter === void 0) return true;
|
|
3999
|
+
if (typeof filter === "string") return actual === filter;
|
|
4000
|
+
if (filter instanceof RegExp) return filter.test(actual);
|
|
4001
|
+
return structuralEquals(filter, actual, new CaptureScope());
|
|
4002
|
+
}
|
|
4003
|
+
//#endregion
|
|
3827
4004
|
//#region src/integrations/anthropic/anthropic.ts
|
|
3828
4005
|
const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
|
|
3829
4006
|
function matchesFilter(body, filter) {
|
|
3830
|
-
if (filter.model)
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
}
|
|
3835
|
-
if (filter.system) {
|
|
3836
|
-
const system = typeof body?.system === "string" ? body.system : "";
|
|
3837
|
-
if (typeof filter.system === "string" && !system.includes(filter.system)) return false;
|
|
3838
|
-
if (filter.system instanceof RegExp && !filter.system.test(system)) return false;
|
|
3839
|
-
}
|
|
3840
|
-
if (filter.user) {
|
|
3841
|
-
const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
|
|
3842
|
-
const text = typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg);
|
|
3843
|
-
if (typeof filter.user === "string" && !text.includes(filter.user)) return false;
|
|
3844
|
-
if (filter.user instanceof RegExp && !filter.user.test(text)) return false;
|
|
3845
|
-
}
|
|
4007
|
+
if (!matchesText(filter.model, body?.model ?? "")) return false;
|
|
4008
|
+
if (!matchesText(filter.system, typeof body?.system === "string" ? body.system : "")) return false;
|
|
4009
|
+
const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
|
|
4010
|
+
if (!matchesText(filter.user, typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg))) return false;
|
|
3846
4011
|
if (filter.tools) {
|
|
3847
4012
|
const names = body?.tools?.map((t) => t.name).filter(Boolean) ?? [];
|
|
3848
4013
|
if (!filter.tools.every((t) => names.includes(t))) return false;
|
|
@@ -3874,7 +4039,7 @@ function buildReply(data) {
|
|
|
3874
4039
|
*/
|
|
3875
4040
|
const anthropic = {
|
|
3876
4041
|
/**
|
|
3877
|
-
*
|
|
4042
|
+
* Request: match Messages API calls, optionally routed through a
|
|
3878
4043
|
* custom gateway URL. When used with a JSON fixture file, the data is
|
|
3879
4044
|
* returned as-is (no wrapping) because Anthropic fixtures are typically
|
|
3880
4045
|
* already in the Messages API response shape.
|
|
@@ -3882,6 +4047,7 @@ const anthropic = {
|
|
|
3882
4047
|
* @example
|
|
3883
4048
|
* anthropic.messages()
|
|
3884
4049
|
* anthropic.messages({ system: /classify/ })
|
|
4050
|
+
* anthropic.messages({ user: buildPrompt() }) // string = EXACT equality
|
|
3885
4051
|
* anthropic.messages({ user: /classify/ }, GATEWAY)
|
|
3886
4052
|
*/
|
|
3887
4053
|
messages(filter, url) {
|
|
@@ -3931,6 +4097,7 @@ function wrapJson(data) {
|
|
|
3931
4097
|
body: data
|
|
3932
4098
|
};
|
|
3933
4099
|
}
|
|
4100
|
+
const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
3934
4101
|
function matchesBody(body, expected) {
|
|
3935
4102
|
if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
|
|
3936
4103
|
if (expected instanceof RegExp) {
|
|
@@ -3947,7 +4114,7 @@ function matchesEntries(expected, lookup) {
|
|
|
3947
4114
|
});
|
|
3948
4115
|
}
|
|
3949
4116
|
/**
|
|
3950
|
-
* Build the `match` predicate for an HTTP
|
|
4117
|
+
* Build the `match` predicate for an HTTP request filter, or `undefined` when
|
|
3951
4118
|
* no filter is supplied (fires on any URL/method match).
|
|
3952
4119
|
*/
|
|
3953
4120
|
function buildMatch(filter) {
|
|
@@ -3958,7 +4125,7 @@ function buildMatch(filter) {
|
|
|
3958
4125
|
if (filter.query) {
|
|
3959
4126
|
let params;
|
|
3960
4127
|
try {
|
|
3961
|
-
params = new URL(request.url).searchParams;
|
|
4128
|
+
params = new URL(request.url, "http://contract.invalid").searchParams;
|
|
3962
4129
|
} catch {
|
|
3963
4130
|
return false;
|
|
3964
4131
|
}
|
|
@@ -3967,74 +4134,79 @@ function buildMatch(filter) {
|
|
|
3967
4134
|
return true;
|
|
3968
4135
|
};
|
|
3969
4136
|
}
|
|
4137
|
+
function declare(method, url, filter) {
|
|
4138
|
+
return {
|
|
4139
|
+
adapter: "http",
|
|
4140
|
+
match: buildMatch(filter),
|
|
4141
|
+
method,
|
|
4142
|
+
url,
|
|
4143
|
+
wrap: wrapJson
|
|
4144
|
+
};
|
|
4145
|
+
}
|
|
3970
4146
|
/**
|
|
3971
|
-
* Generic HTTP
|
|
3972
|
-
*
|
|
3973
|
-
*
|
|
3974
|
-
*
|
|
4147
|
+
* Generic HTTP contract helpers for any URL. The url is absolute (string or
|
|
4148
|
+
* RegExp), or a PATH FORM starting with `/` — `http.get('/articles/{{uuid}}')`
|
|
4149
|
+
* matches that path on ANY origin, which is what an app calling its own
|
|
4150
|
+
* backend needs. An optional {@link HttpContractFilter} narrows matching by
|
|
4151
|
+
* body, headers, or query — a request that hits the URL/method but fails the
|
|
4152
|
+
* filter counts as unmatched (strict contracts, CONVENTIONS D7).
|
|
3975
4153
|
*
|
|
3976
4154
|
* @example
|
|
3977
|
-
*
|
|
3978
|
-
*
|
|
4155
|
+
* defineContract({ request: http.get('/articles/{{uuid}}'), response: http.json(article) })
|
|
4156
|
+
* defineContract({ request: http.post(URL, { body: { user: 'alice' } }), response: http.empty() })
|
|
3979
4157
|
*/
|
|
3980
4158
|
const http = {
|
|
4159
|
+
any(url, filter) {
|
|
4160
|
+
return declare("*", url, filter);
|
|
4161
|
+
},
|
|
4162
|
+
delete(url, filter) {
|
|
4163
|
+
return declare("DELETE", url, filter);
|
|
4164
|
+
},
|
|
3981
4165
|
get(url, filter) {
|
|
3982
|
-
return
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
url,
|
|
3987
|
-
wrap: wrapJson
|
|
3988
|
-
};
|
|
4166
|
+
return declare("GET", url, filter);
|
|
4167
|
+
},
|
|
4168
|
+
patch(url, filter) {
|
|
4169
|
+
return declare("PATCH", url, filter);
|
|
3989
4170
|
},
|
|
3990
4171
|
post(url, filter) {
|
|
3991
|
-
return
|
|
3992
|
-
adapter: "http",
|
|
3993
|
-
match: buildMatch(filter),
|
|
3994
|
-
method: "POST",
|
|
3995
|
-
url,
|
|
3996
|
-
wrap: wrapJson
|
|
3997
|
-
};
|
|
4172
|
+
return declare("POST", url, filter);
|
|
3998
4173
|
},
|
|
3999
4174
|
put(url, filter) {
|
|
4000
|
-
return
|
|
4001
|
-
adapter: "http",
|
|
4002
|
-
match: buildMatch(filter),
|
|
4003
|
-
method: "PUT",
|
|
4004
|
-
url,
|
|
4005
|
-
wrap: wrapJson
|
|
4006
|
-
};
|
|
4175
|
+
return declare("PUT", url, filter);
|
|
4007
4176
|
},
|
|
4008
|
-
|
|
4177
|
+
/** Response: a body-less reply (204 by default). */
|
|
4178
|
+
empty(status = 204) {
|
|
4009
4179
|
return {
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
method: "DELETE",
|
|
4013
|
-
url,
|
|
4014
|
-
wrap: wrapJson
|
|
4180
|
+
status,
|
|
4181
|
+
body: null
|
|
4015
4182
|
};
|
|
4016
4183
|
},
|
|
4017
|
-
|
|
4184
|
+
/** Response: an error status. Without a body, `{ error: 'HTTP <status>' }`. */
|
|
4185
|
+
error(status, body) {
|
|
4018
4186
|
return {
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
method: "*",
|
|
4022
|
-
url,
|
|
4023
|
-
wrap: wrapJson
|
|
4187
|
+
status,
|
|
4188
|
+
body: body === void 0 ? { error: `HTTP ${status}` } : body
|
|
4024
4189
|
};
|
|
4025
4190
|
},
|
|
4026
|
-
/** Response:
|
|
4027
|
-
json(
|
|
4191
|
+
/** Response: a JSON body (200 by default). */
|
|
4192
|
+
json(body, init) {
|
|
4028
4193
|
return {
|
|
4029
|
-
status,
|
|
4030
|
-
body
|
|
4194
|
+
status: init?.status ?? 200,
|
|
4195
|
+
body,
|
|
4196
|
+
delay: init?.delay,
|
|
4197
|
+
headers: init?.headers
|
|
4031
4198
|
};
|
|
4032
4199
|
},
|
|
4033
|
-
/** Response:
|
|
4034
|
-
|
|
4200
|
+
/** Response: a text body, served as `text/plain` (200 by default). */
|
|
4201
|
+
text(body, init) {
|
|
4035
4202
|
return {
|
|
4036
|
-
status,
|
|
4037
|
-
body
|
|
4203
|
+
status: init?.status ?? 200,
|
|
4204
|
+
body,
|
|
4205
|
+
delay: init?.delay,
|
|
4206
|
+
headers: {
|
|
4207
|
+
"content-type": TEXT_CONTENT_TYPE,
|
|
4208
|
+
...init?.headers
|
|
4209
|
+
}
|
|
4038
4210
|
};
|
|
4039
4211
|
}
|
|
4040
4212
|
};
|
|
@@ -4043,21 +4215,9 @@ const http = {
|
|
|
4043
4215
|
const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
|
|
4044
4216
|
const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
|
|
4045
4217
|
function matchesChatFilter(body, filter) {
|
|
4046
|
-
if (filter.model)
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
|
|
4050
|
-
}
|
|
4051
|
-
if (filter.system) {
|
|
4052
|
-
const msg = body?.messages?.find((m) => m.role === "system")?.content ?? "";
|
|
4053
|
-
if (typeof filter.system === "string" && !msg.includes(filter.system)) return false;
|
|
4054
|
-
if (filter.system instanceof RegExp && !filter.system.test(msg)) return false;
|
|
4055
|
-
}
|
|
4056
|
-
if (filter.user) {
|
|
4057
|
-
const msg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
|
|
4058
|
-
if (typeof filter.user === "string" && !msg.includes(filter.user)) return false;
|
|
4059
|
-
if (filter.user instanceof RegExp && !filter.user.test(msg)) return false;
|
|
4060
|
-
}
|
|
4218
|
+
if (!matchesText(filter.model, body?.model ?? "")) return false;
|
|
4219
|
+
if (!matchesText(filter.system, body?.messages?.find((m) => m.role === "system")?.content ?? "")) return false;
|
|
4220
|
+
if (!matchesText(filter.user, body?.messages?.find((m) => m.role === "user")?.content ?? "")) return false;
|
|
4061
4221
|
if (filter.tools) {
|
|
4062
4222
|
const names = body?.tools?.map((t) => t.function?.name).filter(Boolean) ?? [];
|
|
4063
4223
|
if (!filter.tools.every((t) => names.includes(t))) return false;
|
|
@@ -4066,23 +4226,11 @@ function matchesChatFilter(body, filter) {
|
|
|
4066
4226
|
return true;
|
|
4067
4227
|
}
|
|
4068
4228
|
function matchesResponsesFilter(body, filter) {
|
|
4069
|
-
if (filter.model)
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
if (filter.system) {
|
|
4075
|
-
const instructions = body?.instructions ?? "";
|
|
4076
|
-
const systemInput = body?.input?.find?.((m) => m.role === "system")?.content ?? "";
|
|
4077
|
-
const text = instructions || systemInput;
|
|
4078
|
-
if (typeof filter.system === "string" && !text.includes(filter.system)) return false;
|
|
4079
|
-
if (filter.system instanceof RegExp && !filter.system.test(text)) return false;
|
|
4080
|
-
}
|
|
4081
|
-
if (filter.user) {
|
|
4082
|
-
const msgs = (body?.input ?? []).filter((m) => m.role === "user").map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join(" ");
|
|
4083
|
-
if (typeof filter.user === "string" && !msgs.includes(filter.user)) return false;
|
|
4084
|
-
if (filter.user instanceof RegExp && !filter.user.test(msgs)) return false;
|
|
4085
|
-
}
|
|
4229
|
+
if (!matchesText(filter.model, body?.model ?? "")) return false;
|
|
4230
|
+
const systemInput = body?.input?.find?.((m) => m.role === "system")?.content ?? "";
|
|
4231
|
+
if (!matchesText(filter.system, body?.instructions || systemInput)) return false;
|
|
4232
|
+
const userText = (body?.input ?? []).filter((m) => m.role === "user").map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join(" ");
|
|
4233
|
+
if (!matchesText(filter.user, userText)) return false;
|
|
4086
4234
|
if (filter.tools) {
|
|
4087
4235
|
const names = body?.tools?.map((t) => t.name ?? t.function?.name).filter(Boolean) ?? [];
|
|
4088
4236
|
if (!filter.tools.every((t) => names.includes(t))) return false;
|
|
@@ -4146,12 +4294,15 @@ function buildResponsesReply(data) {
|
|
|
4146
4294
|
*/
|
|
4147
4295
|
const openai = {
|
|
4148
4296
|
/**
|
|
4149
|
-
*
|
|
4297
|
+
* Request: match Chat Completions API calls. STRING filters mean EXACT
|
|
4298
|
+
* equality (pass the app's own prompt builder); loosen deliberately with a
|
|
4299
|
+
* RegExp or `match.includes(...)`.
|
|
4150
4300
|
*
|
|
4151
4301
|
* @example
|
|
4152
|
-
* openai.chat()
|
|
4153
|
-
* openai.chat({ model: 'gpt-4o' })
|
|
4154
|
-
* openai.chat({ system:
|
|
4302
|
+
* openai.chat() // any chat call
|
|
4303
|
+
* openai.chat({ model: 'gpt-4o' }) // exact model
|
|
4304
|
+
* openai.chat({ system: buildPrompt() }) // exact system prompt
|
|
4305
|
+
* openai.chat({ system: /classify/ }) // pattern
|
|
4155
4306
|
*/
|
|
4156
4307
|
chat(filter) {
|
|
4157
4308
|
return {
|
|
@@ -4163,7 +4314,8 @@ const openai = {
|
|
|
4163
4314
|
};
|
|
4164
4315
|
},
|
|
4165
4316
|
/**
|
|
4166
|
-
*
|
|
4317
|
+
* Request: match Responses API calls (AI SDK v5+) with auto-wrapping.
|
|
4318
|
+
* String filters mean EXACT equality (see {@link openai.chat}).
|
|
4167
4319
|
* When used with a JSON file, the data is automatically wrapped in the
|
|
4168
4320
|
* Responses API envelope.
|
|
4169
4321
|
*
|
|
@@ -4214,35 +4366,6 @@ const openai = {
|
|
|
4214
4366
|
}
|
|
4215
4367
|
};
|
|
4216
4368
|
//#endregion
|
|
4217
|
-
//#region src/core/contracts/contract.ts
|
|
4218
|
-
/**
|
|
4219
|
-
* Declare an intercept contract. Identity function — its value is the
|
|
4220
|
-
* enforced shape and the naming convention:
|
|
4221
|
-
*
|
|
4222
|
-
* @example
|
|
4223
|
-
* // specs/api/reports/contracts/classify-article.openai.ts
|
|
4224
|
-
* import { defineContract, openai } from '@jterrazz/test';
|
|
4225
|
-
*
|
|
4226
|
-
* export default defineContract({
|
|
4227
|
-
* trigger: openai.responses({ user: /Report Ingestion/, tools: ['classify'] }),
|
|
4228
|
-
* response: openai.reply({ categories: ['TECH'] }),
|
|
4229
|
-
* });
|
|
4230
|
-
*
|
|
4231
|
-
* // Dynamic — the response is computed from the observed request:
|
|
4232
|
-
* export default defineContract({
|
|
4233
|
-
* trigger: http.post('https://api.example.com/echo'),
|
|
4234
|
-
* response: (request) => http.json({ received: request.body }),
|
|
4235
|
-
* });
|
|
4236
|
-
*
|
|
4237
|
-
* // specs/api/reports/reports.test.ts
|
|
4238
|
-
* import classifyArticle from '../../spec/intercept/contracts/classify-article.openai.js';
|
|
4239
|
-
*
|
|
4240
|
-
* const result = await jobs.intercept(classifyArticle).trigger('report-ingestion');
|
|
4241
|
-
*/
|
|
4242
|
-
function defineContract(contract) {
|
|
4243
|
-
return contract;
|
|
4244
|
-
}
|
|
4245
|
-
//#endregion
|
|
4246
4369
|
//#region src/vitest/mock-of.ts
|
|
4247
4370
|
/**
|
|
4248
4371
|
* Create a deep mock proxy for a given type.
|
|
@@ -4269,4 +4392,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
|
|
|
4269
4392
|
}));
|
|
4270
4393
|
registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
|
|
4271
4394
|
//#endregion
|
|
4272
|
-
export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, TableAccessor, TextAccessor, anthropic, banner, button, complementary, content, contentinfo, defineContract, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };
|
|
4395
|
+
export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, complementary, content, contentinfo, defineContract, defineContracts, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };
|