@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/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
- import { i as match, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
2
- import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { i as match, n as Matcher, t as CaptureScope } from "./match.js";
2
+ import { a as isContract, c as mergeTextPreservingPlaceholders, d as structuralSubset, f as textEquals, i as defineContracts, l as renderExpected, n as contractsOf, o as isContracts, r as defineContract, s as mergePreservingPlaceholders, t as ContractQueue, u as structuralEquals } from "./queue.js";
3
+ import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
5
  import { parse } from "yaml";
5
- 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:net";
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(triggerOrContracts, maybeResponse) {
2077
+ intercept(requestOrContracts, maybeResponse) {
2334
2078
  if (this.config.interceptDisabledReason) throw new Error(`.intercept(): ${this.config.interceptDisabledReason}`);
2335
- if (Array.isArray(triggerOrContracts)) {
2336
- for (const contract of triggerOrContracts) this.registerIntercept(contract);
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
- this.registerIntercept(triggerOrContracts, maybeResponse);
2340
- return this;
2341
- }
2342
- /** Register a single intercept — a contract, or a trigger + response pair. */
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.intercepts.length > 0) {
2521
- const { registerIntercepts } = await import("./intercept.js");
2522
- registration = await registerIntercepts(this.intercepts);
2268
+ if (this.config.backend) this.config.backend.beginChain(this.contracts);
2269
+ else if (this.contracts.length > 0) {
2270
+ const { registerContracts } = await import("./intercept.js");
2271
+ registration = await registerContracts(this.contracts);
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: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
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: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
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.website(): server command exited before answering HTTP.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
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.website(): server did not answer on ${readyUrl} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
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
- baseUrl = await serve.start();
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 three specification constructors (CONVENTIONS A2) — created in a
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.connectionString = `file:${this.templatePath}`;
3734
- this.started = true;
3735
- return;
3896
+ if (isValidSqliteTemplate(this.templatePath)) {
3897
+ this.connectionString = `file:${this.templatePath}`;
3898
+ this.started = true;
3899
+ return;
3900
+ }
3901
+ try {
3902
+ unlinkSync(this.templatePath);
3903
+ } catch {}
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
- const model = body?.model;
3832
- if (typeof filter.model === "string" && model !== filter.model) return false;
3833
- if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
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
- * Trigger: match Messages API requests, optionally routed through a
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 trigger filter, or `undefined` when
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 intercept helpers for any URL. An optional {@link
3972
- * HttpInterceptFilter} narrows matching by request body, headers, or query
3973
- * a request that hits the URL/method but fails the filter counts as unmatched
3974
- * (strict intercepts, CONVENTIONS D7).
4147
+ * Generic HTTP contract helpers for any URL. The url is absolute (string or
4148
+ * RegExp), or a PATH FORM starting with `/` `http.get('/articles/{{uuid}}')`
4149
+ * matches that path on ANY origin, which is what an app calling its own
4150
+ * backend needs. An optional {@link HttpContractFilter} narrows matching by
4151
+ * body, headers, or query — a request that hits the URL/method but fails the
4152
+ * filter counts as unmatched (strict contracts, CONVENTIONS D7).
3975
4153
  *
3976
4154
  * @example
3977
- * .intercept(http.get('https://api.example.com/data'), 'http/response.json')
3978
- * .intercept(http.post(URL, { body: { user: 'alice' } }), http.json({ ok: true }))
4155
+ * defineContract({ request: http.get('/articles/{{uuid}}'), response: http.json(article) })
4156
+ * defineContract({ request: http.post(URL, { body: { user: 'alice' } }), response: http.empty() })
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
- adapter: "http",
3984
- match: buildMatch(filter),
3985
- method: "GET",
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
- delete(url, filter) {
4177
+ /** Response: a body-less reply (204 by default). */
4178
+ empty(status = 204) {
4009
4179
  return {
4010
- adapter: "http",
4011
- match: buildMatch(filter),
4012
- method: "DELETE",
4013
- url,
4014
- wrap: wrapJson
4180
+ status,
4181
+ body: null
4015
4182
  };
4016
4183
  },
4017
- any(url, filter) {
4184
+ /** Response: an error status. Without a body, `{ error: 'HTTP <status>' }`. */
4185
+ error(status, body) {
4018
4186
  return {
4019
- adapter: "http",
4020
- match: buildMatch(filter),
4021
- method: "*",
4022
- url,
4023
- wrap: wrapJson
4187
+ status,
4188
+ body: body === void 0 ? { error: `HTTP ${status}` } : body
4024
4189
  };
4025
4190
  },
4026
- /** Response: simple JSON success. */
4027
- json(data, status = 200) {
4191
+ /** Response: a JSON body (200 by default). */
4192
+ json(body, init) {
4028
4193
  return {
4029
- status,
4030
- body: data
4194
+ status: init?.status ?? 200,
4195
+ body,
4196
+ delay: init?.delay,
4197
+ headers: init?.headers
4031
4198
  };
4032
4199
  },
4033
- /** Response: error with message. */
4034
- error(status, message) {
4200
+ /** Response: a text body, served as `text/plain` (200 by default). */
4201
+ text(body, init) {
4035
4202
  return {
4036
- status,
4037
- body: { error: message ?? `HTTP ${status}` }
4203
+ status: init?.status ?? 200,
4204
+ body,
4205
+ delay: init?.delay,
4206
+ headers: {
4207
+ "content-type": TEXT_CONTENT_TYPE,
4208
+ ...init?.headers
4209
+ }
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
- const model = body?.model;
4048
- if (typeof filter.model === "string" && model !== filter.model) return false;
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
- const model = body?.model;
4071
- if (typeof filter.model === "string" && model !== filter.model) return false;
4072
- if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
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
- * Trigger: match Chat Completions API requests.
4297
+ * Request: match Chat Completions API calls. STRING filters mean EXACT
4298
+ * equality (pass the app's own prompt builder); loosen deliberately with a
4299
+ * RegExp or `match.includes(...)`.
4150
4300
  *
4151
4301
  * @example
4152
- * openai.chat() // any chat call
4153
- * openai.chat({ model: 'gpt-4o' }) // specific model
4154
- * openai.chat({ system: /classify/ }) // system prompt match
4302
+ * openai.chat() // any chat call
4303
+ * openai.chat({ model: 'gpt-4o' }) // exact model
4304
+ * openai.chat({ system: buildPrompt() }) // exact system prompt
4305
+ * openai.chat({ system: /classify/ }) // pattern
4155
4306
  */
4156
4307
  chat(filter) {
4157
4308
  return {
@@ -4163,7 +4314,8 @@ const openai = {
4163
4314
  };
4164
4315
  },
4165
4316
  /**
4166
- * Trigger: match Responses API requests (AI SDK v5+) with auto-wrapping.
4317
+ * Request: match Responses API calls (AI SDK v5+) with auto-wrapping.
4318
+ * String filters mean EXACT equality (see {@link openai.chat}).
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 };