@jterrazz/test 10.0.0 → 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/README.md +72 -20
- package/dist/ambiguity.js +126 -0
- package/dist/appium.adapter.js +386 -0
- package/dist/index.d.ts +345 -7
- package/dist/index.js +602 -20
- package/dist/oxlint.cjs +104 -61
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +104 -61
- package/dist/playwright.adapter.js +3 -127
- package/package.json +13 -4
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:
|
|
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
|
|
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
|
-
|
|
653
|
-
|
|
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
|
-
|
|
668
|
+
source += escapeRegExp(expected.slice(lastIndex));
|
|
667
669
|
return {
|
|
668
|
-
pattern: new RegExp(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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;
|
|
@@ -4269,4 +4851,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
|
|
|
4269
4851
|
}));
|
|
4270
4852
|
registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
|
|
4271
4853
|
//#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 };
|
|
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 };
|