@jterrazz/test 9.2.1 → 10.1.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
@@ -2,12 +2,14 @@ import { i as match, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from ".
2
2
  import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { parse } from "yaml";
5
- import { execSync, spawn, spawnSync } from "node:child_process";
5
+ import { execFile, execSync, spawn, spawnSync } from "node:child_process";
6
6
  import { Client } from "pg";
7
7
  import { readdir } from "node:fs/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { tmpdir } from "node:os";
10
- import { createServer } from "node:net";
10
+ import { createServer } from "node:http";
11
+ import { createServer as createServer$1 } from "node:net";
12
+ import { promisify } from "node:util";
11
13
  import Database from "better-sqlite3";
12
14
  import { mockDeep } from "vitest-mock-extended";
13
15
  import MockDatePackage from "mockdate";
@@ -642,15 +644,15 @@ function escapeRegExp(text) {
642
644
  function parsePlaceholderString(expected, scope) {
643
645
  PLACEHOLDER_RE.lastIndex = 0;
644
646
  const refs = [];
645
- let pattern = "^";
647
+ let source = "";
646
648
  let lastIndex = 0;
647
649
  let single = null;
648
650
  let count = 0;
649
651
  for (const found of expected.matchAll(PLACEHOLDER_RE)) {
650
652
  const kind = found.groups.kind;
651
653
  const ref = found.groups.ref;
652
- pattern += escapeRegExp(expected.slice(lastIndex, found.index));
653
- pattern += `(${placeholderSource(kind, scope)})`;
654
+ source += escapeRegExp(expected.slice(lastIndex, found.index));
655
+ source += `(${placeholderSource(kind, scope)})`;
654
656
  refs.push({
655
657
  index: count,
656
658
  kind,
@@ -663,13 +665,22 @@ function parsePlaceholderString(expected, scope) {
663
665
  ref
664
666
  };
665
667
  }
666
- pattern += `${escapeRegExp(expected.slice(lastIndex))}$`;
668
+ source += escapeRegExp(expected.slice(lastIndex));
667
669
  return {
668
- pattern: new RegExp(pattern),
670
+ pattern: new RegExp(`^${source}$`),
669
671
  refs,
670
- single
672
+ single,
673
+ source
671
674
  };
672
675
  }
676
+ /**
677
+ * Unanchored regex source matching a fixture string — plain text escaped
678
+ * verbatim, each `{{token}}` expanded to its embedded grammar. Used to route
679
+ * declared `.http` intercept paths (`/articles/{{uuid}}`) as URL patterns.
680
+ */
681
+ function placeholderPatternSource(expected) {
682
+ return parsePlaceholderString(expected, new CaptureScope()).source;
683
+ }
673
684
  function placeholderStringMatches(expected, actual, scope) {
674
685
  const parsed = parsePlaceholderString(expected, scope);
675
686
  if (parsed.single) {
@@ -1979,6 +1990,128 @@ async function registerMatchers() {
1979
1990
  }
1980
1991
  }
1981
1992
  //#endregion
1993
+ //#region src/core/http-files/intercept-file.ts
1994
+ /** Split a declared path into its pathname and query halves — never through
1995
+ * `new URL()`, which would percent-encode `{{token}}` braces away. */
1996
+ function splitPath(path) {
1997
+ const separator = path.indexOf("?");
1998
+ if (separator === -1) return {
1999
+ pathname: path,
2000
+ query: new URLSearchParams()
2001
+ };
2002
+ return {
2003
+ pathname: path.slice(0, separator),
2004
+ query: new URLSearchParams(path.slice(separator + 1))
2005
+ };
2006
+ }
2007
+ /**
2008
+ * Parse an `intercepts/*.http` file into its declared exchanges. Malformed
2009
+ * files refuse with the exchange number and the expected shape.
2010
+ */
2011
+ function parseInterceptFile(content, fileName) {
2012
+ const segments = content.split(/^###.*$/m).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
2013
+ if (segments.length === 0) throw new Error(`${fileName}: no exchanges found — expected "###"-separated request/response pairs`);
2014
+ return segments.map((segment, index) => {
2015
+ const ordinal = index + 1;
2016
+ const lines = segment.split(/\r?\n/);
2017
+ const statusIndex = lines.findIndex((line) => /^HTTP\/1\.1\s/.test(line.trim()));
2018
+ if (statusIndex === -1) throw new Error(`${fileName}: exchange ${ordinal} has no response block — expected an "HTTP/1.1 <status>" line after the request`);
2019
+ const request = parseRequestFile(lines.slice(0, statusIndex).join("\n"), fileName);
2020
+ if (request.body !== void 0) throw new Error(`${fileName}: exchange ${ordinal} declares a request body — matching is method + path + headers only`);
2021
+ const response = parseResponseFile(lines.slice(statusIndex).join("\n"), fileName);
2022
+ const status = Number(response.status);
2023
+ if (!Number.isInteger(status)) throw new Error(`${fileName}: exchange ${ordinal} has a non-numeric status "${response.status}" — an intercept file states the stubbed reply, not an expectation`);
2024
+ return {
2025
+ request: {
2026
+ headers: request.headers,
2027
+ method: request.method,
2028
+ path: request.path
2029
+ },
2030
+ response: {
2031
+ body: response.body,
2032
+ hasBody: response.hasBody,
2033
+ headers: response.headers,
2034
+ status
2035
+ }
2036
+ };
2037
+ });
2038
+ }
2039
+ /** Token-aware comparison of one declared value against an observed one. */
2040
+ function valueMatches(declared, observed) {
2041
+ return textEquals(declared, observed, new CaptureScope());
2042
+ }
2043
+ /**
2044
+ * Does an observed request satisfy a declared exchange request? Method exact,
2045
+ * pathname exact (`{{token}}` segments match structurally), declared query
2046
+ * params and headers subset-match — extras on the observed side are ignored.
2047
+ */
2048
+ function exchangeMatches(declared, observed) {
2049
+ if (declared.method !== observed.method.toUpperCase()) return false;
2050
+ const { pathname, query } = splitPath(declared.path);
2051
+ let observedUrl;
2052
+ try {
2053
+ observedUrl = new URL(observed.url, "http://stub.invalid");
2054
+ } catch {
2055
+ return false;
2056
+ }
2057
+ if (!valueMatches(pathname, observedUrl.pathname) && !valueMatches(pathname, safeDecode(observedUrl.pathname))) return false;
2058
+ for (const key of new Set(query.keys())) {
2059
+ const observedValues = observedUrl.searchParams.getAll(key);
2060
+ if (!query.getAll(key).every((value) => observedValues.some((actual) => valueMatches(value, actual)))) return false;
2061
+ }
2062
+ return Object.entries(declared.headers).every(([name, value]) => {
2063
+ const actual = observed.headers[name.toLowerCase()];
2064
+ return actual !== void 0 && valueMatches(value, actual);
2065
+ });
2066
+ }
2067
+ /** Decode percent-escapes, falling back to the raw text on malformed input. */
2068
+ function safeDecode(text) {
2069
+ try {
2070
+ return decodeURIComponent(text);
2071
+ } catch {
2072
+ return text;
2073
+ }
2074
+ }
2075
+ /**
2076
+ * A URL pattern routing any-origin requests whose pathname is the declared
2077
+ * one — `{{token}}` segments expanded to their grammars. Deliberately no
2078
+ * wider than {@link exchangeMatches}: the predicate stays the authority.
2079
+ */
2080
+ function urlPatternOf(path) {
2081
+ const { pathname } = splitPath(path);
2082
+ return new RegExp(String.raw`^https?:\/\/[^/?#]+${placeholderPatternSource(pathname)}(?:\?[^#]*)?(?:#.*)?$`);
2083
+ }
2084
+ /**
2085
+ * Convert parsed exchanges into generic-http intercept entries for the MSW
2086
+ * engine — each exchange becomes one FIFO-consumed contract (CONVENTIONS D7
2087
+ * strictness applies unchanged on api/jobs chains).
2088
+ */
2089
+ function interceptEntriesOf(exchanges) {
2090
+ return exchanges.map((exchange) => {
2091
+ return {
2092
+ response: {
2093
+ body: exchange.response.hasBody ? exchange.response.body : null,
2094
+ headers: Object.keys(exchange.response.headers).length > 0 ? exchange.response.headers : void 0,
2095
+ status: exchange.response.status
2096
+ },
2097
+ trigger: {
2098
+ adapter: "http",
2099
+ match: (request) => exchangeMatches(exchange.request, {
2100
+ headers: request.headers,
2101
+ method: exchange.request.method,
2102
+ url: request.url
2103
+ }),
2104
+ method: exchange.request.method,
2105
+ url: urlPatternOf(exchange.request.path),
2106
+ wrap: (data) => ({
2107
+ body: data,
2108
+ status: 200
2109
+ })
2110
+ }
2111
+ };
2112
+ });
2113
+ }
2114
+ //#endregion
1982
2115
  //#region src/core/specification/api/result.ts
1983
2116
  /** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
1984
2117
  var HttpResult = class extends BaseResult {
@@ -1997,6 +2130,30 @@ var HttpResult = class extends BaseResult {
1997
2130
  }
1998
2131
  };
1999
2132
  //#endregion
2133
+ //#region src/core/specification/mobile/result.ts
2134
+ /** Result from a `.open()` action — the screen as the device saw it, final state. */
2135
+ var ScreenResult = class extends BaseResult {
2136
+ capturedScreen;
2137
+ constructor(options) {
2138
+ super(options);
2139
+ this.capturedScreen = options.screen;
2140
+ }
2141
+ /**
2142
+ * The visible screen texts as one stream, in reading order — the scalpel
2143
+ * for a targeted probe: `expect(result.content).toContain('Bookmarked')`.
2144
+ */
2145
+ get content() {
2146
+ return new TextAccessor(this.capturedScreen.texts.join("\n"), "content", this.testDir, { captures: this.captures });
2147
+ }
2148
+ /**
2149
+ * The projected accessibility tree as a JSON accessor — the one golden
2150
+ * per screen: `expect(result.screen).toMatch('events.screen.json')`.
2151
+ */
2152
+ get screen() {
2153
+ return new JsonAccessor(JSON.stringify(this.capturedScreen.tree), this.testDir, void 0, this.captures);
2154
+ }
2155
+ };
2156
+ //#endregion
2000
2157
  //#region src/core/specification/website/result.ts
2001
2158
  /** Result from a raw `.fetch()` action (robots.txt, sitemaps, redirects). */
2002
2159
  var FetchResult = class extends BaseResult {
@@ -2253,6 +2410,7 @@ function copyPlan(path, testDir, workDir) {
2253
2410
  * only surfaces the methods that make sense for it.
2254
2411
  */
2255
2412
  var SpecificationBuilder = class {
2413
+ backendExchanges = [];
2256
2414
  commandEnv = {};
2257
2415
  config;
2258
2416
  fixtures = [];
@@ -2332,6 +2490,7 @@ var SpecificationBuilder = class {
2332
2490
  }
2333
2491
  intercept(triggerOrContracts, maybeResponse) {
2334
2492
  if (this.config.interceptDisabledReason) throw new Error(`.intercept(): ${this.config.interceptDisabledReason}`);
2493
+ if (typeof triggerOrContracts === "string") return this.interceptFile(triggerOrContracts);
2335
2494
  if (Array.isArray(triggerOrContracts)) {
2336
2495
  for (const contract of triggerOrContracts) this.registerIntercept(contract);
2337
2496
  return this;
@@ -2362,6 +2521,27 @@ var SpecificationBuilder = class {
2362
2521
  });
2363
2522
  }
2364
2523
  /**
2524
+ * Register the exchanges of an `intercepts/<name>.http` file — flat in
2525
+ * the calling test's `intercepts/` directory, the same resolution
2526
+ * `.seed()` / `.request()` use. Website/mobile chains feed the declared
2527
+ * stub backend; api/jobs chains feed the MSW engine (same contract list).
2528
+ */
2529
+ interceptFile(file) {
2530
+ if (!file.endsWith(".http")) throw new Error(`.intercept(): a single string argument must name an intercepts/*.http file (e.g. 'two-events.http'), got '${file}'`);
2531
+ const stubFacet = this.config.baseUrl !== void 0 || this.config.device !== void 0;
2532
+ if (stubFacet && !this.config.backend) {
2533
+ const facet = this.config.device === void 0 ? "website" : "mobile";
2534
+ throw new Error(`.intercept(): this runner has no declared backend — add \`backend\` to the specification options (specification.${facet}({ …, backend: { … } })) so the chain's .http intercepts have a stub to serve them.`);
2535
+ }
2536
+ const exchanges = parseInterceptFile(readFileSync(resolve(this.testDir, "intercepts", file), "utf8"), `intercepts/${file}`);
2537
+ if (stubFacet) {
2538
+ this.backendExchanges.push(...exchanges);
2539
+ return this;
2540
+ }
2541
+ this.intercepts.push(...interceptEntriesOf(exchanges));
2542
+ return this;
2543
+ }
2544
+ /**
2365
2545
  * Send the complete request described by `requests/<file>` — first line
2366
2546
  * `METHOD /path`, then headers until a blank line, then the body.
2367
2547
  * Headers set via `.headers()` merge on top of the file's headers.
@@ -2479,6 +2659,24 @@ var SpecificationBuilder = class {
2479
2659
  return this.executeSetup(null, () => this.runVisitAction(path, scenario));
2480
2660
  }
2481
2661
  /**
2662
+ * Terminate and relaunch the app on the simulator, apply the deep link,
2663
+ * run the scenario, and resolve with the captured final screen — the
2664
+ * projected accessibility tree and the visible texts. One driver session
2665
+ * per runner; every open starts from a deterministic fresh app state.
2666
+ *
2667
+ * @example
2668
+ * const result = await mobile.open('news://events');
2669
+ * expect(result.screen).toMatch('events.screen.json');
2670
+ *
2671
+ * const result = await mobile.open('news://events', async (visitor) => {
2672
+ * await visitor.tap(button('Enquête Fauci COVID-19'));
2673
+ * await visitor.see(content('rapports'));
2674
+ * });
2675
+ */
2676
+ open(deepLink, scenario) {
2677
+ return this.executeSetup(null, () => this.runOpenAction(deepLink, scenario));
2678
+ }
2679
+ /**
2482
2680
  * Execute the named job registered via the `jobs` option of
2483
2681
  * `specification.jobs()` and resolve with the result.
2484
2682
  *
@@ -2521,13 +2719,14 @@ var SpecificationBuilder = class {
2521
2719
  const { registerIntercepts } = await import("./intercept.js");
2522
2720
  registration = await registerIntercepts(this.intercepts);
2523
2721
  }
2722
+ this.config.backend?.beginChain(this.backendExchanges);
2524
2723
  try {
2525
2724
  const value = await action();
2526
- const violation = registration?.violation();
2725
+ const violation = registration?.violation() ?? this.config.backend?.violation();
2527
2726
  if (violation) throw violation;
2528
2727
  return value;
2529
2728
  } catch (error) {
2530
- throw registration?.violation() ?? error;
2729
+ throw registration?.violation() ?? this.config.backend?.violation() ?? error;
2531
2730
  } finally {
2532
2731
  registration?.cleanup();
2533
2732
  }
@@ -2595,6 +2794,7 @@ var SpecificationBuilder = class {
2595
2794
  const browser = await this.config.browser();
2596
2795
  const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
2597
2796
  const page = await browser.open(`${baseUrl}${path}`, {
2797
+ allowedOrigins: this.config.backendUrl ? [new URL(this.config.backendUrl).origin] : void 0,
2598
2798
  baseUrl,
2599
2799
  external: this.config.external ?? "allow",
2600
2800
  headers,
@@ -2606,6 +2806,19 @@ var SpecificationBuilder = class {
2606
2806
  testDir: this.testDir
2607
2807
  });
2608
2808
  }
2809
+ async runOpenAction(deepLink, scenario) {
2810
+ if (!this.config.device || this.config.bundleId === void 0) throw new Error(".open() requires a device adapter (use specification.mobile())");
2811
+ const screen = await (await this.config.device()).open({
2812
+ bundleId: this.config.bundleId,
2813
+ deepLink,
2814
+ scenario
2815
+ });
2816
+ return new ScreenResult({
2817
+ config: this.config,
2818
+ screen,
2819
+ testDir: this.testDir
2820
+ });
2821
+ }
2609
2822
  requireBaseUrl(method) {
2610
2823
  if (!this.config.baseUrl) throw new Error(`.${method}() requires a website under test (use specification.website())`);
2611
2824
  return this.config.baseUrl;
@@ -2699,7 +2912,7 @@ function createApiFacet(config) {
2699
2912
  delete: (path) => start().delete(path),
2700
2913
  get: (path) => start().get(path),
2701
2914
  headers: (headers) => start().headers(headers),
2702
- intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2915
+ intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) || typeof triggerOrContracts === "string" ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2703
2916
  post: (path, body) => start().post(path, body),
2704
2917
  put: (path, body) => start().put(path, body),
2705
2918
  request: (file) => start().request(file),
@@ -2712,7 +2925,7 @@ function createApiFacet(config) {
2712
2925
  function createJobsFacet(config) {
2713
2926
  const start = () => new SpecificationBuilder(config, getCallerDir());
2714
2927
  return {
2715
- intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2928
+ intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) || typeof triggerOrContracts === "string" ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2716
2929
  seed: (file, options) => start().seed(file, options),
2717
2930
  trigger: (name) => start().trigger(name)
2718
2931
  };
@@ -2725,10 +2938,21 @@ function createWebsiteFacet(config) {
2725
2938
  return {
2726
2939
  fetch: (path) => start().fetch(path),
2727
2940
  headers: (headers) => start().headers(headers),
2941
+ intercept: (file) => start().intercept(file),
2728
2942
  visit: (path, scenario) => start().visit(path, scenario)
2729
2943
  };
2730
2944
  }
2731
2945
  /**
2946
+ * Create the `mobile` facet bound to the given adapter configuration.
2947
+ */
2948
+ function createMobileFacet(config) {
2949
+ const start = () => new SpecificationBuilder(config, getCallerDir());
2950
+ return {
2951
+ intercept: (file) => start().intercept(file),
2952
+ open: (deepLink, scenario) => start().open(deepLink, scenario)
2953
+ };
2954
+ }
2955
+ /**
2732
2956
  * Create the `cli` facet bound to the given adapter configuration.
2733
2957
  */
2734
2958
  function createCliFacet(config) {
@@ -3423,6 +3647,179 @@ async function startJobs(options) {
3423
3647
  };
3424
3648
  }
3425
3649
  //#endregion
3650
+ //#region src/core/specification/shared/stub-backend.ts
3651
+ /**
3652
+ * The declared stub backend — a small `node:http` server the website and
3653
+ * mobile runners start when their `backend` option is present. It serves the
3654
+ * exchanges the CURRENT chain declared via `.intercept('<name>.http')`:
3655
+ * one chain = one terminal action, and the stub resets between chains the
3656
+ * way databases do.
3657
+ *
3658
+ * Matching is the `intercepts/*.http` grammar (`exchangeMatches`), with the
3659
+ * queue semantics a rendered app needs: same-route entries consume FIFO, and
3660
+ * once a route's queue is exhausted its LAST entry stays sticky — a page
3661
+ * re-fetching the same endpoint (re-render, retry) replays the final reply
3662
+ * instead of failing an honest screen.
3663
+ *
3664
+ * Strictness is the `external: 'block'` analog (CONVENTIONS D7 in spirit): a
3665
+ * request matching no declared exchange is answered 501 and RECORDED; once
3666
+ * the chain declared at least one intercept, the terminal action throws an
3667
+ * error enumerating every unmatched request. Chains with zero intercepts
3668
+ * leave the stub unguarded — mirroring how MSW stays off without them.
3669
+ *
3670
+ * Every response (including the 501 and the OPTIONS preflight) carries
3671
+ * permissive CORS headers — a website's client-side fetches are cross-origin
3672
+ * to the stub by construction.
3673
+ */
3674
+ const CORS_METHODS = "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS";
3675
+ const PREFLIGHT_MAX_AGE = "600";
3676
+ var StubBackend = class {
3677
+ consumed = [];
3678
+ entries = [];
3679
+ /** Guarded once the current chain declared at least one intercept. */
3680
+ guarded = false;
3681
+ options;
3682
+ server = null;
3683
+ unmatchedRequests = /* @__PURE__ */ new Map();
3684
+ url = "";
3685
+ constructor(options = {}) {
3686
+ this.options = options;
3687
+ }
3688
+ /** Start the server and resolve with its base URL. */
3689
+ async start() {
3690
+ const server = createServer((request, response) => {
3691
+ this.handle(request, response);
3692
+ });
3693
+ this.server = server;
3694
+ await new Promise((resolve, reject) => {
3695
+ server.once("error", reject);
3696
+ server.listen(this.options.port ?? 0, "127.0.0.1", () => resolve());
3697
+ });
3698
+ const address = server.address();
3699
+ if (address === null || typeof address === "string") throw new Error("stub backend: could not determine the listening port");
3700
+ this.url = `http://127.0.0.1:${address.port}`;
3701
+ return this.url;
3702
+ }
3703
+ /** Stop the server (idempotent). */
3704
+ async stop() {
3705
+ const server = this.server;
3706
+ if (!server) return;
3707
+ this.server = null;
3708
+ server.closeAllConnections();
3709
+ await new Promise((resolve) => {
3710
+ server.close(() => resolve());
3711
+ });
3712
+ }
3713
+ /**
3714
+ * Arm the stub for one chain: the declared exchanges replace the previous
3715
+ * chain's wholesale, consumption restarts, and the unmatched log clears —
3716
+ * the reset-between-chains databases already follow.
3717
+ */
3718
+ beginChain(exchanges) {
3719
+ this.consumed = exchanges.map(() => false);
3720
+ this.entries = exchanges;
3721
+ this.guarded = exchanges.length > 0;
3722
+ this.unmatchedRequests.clear();
3723
+ }
3724
+ /**
3725
+ * The strict failure for the chain, or null — non-null when the chain
3726
+ * declared at least one intercept AND an unmatched request was recorded.
3727
+ * Enumerates every unmatched request (method, path, count) plus the
3728
+ * declared routes, so the missing exchange writes itself.
3729
+ */
3730
+ violation() {
3731
+ if (!this.guarded || this.unmatchedRequests.size === 0) return null;
3732
+ const unmatched = [...this.unmatchedRequests.values()].map((entry) => ` - ${entry.method} ${entry.path}${entry.count > 1 ? ` (${entry.count} times)` : ""}`).join("\n");
3733
+ return /* @__PURE__ */ new Error(`Unmatched request(s) hit the declared backend during the chain:\n${unmatched}\nDeclared exchanges:\n${this.describeRoutes()}\nEvery backend request of a chain that declares intercepts must match one — add an exchange for it to the chain's intercepts/*.http file.`);
3734
+ }
3735
+ corsHeaders(request) {
3736
+ return {
3737
+ "access-control-allow-headers": request.headers["access-control-request-headers"] ?? "*",
3738
+ "access-control-allow-methods": CORS_METHODS,
3739
+ "access-control-allow-origin": "*",
3740
+ "access-control-max-age": PREFLIGHT_MAX_AGE
3741
+ };
3742
+ }
3743
+ describeRoutes() {
3744
+ if (this.entries.length === 0) return " (no exchanges declared)";
3745
+ return this.entries.map((entry, i) => ` - ${entry.request.method} ${entry.request.path}${this.consumed[i] ? " (consumed)" : ""}`).join("\n");
3746
+ }
3747
+ handle(request, response) {
3748
+ const method = (request.method ?? "GET").toUpperCase();
3749
+ const cors = this.corsHeaders(request);
3750
+ if (method === "OPTIONS") {
3751
+ response.writeHead(204, cors);
3752
+ response.end();
3753
+ return;
3754
+ }
3755
+ const headers = {};
3756
+ for (const [name, value] of Object.entries(request.headers)) if (typeof value === "string") headers[name.toLowerCase()] = value;
3757
+ const url = request.url ?? "/";
3758
+ const entry = this.take({
3759
+ headers,
3760
+ method,
3761
+ url
3762
+ });
3763
+ if (!entry) {
3764
+ this.record(method, url);
3765
+ response.writeHead(501, {
3766
+ ...cors,
3767
+ "content-type": "application/json"
3768
+ });
3769
+ response.end(JSON.stringify({
3770
+ declared: this.entries.map((declared) => `${declared.request.method} ${declared.request.path}`),
3771
+ error: `@jterrazz/test declared backend: no exchange matches ${method} ${url}`
3772
+ }));
3773
+ return;
3774
+ }
3775
+ const { body, hasBody, status } = entry.response;
3776
+ if (!hasBody) {
3777
+ response.writeHead(status, {
3778
+ ...cors,
3779
+ ...entry.response.headers
3780
+ });
3781
+ response.end();
3782
+ return;
3783
+ }
3784
+ const payload = typeof body === "string" ? body : JSON.stringify(body);
3785
+ const contentType = typeof body === "string" ? "text/plain; charset=utf-8" : "application/json";
3786
+ response.writeHead(status, {
3787
+ ...cors,
3788
+ "content-type": contentType,
3789
+ ...entry.response.headers
3790
+ });
3791
+ response.end(payload);
3792
+ }
3793
+ record(method, path) {
3794
+ const key = `${method} ${path}`;
3795
+ const existing = this.unmatchedRequests.get(key);
3796
+ if (existing) existing.count += 1;
3797
+ else this.unmatchedRequests.set(key, {
3798
+ count: 1,
3799
+ method,
3800
+ path
3801
+ });
3802
+ }
3803
+ /**
3804
+ * FIFO with a sticky tail: the first unconsumed matching exchange is
3805
+ * consumed; when every matching exchange is spent, the LAST one keeps
3806
+ * replying.
3807
+ */
3808
+ take(observed) {
3809
+ let sticky = null;
3810
+ for (let i = 0; i < this.entries.length; i++) {
3811
+ const entry = this.entries[i];
3812
+ if (!exchangeMatches(entry.request, observed)) continue;
3813
+ if (!this.consumed[i]) {
3814
+ this.consumed[i] = true;
3815
+ return entry;
3816
+ }
3817
+ sticky = entry;
3818
+ }
3819
+ return sticky;
3820
+ }
3821
+ };
3822
+ //#endregion
3426
3823
  //#region src/core/specification/website/serve.adapter.ts
3427
3824
  /** Grace period between SIGTERM and the SIGKILL escalation. */
3428
3825
  const KILL_GRACE_MS = 2e3;
@@ -3432,7 +3829,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3432
3829
  /** Ask the OS for a free TCP port. */
3433
3830
  function findFreePort() {
3434
3831
  return new Promise((resolve, reject) => {
3435
- const server = createServer();
3832
+ const server = createServer$1();
3436
3833
  server.listen(0, () => {
3437
3834
  const address = server.address();
3438
3835
  if (address === null || typeof address === "string") {
@@ -3458,10 +3855,16 @@ function findFreePort() {
3458
3855
  */
3459
3856
  var ServeAdapter = class {
3460
3857
  child = null;
3858
+ /** Extra environment injected into the child (e.g. the stub backend URL). */
3859
+ extraEnv;
3860
+ /** The constructor named in error messages — `website`, or `mobile` (appium server). */
3861
+ facet;
3461
3862
  options;
3462
3863
  output = "";
3463
3864
  root;
3464
- constructor(options, root) {
3865
+ constructor(options, root, facet = "website", extraEnv = {}) {
3866
+ this.extraEnv = extraEnv;
3867
+ this.facet = facet;
3465
3868
  this.options = options;
3466
3869
  this.root = root;
3467
3870
  }
@@ -3476,6 +3879,7 @@ var ServeAdapter = class {
3476
3879
  detached: process.platform !== "win32",
3477
3880
  env: {
3478
3881
  ...process.env,
3882
+ ...this.extraEnv,
3479
3883
  PORT: String(port)
3480
3884
  },
3481
3885
  shell: true,
@@ -3497,14 +3901,14 @@ var ServeAdapter = class {
3497
3901
  });
3498
3902
  const deadline = Date.now() + timeout;
3499
3903
  for (;;) {
3500
- if (exited) throw new Error(`specification.website(): server command exited before answering HTTP.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
3904
+ if (exited) throw new Error(`specification.${this.facet}(): server command exited before answering HTTP.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
3501
3905
  try {
3502
3906
  await fetch(readyUrl, { redirect: "manual" });
3503
3907
  return baseUrl;
3504
3908
  } catch {
3505
3909
  if (Date.now() >= deadline) {
3506
3910
  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}`);
3911
+ throw new Error(`specification.${this.facet}(): server did not answer on ${readyUrl} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
3508
3912
  }
3509
3913
  await delay(READY_POLL_INTERVAL_MS);
3510
3914
  }
@@ -3544,16 +3948,184 @@ var ServeAdapter = class {
3544
3948
  }
3545
3949
  };
3546
3950
  //#endregion
3951
+ //#region src/core/specification/mobile/appium-server.ts
3952
+ const APPIUM_READY_TIMEOUT_MS = 6e4;
3953
+ /**
3954
+ * The guidance thrown when the optional peers are missing — the exact fix,
3955
+ * mirroring the playwright message on `.visit()`.
3956
+ */
3957
+ const APPIUM_INSTALL_HINT = "specification.mobile() requires appium and webdriverio (optional peer dependencies): npm install -D appium webdriverio && npx appium driver install xcuitest";
3958
+ /**
3959
+ * Start the caller project's appium server as a child process on a free port
3960
+ * and wait until `/status` answers. The binary is resolved from the project
3961
+ * root's `node_modules/.bin` — appium is an optional peer, so its absence
3962
+ * refuses with the exact install command. Process lifecycle (free port via
3963
+ * `PORT`, readiness poll, SIGTERM→SIGKILL group escalation on stop) is the
3964
+ * serve adapter's — one implementation for every child the framework spawns.
3965
+ */
3966
+ async function startAppiumServer(root) {
3967
+ const bin = resolve(root, "node_modules", ".bin", "appium");
3968
+ if (!existsSync(bin)) throw new Error(APPIUM_INSTALL_HINT);
3969
+ const serve = new ServeAdapter({
3970
+ command: `"${bin}" --port "$PORT"`,
3971
+ ready: "/status",
3972
+ timeout: APPIUM_READY_TIMEOUT_MS
3973
+ }, root, "mobile");
3974
+ return {
3975
+ stop: () => serve.stop(),
3976
+ url: await serve.start()
3977
+ };
3978
+ }
3979
+ //#endregion
3980
+ //#region src/core/specification/mobile/simulator.ts
3981
+ /**
3982
+ * Simulator resolution — `device: { name, os? }` → one booted UDID.
3983
+ *
3984
+ * The selection logic is pure (`simctl list` JSON in, one device out) so the
3985
+ * refusals are unit-testable; only the thin wrappers below shell out to
3986
+ * `xcrun`. Zero matches and several matches both refuse with the full device
3987
+ * listing — the framework never guesses which simulator a spec meant.
3988
+ */
3989
+ const run = promisify(execFile);
3990
+ const BOOT_TIMEOUT_MS = 12e4;
3991
+ /** `com.apple.CoreSimulator.SimRuntime.iOS-26-5` → `iOS 26.5`. */
3992
+ function runtimeLabel(runtime) {
3993
+ const match = /SimRuntime\.(?<platform>[A-Za-z]+)-(?<version>[\d-]+)$/.exec(runtime);
3994
+ return match?.groups ? `${match.groups["platform"]} ${match.groups["version"].replaceAll("-", ".")}` : runtime;
3995
+ }
3996
+ /** Flatten the per-runtime record into one available-device list. */
3997
+ function listSimulators(listJson) {
3998
+ const parsed = JSON.parse(listJson);
3999
+ return Object.entries(parsed.devices).flatMap(([runtime, devices]) => devices.filter((device) => device.isAvailable !== false).map((device) => ({
4000
+ name: device.name,
4001
+ os: runtimeLabel(runtime),
4002
+ state: device.state,
4003
+ udid: device.udid
4004
+ })));
4005
+ }
4006
+ /** `iOS 26.5` matches `os: 'iOS 26.5'` and the bare version `'26.5'`, case-insensitive. */
4007
+ function osMatches(deviceOs, wanted) {
4008
+ const normalized = wanted.toLowerCase();
4009
+ return deviceOs.toLowerCase() === normalized || deviceOs.split(" ")[1] === wanted;
4010
+ }
4011
+ function formatListing(devices) {
4012
+ return devices.map((device) => ` ${device.name} — ${device.os} (${device.state}) ${device.udid}`).join("\n");
4013
+ }
4014
+ /**
4015
+ * Pick exactly one simulator by name (and OS when given). Zero or several
4016
+ * matches refuse with the listing needed to fix the options without opening
4017
+ * Xcode.
4018
+ */
4019
+ function selectSimulator(listJson, criteria) {
4020
+ const available = listSimulators(listJson);
4021
+ const matches = available.filter((device) => device.name === criteria.name && (criteria.os === void 0 || osMatches(device.os, criteria.os)));
4022
+ const wanted = criteria.os === void 0 ? `"${criteria.name}"` : `"${criteria.name}" on ${criteria.os}`;
4023
+ if (matches.length === 0) throw new Error(`specification.mobile(): no simulator matches ${wanted}.\nAvailable devices:\n${formatListing(available)}`);
4024
+ 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)}`);
4025
+ return matches[0];
4026
+ }
4027
+ /** Resolve `device: { name, os? }` to a UDID via `xcrun simctl`. */
4028
+ async function resolveSimulatorUdid(criteria) {
4029
+ const { stdout } = await run("xcrun", [
4030
+ "simctl",
4031
+ "list",
4032
+ "devices",
4033
+ "--json"
4034
+ ], { maxBuffer: 16 * 1024 * 1024 });
4035
+ return selectSimulator(stdout, criteria).udid;
4036
+ }
4037
+ /**
4038
+ * Boot the simulator when it is shut down and wait until it reports Booted.
4039
+ * Idempotent: booting an already-booted device is tolerated, and
4040
+ * `bootstatus -b` returns immediately when the device is up.
4041
+ */
4042
+ async function ensureBooted(udid) {
4043
+ try {
4044
+ await run("xcrun", [
4045
+ "simctl",
4046
+ "boot",
4047
+ udid
4048
+ ]);
4049
+ } catch (error) {
4050
+ const message = error instanceof Error ? error.message : String(error);
4051
+ if (!message.includes("current state: Booted")) throw new Error(`specification.mobile(): could not boot simulator ${udid}.\n${message}`, { cause: error });
4052
+ }
4053
+ await run("xcrun", [
4054
+ "simctl",
4055
+ "bootstatus",
4056
+ udid,
4057
+ "-b"
4058
+ ], { timeout: BOOT_TIMEOUT_MS });
4059
+ }
4060
+ //#endregion
4061
+ //#region src/core/specification/mobile/start-mobile.ts
4062
+ async function startMobile(options) {
4063
+ const callerDir = getCallerDir();
4064
+ await registerMatchers();
4065
+ const root = resolveRoot(options.root, callerDir);
4066
+ const udid = options.device.udid ?? await resolveSimulatorUdid(options.device);
4067
+ await ensureBooted(udid);
4068
+ const appium = await startAppiumServer(root);
4069
+ let backend = null;
4070
+ let backendUrl;
4071
+ if (options.backend) {
4072
+ backend = new StubBackend({ port: options.backend.port });
4073
+ backendUrl = await backend.start();
4074
+ }
4075
+ let device = null;
4076
+ const getDevice = async () => {
4077
+ if (!device) {
4078
+ const { AppiumAdapter } = await import("./appium.adapter.js");
4079
+ device = new AppiumAdapter({
4080
+ serverUrl: appium.url,
4081
+ udid
4082
+ });
4083
+ }
4084
+ return device;
4085
+ };
4086
+ const config = {
4087
+ backend: backend ?? void 0,
4088
+ backendUrl,
4089
+ bundleId: options.app.bundleId,
4090
+ device: getDevice
4091
+ };
4092
+ return {
4093
+ backendUrl,
4094
+ cleanup: async () => {
4095
+ if (device) {
4096
+ await device.close();
4097
+ device = null;
4098
+ }
4099
+ await appium.stop();
4100
+ if (backend) await backend.stop();
4101
+ },
4102
+ mobile: createMobileFacet(config),
4103
+ udid
4104
+ };
4105
+ }
4106
+ //#endregion
3547
4107
  //#region src/core/specification/website/start-website.ts
3548
4108
  async function startWebsite(options) {
3549
4109
  const callerDir = getCallerDir();
3550
4110
  await registerMatchers();
4111
+ if (options.backend && !options.server) throw new Error("specification.website(): `backend` requires `server` mode — a deployed `url` cannot be pointed at a local stub.");
4112
+ let backend = null;
4113
+ let backendUrl;
4114
+ if (options.backend) {
4115
+ backend = new StubBackend({ port: options.backend.port });
4116
+ backendUrl = await backend.start();
4117
+ }
3551
4118
  let serve = null;
3552
4119
  let baseUrl;
3553
4120
  if (options.server) {
3554
4121
  const root = resolveRoot(options.root, callerDir);
3555
- serve = new ServeAdapter(options.server, root);
3556
- baseUrl = await serve.start();
4122
+ serve = new ServeAdapter(options.server, root, "website", options.backend && backendUrl !== void 0 ? { [options.backend.env]: backendUrl } : {});
4123
+ try {
4124
+ baseUrl = await serve.start();
4125
+ } catch (error) {
4126
+ await backend?.stop();
4127
+ throw error;
4128
+ }
3557
4129
  } else baseUrl = options.url.replace(/\/$/, "");
3558
4130
  let browser = null;
3559
4131
  const getBrowser = async () => {
@@ -3564,6 +4136,8 @@ async function startWebsite(options) {
3564
4136
  return browser;
3565
4137
  };
3566
4138
  const config = {
4139
+ backend: backend ?? void 0,
4140
+ backendUrl,
3567
4141
  baseUrl,
3568
4142
  browser: getBrowser,
3569
4143
  external: options.external ?? (options.server ? "block" : "allow")
@@ -3575,6 +4149,7 @@ async function startWebsite(options) {
3575
4149
  browser = null;
3576
4150
  }
3577
4151
  if (serve) await serve.stop();
4152
+ if (backend) await backend.stop();
3578
4153
  },
3579
4154
  url: baseUrl,
3580
4155
  website: createWebsiteFacet(config)
@@ -3583,7 +4158,7 @@ async function startWebsite(options) {
3583
4158
  //#endregion
3584
4159
  //#region src/core/specification/shared/specification.ts
3585
4160
  /**
3586
- * The three specification constructors (CONVENTIONS A2) — created in a
4161
+ * The five specification constructors (CONVENTIONS A2) — created in a
3587
4162
  * `*.specification.ts` file under `specs/`, destructured with canonical
3588
4163
  * names, and cleaned up via `afterAll(cleanup)` (A1/A3/A4).
3589
4164
  *
@@ -3627,6 +4202,13 @@ const specification = {
3627
4202
  */
3628
4203
  jobs: startJobs,
3629
4204
  /**
4205
+ * Test a native app on the iOS simulator. `device` names the simulator
4206
+ * (resolved and booted via `simctl`); `app` names the bundle under
4207
+ * test. `.open(deepLink?, scenario?)` relaunches the app fresh, runs
4208
+ * the scenario, and captures the final screen.
4209
+ */
4210
+ mobile: startMobile,
4211
+ /**
3630
4212
  * Test a deployed or locally-served website. `server` starts the site
3631
4213
  * (a shell command receiving `PORT`); `url` targets a running one.
3632
4214
  * `.visit(path)` renders the page in a single shared browser instance;
@@ -3637,42 +4219,65 @@ const specification = {
3637
4219
  };
3638
4220
  //#endregion
3639
4221
  //#region src/core/specification/website/elements.ts
3640
- /**
3641
- * The element vocabulary — user-facing descriptors for visit scenarios.
3642
- * Reads like English (`click(link('Articles'))`), translates to
3643
- * accessibility-first locators in the browser integration. CSS/XPath is
3644
- * deliberately not expressible; `testId()` is the single escape hatch.
3645
- */
3646
- /** A button (or element with the button role), by accessible name. */
3647
- const button = (name) => ({
3648
- kind: "button",
3649
- name
4222
+ const named = (kind) => (name, options) => ({
4223
+ kind,
4224
+ name,
4225
+ ...options?.exact ? { exact: true } : {}
3650
4226
  });
4227
+ /** A button (or element with the button role), by accessible name. */
4228
+ const button = named("button");
3651
4229
  /** A form field, by label. */
3652
- const field = (label) => ({
3653
- kind: "field",
3654
- name: label
3655
- });
4230
+ const field = named("field");
3656
4231
  /** A heading, by accessible name. */
3657
- const heading = (name) => ({
3658
- kind: "heading",
3659
- name
3660
- });
4232
+ const heading = named("heading");
3661
4233
  /** A link, by accessible name. */
3662
- const link = (name) => ({
3663
- kind: "link",
3664
- name
3665
- });
4234
+ const link = named("link");
3666
4235
  /** An element containing the given text. */
3667
- const content = (value) => ({
3668
- kind: "text",
3669
- name: value
3670
- });
4236
+ const content = named("text");
3671
4237
  /** The escape hatch: an element by `data-testid`. Prefer user-facing elements. */
3672
4238
  const testId = (id) => ({
3673
4239
  kind: "testId",
3674
4240
  name: id
3675
4241
  });
4242
+ const landmark = (kind) => (name, options) => ({
4243
+ kind,
4244
+ ...name === void 0 ? {} : { name },
4245
+ ...options?.exact ? { exact: true } : {}
4246
+ });
4247
+ /** The `banner` landmark — the page header. */
4248
+ const banner = landmark("banner");
4249
+ /** The `complementary` landmark — an `<aside>`, a sidebar. */
4250
+ const complementary = landmark("complementary");
4251
+ /** The `contentinfo` landmark — the page footer. */
4252
+ const contentinfo = landmark("contentinfo");
4253
+ /** The `form` landmark — a form carrying an accessible name. */
4254
+ const form = landmark("form");
4255
+ /** The `main` landmark — the primary content of the document. */
4256
+ const main = landmark("main");
4257
+ /** The `navigation` landmark — a `<nav>`. Name it when a page has several. */
4258
+ const navigation = landmark("navigation");
4259
+ /** The `region` landmark — a `<section>` carrying an accessible name. */
4260
+ const region = landmark("region");
4261
+ /** The `search` landmark. */
4262
+ const search = landmark("search");
4263
+ /**
4264
+ * Restrict a descriptor to the inside of another — the answer to ambiguity,
4265
+ * and the one the framework prefers over a test id.
4266
+ *
4267
+ * click(within(navigation(), link('Articles')))
4268
+ *
4269
+ * Composes outside-in: the scope may itself be scoped, so a deeply nested
4270
+ * target reads `within(main(), within(region('Series'), link('Part 2')))`.
4271
+ * Any descriptor works as a scope, including `testId()` when a container has
4272
+ * no landmark role to stand on.
4273
+ */
4274
+ const within = (scope, target) => ({
4275
+ ...target,
4276
+ scope: target.scope ? {
4277
+ ...target.scope,
4278
+ scope
4279
+ } : scope
4280
+ });
3676
4281
  //#endregion
3677
4282
  //#region src/integrations/sqlite/sqlite.ts
3678
4283
  var SqliteHandle = class {
@@ -4246,4 +4851,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
4246
4851
  }));
4247
4852
  registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
4248
4853
  //#endregion
4249
- export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, TableAccessor, TextAccessor, anthropic, button, content, defineContract, field, findContainersByLabel, heading, http, inspectContainer, link, match, mockOf, mockOfDate, openai, postgres, redis, removeContainers, specification, sqlite, testId, text };
4854
+ export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, complementary, content, contentinfo, defineContract, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };