@jterrazz/test 9.0.0 → 9.2.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 +81 -28
- package/dist/checker.js +9 -9
- package/dist/index.d.ts +314 -3
- package/dist/index.js +428 -7
- package/dist/oxlint.cjs +132 -66
- package/dist/oxlint.d.cts +5 -0
- package/dist/oxlint.d.ts +5 -0
- package/dist/oxlint.js +132 -66
- package/dist/playwright.adapter.js +135 -0
- package/package.json +12 -4
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ 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
11
|
import Database from "better-sqlite3";
|
|
11
12
|
import { mockDeep } from "vitest-mock-extended";
|
|
12
13
|
import MockDatePackage from "mockdate";
|
|
@@ -1926,7 +1927,15 @@ async function toMatchRows(received, expected) {
|
|
|
1926
1927
|
};
|
|
1927
1928
|
}
|
|
1928
1929
|
async function toBeEmpty(received) {
|
|
1929
|
-
if (
|
|
1930
|
+
if (received instanceof TextAccessor) {
|
|
1931
|
+
const content = received.comparableText;
|
|
1932
|
+
const pass = content === "";
|
|
1933
|
+
return {
|
|
1934
|
+
message: () => pass ? `expected ${received.streamName} not to be empty` : `Expected ${received.streamName} to be empty, but it contains:\n${content}`,
|
|
1935
|
+
pass
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
if (!(received instanceof TableAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected result.table(...) or a text accessor.");
|
|
1930
1939
|
const rows = await received.query(["*"]);
|
|
1931
1940
|
const pass = rows.length === 0;
|
|
1932
1941
|
return {
|
|
@@ -1988,6 +1997,128 @@ var HttpResult = class extends BaseResult {
|
|
|
1988
1997
|
}
|
|
1989
1998
|
};
|
|
1990
1999
|
//#endregion
|
|
2000
|
+
//#region src/core/specification/website/result.ts
|
|
2001
|
+
/** Result from a raw `.fetch()` action (robots.txt, sitemaps, redirects). */
|
|
2002
|
+
var FetchResult = class extends BaseResult {
|
|
2003
|
+
exchange;
|
|
2004
|
+
constructor(options) {
|
|
2005
|
+
super(options);
|
|
2006
|
+
this.exchange = options.exchange;
|
|
2007
|
+
}
|
|
2008
|
+
/** The response body as a text accessor (`toMatch('robots.txt')`, `.grep()`). */
|
|
2009
|
+
get body() {
|
|
2010
|
+
return new TextAccessor(this.exchange.body, "body", this.testDir, { captures: this.captures });
|
|
2011
|
+
}
|
|
2012
|
+
/** Response headers as a flat, lower-cased key-value map. */
|
|
2013
|
+
get headers() {
|
|
2014
|
+
return this.exchange.headers;
|
|
2015
|
+
}
|
|
2016
|
+
/** The response body parsed as JSON. */
|
|
2017
|
+
get json() {
|
|
2018
|
+
return new JsonAccessor(this.exchange.body, this.testDir, void 0, this.captures);
|
|
2019
|
+
}
|
|
2020
|
+
/** The `location` header of a redirect response, or undefined. */
|
|
2021
|
+
get location() {
|
|
2022
|
+
return this.exchange.headers["location"];
|
|
2023
|
+
}
|
|
2024
|
+
/** The HTTP response status code — redirects surface as 3xx, never followed. */
|
|
2025
|
+
get status() {
|
|
2026
|
+
return this.exchange.status;
|
|
2027
|
+
}
|
|
2028
|
+
};
|
|
2029
|
+
/** Result from a rendered `.visit()` action — the page as a browser saw it. */
|
|
2030
|
+
var PageResult = class extends BaseResult {
|
|
2031
|
+
page;
|
|
2032
|
+
constructor(options) {
|
|
2033
|
+
super(options);
|
|
2034
|
+
this.page = options.page;
|
|
2035
|
+
}
|
|
2036
|
+
/** Hreflang alternates declared in the head, keyed by language code. */
|
|
2037
|
+
get alternates() {
|
|
2038
|
+
const alternates = {};
|
|
2039
|
+
for (const link of this.page.links) if (link.rel === "alternate" && link.hreflang) alternates[link.hreflang] = link.href;
|
|
2040
|
+
return alternates;
|
|
2041
|
+
}
|
|
2042
|
+
/** The canonical URL declared in the head, or null. */
|
|
2043
|
+
get canonical() {
|
|
2044
|
+
return this.page.links.find((link) => link.rel === "canonical")?.href ?? null;
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* The browser console as a stream — one message per line, prefixed by
|
|
2048
|
+
* its type — same shape as `stdout` on a cli result. Assert emptiness
|
|
2049
|
+
* with `toBeEmpty()`, or snapshot with `toMatch('home.console.txt')`.
|
|
2050
|
+
*/
|
|
2051
|
+
get console() {
|
|
2052
|
+
return new TextAccessor(this.page.consoleMessages.map((m) => `[${m.type}] ${m.text}`).join("\n"), "console", this.testDir, { captures: this.captures });
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Console messages of type `error` only — the stream you usually want
|
|
2056
|
+
* empty: `expect(result.errors).toBeEmpty()`. Same shape as `stderr` on
|
|
2057
|
+
* a cli result.
|
|
2058
|
+
*/
|
|
2059
|
+
get errors() {
|
|
2060
|
+
return new TextAccessor(this.page.consoleMessages.filter((m) => m.type === "error").map((m) => m.text).join("\n"), "errors", this.testDir, { captures: this.captures });
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* The document-head projection as a JSON accessor — title, canonical,
|
|
2064
|
+
* hreflang alternates, and named metas — for one golden per page:
|
|
2065
|
+
* `expect(result.head).toMatch('home.head.json')`.
|
|
2066
|
+
*/
|
|
2067
|
+
get head() {
|
|
2068
|
+
const summary = {
|
|
2069
|
+
alternates: this.alternates,
|
|
2070
|
+
canonical: this.canonical,
|
|
2071
|
+
metas: namedMetas(this.page.metas),
|
|
2072
|
+
title: this.page.title
|
|
2073
|
+
};
|
|
2074
|
+
return new JsonAccessor(JSON.stringify(summary), this.testDir, void 0, this.captures);
|
|
2075
|
+
}
|
|
2076
|
+
/** The rendered DOM serialization as a text accessor. */
|
|
2077
|
+
get html() {
|
|
2078
|
+
return new TextAccessor(this.page.html, "html", this.testDir, { captures: this.captures });
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Every `application/ld+json` block of the page, parsed, as one JSON
|
|
2082
|
+
* array — `expect(result.jsonLd).toMatch('article.jsonld.json')`.
|
|
2083
|
+
*/
|
|
2084
|
+
get jsonLd() {
|
|
2085
|
+
return new JsonAccessor(`[${this.page.jsonLdBlocks.join(",")}]`, this.testDir, void 0, this.captures);
|
|
2086
|
+
}
|
|
2087
|
+
/** `<link>` elements of the head, in DOM order. */
|
|
2088
|
+
get links() {
|
|
2089
|
+
return this.page.links;
|
|
2090
|
+
}
|
|
2091
|
+
/** The content of a named meta — `meta('description')`, `meta('og:image')`. */
|
|
2092
|
+
meta(name) {
|
|
2093
|
+
return this.page.metas.find((m) => m.name === name || m.property === name)?.content;
|
|
2094
|
+
}
|
|
2095
|
+
/** HTTP status of the main document response. */
|
|
2096
|
+
get status() {
|
|
2097
|
+
return this.page.status;
|
|
2098
|
+
}
|
|
2099
|
+
/** Rendered body text as a text accessor. */
|
|
2100
|
+
get content() {
|
|
2101
|
+
return new TextAccessor(this.page.text, "text", this.testDir, { captures: this.captures });
|
|
2102
|
+
}
|
|
2103
|
+
/** The document title as a text accessor. */
|
|
2104
|
+
get title() {
|
|
2105
|
+
return new TextAccessor(this.page.title, "title", this.testDir, { captures: this.captures });
|
|
2106
|
+
}
|
|
2107
|
+
/** Final URL after redirects. */
|
|
2108
|
+
get url() {
|
|
2109
|
+
return this.page.url;
|
|
2110
|
+
}
|
|
2111
|
+
};
|
|
2112
|
+
/** Collapse meta elements into a flat name → content map (property wins the key). */
|
|
2113
|
+
function namedMetas(metas) {
|
|
2114
|
+
const named = {};
|
|
2115
|
+
for (const meta of metas) {
|
|
2116
|
+
const key = meta.name ?? meta.property;
|
|
2117
|
+
if (key) named[key] = meta.content;
|
|
2118
|
+
}
|
|
2119
|
+
return named;
|
|
2120
|
+
}
|
|
2121
|
+
//#endregion
|
|
1991
2122
|
//#region src/core/specification/shared/binding.ts
|
|
1992
2123
|
/**
|
|
1993
2124
|
* Compose-binding resolution and the case conversions it shares with the
|
|
@@ -2316,6 +2447,38 @@ var SpecificationBuilder = class {
|
|
|
2316
2447
|
});
|
|
2317
2448
|
}
|
|
2318
2449
|
/**
|
|
2450
|
+
* Perform one raw HTTP GET against the website under test and resolve
|
|
2451
|
+
* with the exchange. Redirects are never followed — a 308 IS the result,
|
|
2452
|
+
* with its `location` readable on the result. The scalpel for wire-level
|
|
2453
|
+
* surfaces: robots.txt, sitemaps, llms.txt, redirect policies.
|
|
2454
|
+
*
|
|
2455
|
+
* @example
|
|
2456
|
+
* const result = await website.fetch('/robots.txt');
|
|
2457
|
+
* expect(result.status).toBe(200);
|
|
2458
|
+
* expect(result.body).toMatch('robots.txt');
|
|
2459
|
+
*/
|
|
2460
|
+
fetch(path) {
|
|
2461
|
+
return this.executeSetup(null, () => this.runFetchAction(path));
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Render the page in the shared browser instance and resolve with the
|
|
2465
|
+
* captured document — rendered title, head elements, JSON-LD blocks,
|
|
2466
|
+
* body text, console errors. One browser per runner; each visit gets a
|
|
2467
|
+
* fresh, isolated context.
|
|
2468
|
+
*
|
|
2469
|
+
* @example
|
|
2470
|
+
* const result = await website.visit('/articles');
|
|
2471
|
+
* expect(result.head).toMatch('articles.head.json');
|
|
2472
|
+
*
|
|
2473
|
+
* const result = await website.visit('/', async (visitor) => {
|
|
2474
|
+
* await visitor.click(link('Articles'));
|
|
2475
|
+
* });
|
|
2476
|
+
* expect(result.url).toContain('/articles');
|
|
2477
|
+
*/
|
|
2478
|
+
visit(path, scenario) {
|
|
2479
|
+
return this.executeSetup(null, () => this.runVisitAction(path, scenario));
|
|
2480
|
+
}
|
|
2481
|
+
/**
|
|
2319
2482
|
* Execute the named job registered via the `jobs` option of
|
|
2320
2483
|
* `specification.jobs()` and resolve with the result.
|
|
2321
2484
|
*
|
|
@@ -2404,6 +2567,49 @@ var SpecificationBuilder = class {
|
|
|
2404
2567
|
testDir: this.testDir
|
|
2405
2568
|
});
|
|
2406
2569
|
}
|
|
2570
|
+
async runFetchAction(path) {
|
|
2571
|
+
const baseUrl = this.requireBaseUrl("fetch");
|
|
2572
|
+
const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
|
|
2573
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
2574
|
+
headers,
|
|
2575
|
+
redirect: "manual"
|
|
2576
|
+
});
|
|
2577
|
+
const body = await response.text();
|
|
2578
|
+
const responseHeaders = {};
|
|
2579
|
+
response.headers.forEach((value, key) => {
|
|
2580
|
+
responseHeaders[key] = value;
|
|
2581
|
+
});
|
|
2582
|
+
return new FetchResult({
|
|
2583
|
+
config: this.config,
|
|
2584
|
+
exchange: {
|
|
2585
|
+
body,
|
|
2586
|
+
headers: responseHeaders,
|
|
2587
|
+
status: response.status
|
|
2588
|
+
},
|
|
2589
|
+
testDir: this.testDir
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
async runVisitAction(path, scenario) {
|
|
2593
|
+
const baseUrl = this.requireBaseUrl("visit");
|
|
2594
|
+
if (!this.config.browser) throw new Error(".visit() requires a browser adapter (use specification.website())");
|
|
2595
|
+
const browser = await this.config.browser();
|
|
2596
|
+
const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
|
|
2597
|
+
const page = await browser.open(`${baseUrl}${path}`, {
|
|
2598
|
+
baseUrl,
|
|
2599
|
+
external: this.config.external ?? "allow",
|
|
2600
|
+
headers,
|
|
2601
|
+
scenario
|
|
2602
|
+
});
|
|
2603
|
+
return new PageResult({
|
|
2604
|
+
config: this.config,
|
|
2605
|
+
page,
|
|
2606
|
+
testDir: this.testDir
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
requireBaseUrl(method) {
|
|
2610
|
+
if (!this.config.baseUrl) throw new Error(`.${method}() requires a website under test (use specification.website())`);
|
|
2611
|
+
return this.config.baseUrl;
|
|
2612
|
+
}
|
|
2407
2613
|
async runJobAction(name) {
|
|
2408
2614
|
if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via the jobs option of specification.jobs()");
|
|
2409
2615
|
const job = this.config.jobs.find((j) => j.name === name);
|
|
@@ -2512,6 +2718,17 @@ function createJobsFacet(config) {
|
|
|
2512
2718
|
};
|
|
2513
2719
|
}
|
|
2514
2720
|
/**
|
|
2721
|
+
* Create the `website` facet bound to the given adapter configuration.
|
|
2722
|
+
*/
|
|
2723
|
+
function createWebsiteFacet(config) {
|
|
2724
|
+
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2725
|
+
return {
|
|
2726
|
+
fetch: (path) => start().fetch(path),
|
|
2727
|
+
headers: (headers) => start().headers(headers),
|
|
2728
|
+
visit: (path, scenario) => start().visit(path, scenario)
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2515
2732
|
* Create the `cli` facet bound to the given adapter configuration.
|
|
2516
2733
|
*/
|
|
2517
2734
|
function createCliFacet(config) {
|
|
@@ -2900,7 +3117,7 @@ function isTransientConnectionError(error) {
|
|
|
2900
3117
|
const cause = error.cause;
|
|
2901
3118
|
return cause?.code !== void 0 && TRANSIENT_CONNECTION_CODES.has(cause.code) || error.message.includes("fetch failed");
|
|
2902
3119
|
}
|
|
2903
|
-
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3120
|
+
const delay$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2904
3121
|
/**
|
|
2905
3122
|
* Server adapter that sends real HTTP requests via the Fetch API.
|
|
2906
3123
|
* Used by the `e2e()` specification runner to hit a live server.
|
|
@@ -2941,7 +3158,7 @@ var FetchAdapter = class {
|
|
|
2941
3158
|
return await fetch(url, init);
|
|
2942
3159
|
} catch (error) {
|
|
2943
3160
|
if (attempt >= MAX_ATTEMPTS || !isTransientConnectionError(error)) throw error;
|
|
2944
|
-
await delay(BASE_BACKOFF_MS * 2 ** (attempt - 1));
|
|
3161
|
+
await delay$1(BASE_BACKOFF_MS * 2 ** (attempt - 1));
|
|
2945
3162
|
}
|
|
2946
3163
|
}
|
|
2947
3164
|
};
|
|
@@ -3004,7 +3221,7 @@ async function startApi(options) {
|
|
|
3004
3221
|
//#region src/core/specification/cli/exec.adapter.ts
|
|
3005
3222
|
const DEFAULT_WATCH_TIMEOUT = 1e4;
|
|
3006
3223
|
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
3007
|
-
const KILL_GRACE_MS = 2e3;
|
|
3224
|
+
const KILL_GRACE_MS$1 = 2e3;
|
|
3008
3225
|
/**
|
|
3009
3226
|
* Build a child-process env from the parent env plus user overrides.
|
|
3010
3227
|
* `null` overrides delete keys (e.g. `INIT_CWD: null`).
|
|
@@ -3047,7 +3264,7 @@ function observeProcess(child, options) {
|
|
|
3047
3264
|
child.kill("SIGTERM");
|
|
3048
3265
|
killTimer = setTimeout(() => {
|
|
3049
3266
|
if (!exited) child.kill("SIGKILL");
|
|
3050
|
-
}, KILL_GRACE_MS);
|
|
3267
|
+
}, KILL_GRACE_MS$1);
|
|
3051
3268
|
};
|
|
3052
3269
|
const finish = (exitCode) => {
|
|
3053
3270
|
if (resolved) return;
|
|
@@ -3206,6 +3423,164 @@ async function startJobs(options) {
|
|
|
3206
3423
|
};
|
|
3207
3424
|
}
|
|
3208
3425
|
//#endregion
|
|
3426
|
+
//#region src/core/specification/website/serve.adapter.ts
|
|
3427
|
+
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
3428
|
+
const KILL_GRACE_MS = 2e3;
|
|
3429
|
+
const DEFAULT_READY_TIMEOUT = 3e4;
|
|
3430
|
+
const READY_POLL_INTERVAL_MS = 250;
|
|
3431
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3432
|
+
/** Ask the OS for a free TCP port. */
|
|
3433
|
+
function findFreePort() {
|
|
3434
|
+
return new Promise((resolve, reject) => {
|
|
3435
|
+
const server = createServer();
|
|
3436
|
+
server.listen(0, () => {
|
|
3437
|
+
const address = server.address();
|
|
3438
|
+
if (address === null || typeof address === "string") {
|
|
3439
|
+
server.close();
|
|
3440
|
+
reject(/* @__PURE__ */ new Error("could not determine a free port"));
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
const { port } = address;
|
|
3444
|
+
server.close(() => resolve(port));
|
|
3445
|
+
});
|
|
3446
|
+
server.on("error", reject);
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
/**
|
|
3450
|
+
* Starts the site under test as a child process and waits until it answers
|
|
3451
|
+
* HTTP. Any HTTP status counts as ready — a 404 on the ready path is still a
|
|
3452
|
+
* listening server; only connection failures keep the poll going.
|
|
3453
|
+
*
|
|
3454
|
+
* The child runs through the shell in its own process group (POSIX) so
|
|
3455
|
+
* `stop()` can terminate the whole tree — a `next start` grandchild must not
|
|
3456
|
+
* outlive the spec run (same escalation as the cli exec adapter: SIGTERM,
|
|
3457
|
+
* then SIGKILL after a 2 s grace).
|
|
3458
|
+
*/
|
|
3459
|
+
var ServeAdapter = class {
|
|
3460
|
+
child = null;
|
|
3461
|
+
options;
|
|
3462
|
+
output = "";
|
|
3463
|
+
root;
|
|
3464
|
+
constructor(options, root) {
|
|
3465
|
+
this.options = options;
|
|
3466
|
+
this.root = root;
|
|
3467
|
+
}
|
|
3468
|
+
/** Start the server and resolve with its base URL once it answers HTTP. */
|
|
3469
|
+
async start() {
|
|
3470
|
+
const port = this.options.port ?? await findFreePort();
|
|
3471
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
3472
|
+
const readyUrl = `${baseUrl}${this.options.ready ?? "/"}`;
|
|
3473
|
+
const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
|
|
3474
|
+
this.child = spawn(this.options.command, [], {
|
|
3475
|
+
cwd: this.root,
|
|
3476
|
+
detached: process.platform !== "win32",
|
|
3477
|
+
env: {
|
|
3478
|
+
...process.env,
|
|
3479
|
+
PORT: String(port)
|
|
3480
|
+
},
|
|
3481
|
+
shell: true,
|
|
3482
|
+
stdio: [
|
|
3483
|
+
"ignore",
|
|
3484
|
+
"pipe",
|
|
3485
|
+
"pipe"
|
|
3486
|
+
]
|
|
3487
|
+
});
|
|
3488
|
+
this.child.stdout?.on("data", (data) => {
|
|
3489
|
+
this.output += data.toString();
|
|
3490
|
+
});
|
|
3491
|
+
this.child.stderr?.on("data", (data) => {
|
|
3492
|
+
this.output += data.toString();
|
|
3493
|
+
});
|
|
3494
|
+
let exited = false;
|
|
3495
|
+
this.child.on("exit", () => {
|
|
3496
|
+
exited = true;
|
|
3497
|
+
});
|
|
3498
|
+
const deadline = Date.now() + timeout;
|
|
3499
|
+
for (;;) {
|
|
3500
|
+
if (exited) throw new Error(`specification.website(): server command exited before answering HTTP.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
3501
|
+
try {
|
|
3502
|
+
await fetch(readyUrl, { redirect: "manual" });
|
|
3503
|
+
return baseUrl;
|
|
3504
|
+
} catch {
|
|
3505
|
+
if (Date.now() >= deadline) {
|
|
3506
|
+
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}`);
|
|
3508
|
+
}
|
|
3509
|
+
await delay(READY_POLL_INTERVAL_MS);
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
/** Terminate the server process group (idempotent). */
|
|
3514
|
+
async stop() {
|
|
3515
|
+
const child = this.child;
|
|
3516
|
+
if (!child) return;
|
|
3517
|
+
this.child = null;
|
|
3518
|
+
await new Promise((resolve) => {
|
|
3519
|
+
let exited = false;
|
|
3520
|
+
child.on("exit", () => {
|
|
3521
|
+
exited = true;
|
|
3522
|
+
resolve();
|
|
3523
|
+
});
|
|
3524
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
3525
|
+
resolve();
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
this.kill(child, "SIGTERM");
|
|
3529
|
+
setTimeout(() => {
|
|
3530
|
+
if (!exited) {
|
|
3531
|
+
this.kill(child, "SIGKILL");
|
|
3532
|
+
resolve();
|
|
3533
|
+
}
|
|
3534
|
+
}, KILL_GRACE_MS).unref?.();
|
|
3535
|
+
});
|
|
3536
|
+
}
|
|
3537
|
+
/** Group-aware kill: signal the process group, fall back to the child. */
|
|
3538
|
+
kill(child, signal) {
|
|
3539
|
+
if (child.pid !== void 0 && process.platform !== "win32") try {
|
|
3540
|
+
process.kill(-child.pid, signal);
|
|
3541
|
+
return;
|
|
3542
|
+
} catch {}
|
|
3543
|
+
child.kill(signal);
|
|
3544
|
+
}
|
|
3545
|
+
};
|
|
3546
|
+
//#endregion
|
|
3547
|
+
//#region src/core/specification/website/start-website.ts
|
|
3548
|
+
async function startWebsite(options) {
|
|
3549
|
+
const callerDir = getCallerDir();
|
|
3550
|
+
await registerMatchers();
|
|
3551
|
+
let serve = null;
|
|
3552
|
+
let baseUrl;
|
|
3553
|
+
if (options.server) {
|
|
3554
|
+
const root = resolveRoot(options.root, callerDir);
|
|
3555
|
+
serve = new ServeAdapter(options.server, root);
|
|
3556
|
+
baseUrl = await serve.start();
|
|
3557
|
+
} else baseUrl = options.url.replace(/\/$/, "");
|
|
3558
|
+
let browser = null;
|
|
3559
|
+
const getBrowser = async () => {
|
|
3560
|
+
if (!browser) {
|
|
3561
|
+
const { PlaywrightAdapter } = await import("./playwright.adapter.js");
|
|
3562
|
+
browser = new PlaywrightAdapter();
|
|
3563
|
+
}
|
|
3564
|
+
return browser;
|
|
3565
|
+
};
|
|
3566
|
+
const config = {
|
|
3567
|
+
baseUrl,
|
|
3568
|
+
browser: getBrowser,
|
|
3569
|
+
external: options.external ?? (options.server ? "block" : "allow")
|
|
3570
|
+
};
|
|
3571
|
+
return {
|
|
3572
|
+
cleanup: async () => {
|
|
3573
|
+
if (browser) {
|
|
3574
|
+
await browser.close();
|
|
3575
|
+
browser = null;
|
|
3576
|
+
}
|
|
3577
|
+
if (serve) await serve.stop();
|
|
3578
|
+
},
|
|
3579
|
+
url: baseUrl,
|
|
3580
|
+
website: createWebsiteFacet(config)
|
|
3581
|
+
};
|
|
3582
|
+
}
|
|
3583
|
+
//#endregion
|
|
3209
3584
|
//#region src/core/specification/shared/specification.ts
|
|
3210
3585
|
/**
|
|
3211
3586
|
* The three specification constructors (CONVENTIONS A2) — created in a
|
|
@@ -3250,9 +3625,55 @@ const specification = {
|
|
|
3250
3625
|
* Test background jobs. Jobs run in-process by definition — no HTTP
|
|
3251
3626
|
* server, no mode. `.trigger(name)` is the terminal action.
|
|
3252
3627
|
*/
|
|
3253
|
-
jobs: startJobs
|
|
3628
|
+
jobs: startJobs,
|
|
3629
|
+
/**
|
|
3630
|
+
* Test a deployed or locally-served website. `server` starts the site
|
|
3631
|
+
* (a shell command receiving `PORT`); `url` targets a running one.
|
|
3632
|
+
* `.visit(path)` renders the page in a single shared browser instance;
|
|
3633
|
+
* `.fetch(path)` performs one raw HTTP exchange (redirects never
|
|
3634
|
+
* followed).
|
|
3635
|
+
*/
|
|
3636
|
+
website: startWebsite
|
|
3254
3637
|
};
|
|
3255
3638
|
//#endregion
|
|
3639
|
+
//#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
|
|
3650
|
+
});
|
|
3651
|
+
/** A form field, by label. */
|
|
3652
|
+
const field = (label) => ({
|
|
3653
|
+
kind: "field",
|
|
3654
|
+
name: label
|
|
3655
|
+
});
|
|
3656
|
+
/** A heading, by accessible name. */
|
|
3657
|
+
const heading = (name) => ({
|
|
3658
|
+
kind: "heading",
|
|
3659
|
+
name
|
|
3660
|
+
});
|
|
3661
|
+
/** A link, by accessible name. */
|
|
3662
|
+
const link = (name) => ({
|
|
3663
|
+
kind: "link",
|
|
3664
|
+
name
|
|
3665
|
+
});
|
|
3666
|
+
/** An element containing the given text. */
|
|
3667
|
+
const content = (value) => ({
|
|
3668
|
+
kind: "text",
|
|
3669
|
+
name: value
|
|
3670
|
+
});
|
|
3671
|
+
/** The escape hatch: an element by `data-testid`. Prefer user-facing elements. */
|
|
3672
|
+
const testId = (id) => ({
|
|
3673
|
+
kind: "testId",
|
|
3674
|
+
name: id
|
|
3675
|
+
});
|
|
3676
|
+
//#endregion
|
|
3256
3677
|
//#region src/integrations/sqlite/sqlite.ts
|
|
3257
3678
|
var SqliteHandle = class {
|
|
3258
3679
|
type = "sqlite";
|
|
@@ -3825,4 +4246,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
|
|
|
3825
4246
|
}));
|
|
3826
4247
|
registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
|
|
3827
4248
|
//#endregion
|
|
3828
|
-
export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, ResponseAccessor, TableAccessor, TextAccessor, anthropic, defineContract, findContainersByLabel, http, inspectContainer, match, mockOf, mockOfDate, openai, postgres, redis, removeContainers, specification, sqlite, text };
|
|
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 };
|