@jterrazz/test 14.3.0 → 15.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 +11 -11
- package/dist/ambiguity.js +2 -2
- package/dist/appium.adapter.js +34 -25
- package/dist/artifacts.js +503 -525
- package/dist/checker.js +74 -73
- package/dist/index.d.ts +587 -660
- package/dist/index.js +191 -188
- package/dist/match.js +6 -16
- package/dist/oxlint.cjs +681 -873
- package/dist/oxlint.d.cts +7066 -17
- package/dist/oxlint.d.ts +7066 -17
- package/dist/oxlint.js +681 -873
- package/dist/playwright.adapter.js +36 -14
- package/dist/queue.js +27 -28
- package/dist/spec-document.js +19 -25
- package/dist/vitest.d.ts +6 -6
- package/dist/vitest.js +2 -2
- package/package.json +10 -6
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { a as copyPlan, c as GROUND_EXPECTED, d as GROUND_SEEDS, i as match, n as Matcher, t as CaptureScope, u as GROUND_REQUESTS } from "./match.js";
|
|
2
|
-
import {
|
|
2
|
+
import { B as text, C as FilesystemAccessor, E as walkDirectory, F as findContainersByLabel, I as inspectContainer, L as removeContainers, M as structuralEquals, N as structuralSubset, O as mergePreservingPlaceholders, P as textEquals, R as JsonAccessor, S as resolveRoot, T as diffDirectories, V as getCallerDir, _ as validateDatabaseOption, b as expandWorkdir, c as shouldUpdateSnapshots, d as formatStartupReport, f as formatStdoutDiff, g as BaseResult, h as CliResult, i as runSpecDocument, j as renderExpected, k as mergeTextPreservingPlaceholders, l as formatDirectoryDiff, m as ContainerAccessor, n as SQLITE_TEMPLATE_DIR, o as ServeAdapter, p as formatTableDiff, s as UPDATE_HINT, u as formatResponseDiff, v as TableAccessor, w as DirectoryAccessor, x as resolveCommand, y as discoverRoot, z as TextAccessor } from "./artifacts.js";
|
|
3
3
|
import { a as isContract, i as defineContracts, n as contractsOf, o as isContracts, r as defineContract, t as ContractQueue } from "./queue.js";
|
|
4
4
|
import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
5
5
|
import { dirname, join, relative, resolve } from "node:path";
|
|
@@ -13,25 +13,6 @@ import Database from "better-sqlite3";
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { mockDeep } from "vitest-mock-extended";
|
|
15
15
|
import MockDatePackage from "mockdate";
|
|
16
|
-
//#region src/core/specification/shared/registry.ts
|
|
17
|
-
let containerIntegrations = null;
|
|
18
|
-
const composeServiceFactories = /* @__PURE__ */ new Map();
|
|
19
|
-
/** Wire the container runtimes. Called by the package entry point. */
|
|
20
|
-
function registerContainerIntegrations(integrations) {
|
|
21
|
-
containerIntegrations = integrations;
|
|
22
|
-
}
|
|
23
|
-
/** Wire a service auto-detection factory (e.g. `'postgres'`, `'redis'`). */
|
|
24
|
-
function registerComposeServiceFactory(type, factory) {
|
|
25
|
-
composeServiceFactories.set(type, factory);
|
|
26
|
-
}
|
|
27
|
-
function getContainerIntegrations() {
|
|
28
|
-
if (!containerIntegrations) throw new Error("@jterrazz/test: container integrations are not registered — import from '@jterrazz/test' (the package entry point wires them).");
|
|
29
|
-
return containerIntegrations;
|
|
30
|
-
}
|
|
31
|
-
function getComposeServiceFactory(type) {
|
|
32
|
-
return composeServiceFactories.get(type);
|
|
33
|
-
}
|
|
34
|
-
//#endregion
|
|
35
16
|
//#region src/integrations/compose/compose-parser.ts
|
|
36
17
|
/**
|
|
37
18
|
* Parse a docker-compose file and extract service definitions.
|
|
@@ -58,7 +39,7 @@ function parseComposeFile(filePath) {
|
|
|
58
39
|
const environment = {};
|
|
59
40
|
if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
|
|
60
41
|
const [key, ...rest] = String(env).split("=");
|
|
61
|
-
environment[key] = rest.join("=");
|
|
42
|
+
if (key !== void 0) environment[key] = rest.join("=");
|
|
62
43
|
}
|
|
63
44
|
else Object.assign(environment, def.environment);
|
|
64
45
|
const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
|
|
@@ -351,7 +332,7 @@ var TestcontainersAdapter = class {
|
|
|
351
332
|
const { GenericContainer, Wait } = await import("testcontainers");
|
|
352
333
|
let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
|
|
353
334
|
for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
|
|
354
|
-
if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections
|
|
335
|
+
if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/u, 2));
|
|
355
336
|
if (this.reuse) builder = builder.withReuse();
|
|
356
337
|
this.container = await builder.start();
|
|
357
338
|
}
|
|
@@ -375,7 +356,7 @@ var TestcontainersAdapter = class {
|
|
|
375
356
|
async getLogs() {
|
|
376
357
|
if (!this.container) return "";
|
|
377
358
|
const stream = await this.container.logs();
|
|
378
|
-
return new Promise((resolve) => {
|
|
359
|
+
return await new Promise((resolve) => {
|
|
379
360
|
let output = "";
|
|
380
361
|
stream.on("data", (chunk) => {
|
|
381
362
|
output += chunk.toString();
|
|
@@ -390,6 +371,25 @@ var TestcontainersAdapter = class {
|
|
|
390
371
|
}
|
|
391
372
|
};
|
|
392
373
|
//#endregion
|
|
374
|
+
//#region src/specification/facets/_common/registry.ts
|
|
375
|
+
let containerIntegrations = null;
|
|
376
|
+
const composeServiceFactories = /* @__PURE__ */ new Map();
|
|
377
|
+
/** Wire the container runtimes. Called by the package entry point. */
|
|
378
|
+
function registerContainerIntegrations(integrations) {
|
|
379
|
+
containerIntegrations = integrations;
|
|
380
|
+
}
|
|
381
|
+
/** Wire a service auto-detection factory (e.g. `'postgres'`, `'redis'`). */
|
|
382
|
+
function registerComposeServiceFactory(type, factory) {
|
|
383
|
+
composeServiceFactories.set(type, factory);
|
|
384
|
+
}
|
|
385
|
+
function getContainerIntegrations() {
|
|
386
|
+
if (!containerIntegrations) throw new Error("@jterrazz/test: container integrations are not registered — import from '@jterrazz/test' (the package entry point wires them).");
|
|
387
|
+
return containerIntegrations;
|
|
388
|
+
}
|
|
389
|
+
function getComposeServiceFactory(type) {
|
|
390
|
+
return composeServiceFactories.get(type);
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
393
|
//#region src/integrations/hono/hono.adapter.ts
|
|
394
394
|
/**
|
|
395
395
|
* Server adapter that dispatches requests in-process through a Hono app instance.
|
|
@@ -423,17 +423,48 @@ var HonoAdapter = class {
|
|
|
423
423
|
}
|
|
424
424
|
};
|
|
425
425
|
//#endregion
|
|
426
|
-
//#region src/
|
|
426
|
+
//#region src/specification/facets/_common/result/response.ts
|
|
427
|
+
/**
|
|
428
|
+
* Read-only accessor for an HTTP response.
|
|
429
|
+
*
|
|
430
|
+
* Assertions go through `expect()`:
|
|
431
|
+
* `expect(result.response).toMatch('created.http')` compares status,
|
|
432
|
+
* a subset of headers, and the JSON body against `_expected/<name>` —
|
|
433
|
+
* with `{{placeholder}}` support in all three.
|
|
434
|
+
*/
|
|
435
|
+
var ResponseAccessor = class {
|
|
436
|
+
/** The parsed JSON response body (null when parsing failed). */
|
|
437
|
+
body;
|
|
438
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
439
|
+
captures;
|
|
440
|
+
/** Response headers as a flat, lower-cased key-value map. */
|
|
441
|
+
headers;
|
|
442
|
+
/** The HTTP response status code. */
|
|
443
|
+
status;
|
|
444
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
445
|
+
testDir;
|
|
446
|
+
constructor(response, testDir, captures) {
|
|
447
|
+
this.body = response.body;
|
|
448
|
+
this.headers = response.headers;
|
|
449
|
+
this.status = response.status;
|
|
450
|
+
this.testDir = testDir;
|
|
451
|
+
this.captures = captures ?? new CaptureScope();
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/specification/http-files/http-file.ts
|
|
427
456
|
function splitSections(content) {
|
|
428
|
-
const lines = content.split(/\r?\n/);
|
|
457
|
+
const lines = content.split(/\r?\n/u);
|
|
429
458
|
let index = 0;
|
|
430
|
-
while (
|
|
459
|
+
while (lines[index]?.trim() === "") index++;
|
|
431
460
|
const startLine = lines[index] ?? "";
|
|
432
461
|
index++;
|
|
433
462
|
const headerLines = [];
|
|
434
|
-
|
|
435
|
-
|
|
463
|
+
let header = lines[index];
|
|
464
|
+
while (header !== void 0 && header.trim() !== "") {
|
|
465
|
+
headerLines.push(header);
|
|
436
466
|
index++;
|
|
467
|
+
header = lines[index];
|
|
437
468
|
}
|
|
438
469
|
return {
|
|
439
470
|
body: lines.slice(index + 1).join("\n").trim(),
|
|
@@ -453,20 +484,22 @@ function parseHeaders(headerLines, fileName) {
|
|
|
453
484
|
/** Parse a `_requests/*.http` file. First line must be `METHOD /path`. */
|
|
454
485
|
function parseRequestFile(content, fileName) {
|
|
455
486
|
const { body, headerLines, startLine } = splitSections(content);
|
|
456
|
-
const start = /^(?<method>[A-Z]+)\s+(?<path>\S+)
|
|
457
|
-
|
|
487
|
+
const start = /^(?<method>[A-Z]+)\s+(?<path>\S+)$/u.exec(startLine.trim());
|
|
488
|
+
const method = start?.groups?.method;
|
|
489
|
+
const path = start?.groups?.path;
|
|
490
|
+
if (method === void 0 || path === void 0) throw new Error(`${fileName}: first line must be "METHOD /path" (e.g. "POST /users"), got "${startLine}"`);
|
|
458
491
|
return {
|
|
459
492
|
body: body.length > 0 ? body : void 0,
|
|
460
493
|
headers: parseHeaders(headerLines, fileName),
|
|
461
|
-
method
|
|
462
|
-
path
|
|
494
|
+
method,
|
|
495
|
+
path
|
|
463
496
|
};
|
|
464
497
|
}
|
|
465
498
|
/** Parse a response `.http` file. First line must be `HTTP/1.1 <status>`. */
|
|
466
499
|
function parseResponseFile(content, fileName) {
|
|
467
500
|
const { body, headerLines, startLine } = splitSections(content);
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
501
|
+
const status = /^HTTP\/1\.1\s+(?<status>\S+)(?:\s+.*)?$/u.exec(startLine.trim())?.groups?.status;
|
|
502
|
+
if (status === void 0) throw new Error(`${fileName}: first line must be "HTTP/1.1 <status>" (e.g. "HTTP/1.1 200 OK"), got "${startLine}"`);
|
|
470
503
|
let parsedBody;
|
|
471
504
|
if (body.length > 0) try {
|
|
472
505
|
parsedBody = JSON.parse(body);
|
|
@@ -477,7 +510,7 @@ function parseResponseFile(content, fileName) {
|
|
|
477
510
|
body: parsedBody,
|
|
478
511
|
hasBody: body.length > 0,
|
|
479
512
|
headers: parseHeaders(headerLines, fileName),
|
|
480
|
-
status
|
|
513
|
+
status
|
|
481
514
|
};
|
|
482
515
|
}
|
|
483
516
|
const STATUS_TEXT = {
|
|
@@ -505,10 +538,7 @@ function serializeResponseFile(response) {
|
|
|
505
538
|
const statusText = STATUS_TEXT[statusNumber];
|
|
506
539
|
const lines = [`HTTP/1.1 ${response.status}${statusText ? ` ${statusText}` : ""}`];
|
|
507
540
|
for (const [name, value] of Object.entries(response.headers)) lines.push(`${name}: ${value}`);
|
|
508
|
-
if (response.hasBody)
|
|
509
|
-
lines.push("");
|
|
510
|
-
lines.push(typeof response.body === "string" && !isJsonLike(response.body) ? response.body : JSON.stringify(response.body, null, 4));
|
|
511
|
-
}
|
|
541
|
+
if (response.hasBody) lines.push("", typeof response.body === "string" && !isJsonLike(response.body) ? response.body : JSON.stringify(response.body, null, 4));
|
|
512
542
|
return `${lines.join("\n")}\n`;
|
|
513
543
|
}
|
|
514
544
|
function isJsonLike(text) {
|
|
@@ -520,35 +550,6 @@ function isJsonLike(text) {
|
|
|
520
550
|
}
|
|
521
551
|
}
|
|
522
552
|
//#endregion
|
|
523
|
-
//#region src/core/specification/shared/result/response.ts
|
|
524
|
-
/**
|
|
525
|
-
* Read-only accessor for an HTTP response.
|
|
526
|
-
*
|
|
527
|
-
* Assertions go through `expect()`:
|
|
528
|
-
* `expect(result.response).toMatch('created.http')` compares status,
|
|
529
|
-
* a subset of headers, and the JSON body against `_expected/<name>` —
|
|
530
|
-
* with `{{placeholder}}` support in all three.
|
|
531
|
-
*/
|
|
532
|
-
var ResponseAccessor = class {
|
|
533
|
-
/** The parsed JSON response body (null when parsing failed). */
|
|
534
|
-
body;
|
|
535
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
536
|
-
captures;
|
|
537
|
-
/** Response headers as a flat, lower-cased key-value map. */
|
|
538
|
-
headers;
|
|
539
|
-
/** The HTTP response status code. */
|
|
540
|
-
status;
|
|
541
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
542
|
-
testDir;
|
|
543
|
-
constructor(response, testDir, captures) {
|
|
544
|
-
this.body = response.body;
|
|
545
|
-
this.headers = response.headers;
|
|
546
|
-
this.status = response.status;
|
|
547
|
-
this.testDir = testDir;
|
|
548
|
-
this.captures = captures ?? new CaptureScope();
|
|
549
|
-
}
|
|
550
|
-
};
|
|
551
|
-
//#endregion
|
|
552
553
|
//#region src/vitest/matchers.ts
|
|
553
554
|
/**
|
|
554
555
|
* Vitest custom matchers for `@jterrazz/test` accessors.
|
|
@@ -570,7 +571,7 @@ const FAIL = (message) => ({
|
|
|
570
571
|
pass: false
|
|
571
572
|
});
|
|
572
573
|
function requireExtension(name, subject) {
|
|
573
|
-
if (!/\.[A-Za-z0-9]
|
|
574
|
+
if (!/\.[A-Za-z0-9]+$/u.test(name)) throw new Error(`toMatch("${name}"): the extension is part of the name and is required for ${subject} subjects (e.g. "help.txt").`);
|
|
574
575
|
}
|
|
575
576
|
function formatJson(value) {
|
|
576
577
|
return `${JSON.stringify(value, null, 4)}\n`;
|
|
@@ -639,7 +640,7 @@ function buildUpdatedResponse(previous, actual, workdir) {
|
|
|
639
640
|
* @internal Exported for unit tests.
|
|
640
641
|
*/
|
|
641
642
|
function compareResponse(name, expected, actual, scope) {
|
|
642
|
-
if (!(/^\d
|
|
643
|
+
if (!(/^\d+$/u.test(expected.status) ? Number(expected.status) === actual.status : structuralEquals(expected.status, String(actual.status), scope))) return `Response status mismatch (${name})\n expected: ${expected.status}\n received: ${actual.status}`;
|
|
643
644
|
for (const [key, value] of Object.entries(expected.headers)) {
|
|
644
645
|
const actualValue = actual.headers[key.toLowerCase()];
|
|
645
646
|
if (actualValue === void 0 || !structuralEquals(value, actualValue, scope)) return `Response header mismatch (${name})\n header: ${key}\n expected: ${value}\n received: ${actualValue ?? "(absent)"}`;
|
|
@@ -756,7 +757,10 @@ function toContain(received, expected) {
|
|
|
756
757
|
async function toMatchRows(received, expected) {
|
|
757
758
|
if (!(received instanceof TableAccessor)) throw new TypeError("toMatchRows: unsupported subject — expected result.table(...).");
|
|
758
759
|
const actual = await received.query(expected.columns);
|
|
759
|
-
const pass = actual.length === expected.rows.length && expected.rows.every((row, i) =>
|
|
760
|
+
const pass = actual.length === expected.rows.length && expected.rows.every((row, i) => {
|
|
761
|
+
const actualRow = actual[i];
|
|
762
|
+
return actualRow !== void 0 && row.length === actualRow.length && row.every((cell, j) => structuralEquals(cell, actualRow[j], received.captures));
|
|
763
|
+
});
|
|
760
764
|
return {
|
|
761
765
|
message: () => pass ? `expected table "${received.name}" not to match the given rows` : formatTableDiff(received.name, expected.columns, expected.rows.map((row) => row.map((cell) => renderExpected(cell))), actual),
|
|
762
766
|
pass
|
|
@@ -815,7 +819,7 @@ async function registerMatchers() {
|
|
|
815
819
|
}
|
|
816
820
|
}
|
|
817
821
|
//#endregion
|
|
818
|
-
//#region src/
|
|
822
|
+
//#region src/specification/facets/api/result.ts
|
|
819
823
|
/** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
|
|
820
824
|
var HttpResult = class extends BaseResult {
|
|
821
825
|
responseData;
|
|
@@ -833,7 +837,7 @@ var HttpResult = class extends BaseResult {
|
|
|
833
837
|
}
|
|
834
838
|
};
|
|
835
839
|
//#endregion
|
|
836
|
-
//#region src/
|
|
840
|
+
//#region src/specification/facets/mobile/result.ts
|
|
837
841
|
/** Result from a `.open()` action — the screen as the device saw it, final state. */
|
|
838
842
|
var ScreenResult = class extends BaseResult {
|
|
839
843
|
capturedScreen;
|
|
@@ -857,7 +861,7 @@ var ScreenResult = class extends BaseResult {
|
|
|
857
861
|
}
|
|
858
862
|
};
|
|
859
863
|
//#endregion
|
|
860
|
-
//#region src/
|
|
864
|
+
//#region src/specification/facets/website/result.ts
|
|
861
865
|
/** Result from a raw `.fetch()` action (robots.txt, sitemaps, redirects). */
|
|
862
866
|
var FetchResult = class extends BaseResult {
|
|
863
867
|
exchange;
|
|
@@ -879,7 +883,7 @@ var FetchResult = class extends BaseResult {
|
|
|
879
883
|
}
|
|
880
884
|
/** The `location` header of a redirect response, or undefined. */
|
|
881
885
|
get location() {
|
|
882
|
-
return this.exchange.headers
|
|
886
|
+
return this.exchange.headers.location;
|
|
883
887
|
}
|
|
884
888
|
/** The HTTP response status code — redirects surface as 3xx, never followed. */
|
|
885
889
|
get status() {
|
|
@@ -979,7 +983,7 @@ function namedMetas(metas) {
|
|
|
979
983
|
return named;
|
|
980
984
|
}
|
|
981
985
|
//#endregion
|
|
982
|
-
//#region src/
|
|
986
|
+
//#region src/specification/facets/_common/binding.ts
|
|
983
987
|
/**
|
|
984
988
|
* Compose-binding resolution and the case conversions it shares with the
|
|
985
989
|
* automatic env injection (CONVENTIONS A6 / B6).
|
|
@@ -995,7 +999,7 @@ function namedMetas(metas) {
|
|
|
995
999
|
* `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
|
|
996
1000
|
*/
|
|
997
1001
|
function toKebabCase(key) {
|
|
998
|
-
return key.
|
|
1002
|
+
return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>-$<upper>").toLowerCase();
|
|
999
1003
|
}
|
|
1000
1004
|
/**
|
|
1001
1005
|
* Convert a record key to CONSTANT_CASE for env injection: insert `_` at
|
|
@@ -1003,7 +1007,7 @@ function toKebabCase(key) {
|
|
|
1003
1007
|
* uppercase. `analyticsDb` → `ANALYTICS_DB`, `db-main` → `DB_MAIN`, `db` → `DB`.
|
|
1004
1008
|
*/
|
|
1005
1009
|
function toConstantCase(key) {
|
|
1006
|
-
return key.
|
|
1010
|
+
return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>_$<upper>").replaceAll(/[^A-Za-z0-9]/gu, "_").toUpperCase();
|
|
1007
1011
|
}
|
|
1008
1012
|
/**
|
|
1009
1013
|
* Resolve the compose service a record key binds to (CONVENTIONS A6).
|
|
@@ -1028,7 +1032,7 @@ function resolveComposeBinding(key, explicitComposeName, serviceNames) {
|
|
|
1028
1032
|
return key;
|
|
1029
1033
|
}
|
|
1030
1034
|
//#endregion
|
|
1031
|
-
//#region src/
|
|
1035
|
+
//#region src/specification/facets/_common/builder.ts
|
|
1032
1036
|
/**
|
|
1033
1037
|
* Fluent builder for declaring a single test specification.
|
|
1034
1038
|
*
|
|
@@ -1146,8 +1150,8 @@ var SpecificationBuilder = class {
|
|
|
1146
1150
|
* @example
|
|
1147
1151
|
* const result = await api.request("create-user.http");
|
|
1148
1152
|
*/
|
|
1149
|
-
request(file) {
|
|
1150
|
-
return this.executeHttp({
|
|
1153
|
+
async request(file) {
|
|
1154
|
+
return await this.executeHttp({
|
|
1151
1155
|
method: "",
|
|
1152
1156
|
path: "",
|
|
1153
1157
|
requestFile: file
|
|
@@ -1159,8 +1163,8 @@ var SpecificationBuilder = class {
|
|
|
1159
1163
|
* @example
|
|
1160
1164
|
* const result = await api.get("/api/items");
|
|
1161
1165
|
*/
|
|
1162
|
-
get(path) {
|
|
1163
|
-
return this.executeHttp({
|
|
1166
|
+
async get(path) {
|
|
1167
|
+
return await this.executeHttp({
|
|
1164
1168
|
method: "GET",
|
|
1165
1169
|
path
|
|
1166
1170
|
});
|
|
@@ -1173,24 +1177,24 @@ var SpecificationBuilder = class {
|
|
|
1173
1177
|
* @example
|
|
1174
1178
|
* const result = await api.post("/api/items", { name: "Widget" });
|
|
1175
1179
|
*/
|
|
1176
|
-
post(path, body) {
|
|
1177
|
-
return this.executeHttp({
|
|
1180
|
+
async post(path, body) {
|
|
1181
|
+
return await this.executeHttp({
|
|
1178
1182
|
body,
|
|
1179
1183
|
method: "POST",
|
|
1180
1184
|
path
|
|
1181
1185
|
});
|
|
1182
1186
|
}
|
|
1183
1187
|
/** Send a PUT request to the server adapter and resolve with the result. */
|
|
1184
|
-
put(path, body) {
|
|
1185
|
-
return this.executeHttp({
|
|
1188
|
+
async put(path, body) {
|
|
1189
|
+
return await this.executeHttp({
|
|
1186
1190
|
body,
|
|
1187
1191
|
method: "PUT",
|
|
1188
1192
|
path
|
|
1189
1193
|
});
|
|
1190
1194
|
}
|
|
1191
1195
|
/** Send a DELETE request to the server adapter and resolve with the result. */
|
|
1192
|
-
delete(path) {
|
|
1193
|
-
return this.executeHttp({
|
|
1196
|
+
async delete(path) {
|
|
1197
|
+
return await this.executeHttp({
|
|
1194
1198
|
method: "DELETE",
|
|
1195
1199
|
path
|
|
1196
1200
|
});
|
|
@@ -1215,10 +1219,10 @@ var SpecificationBuilder = class {
|
|
|
1215
1219
|
* const result = await cli.exec(["init", "build"]);
|
|
1216
1220
|
* const result = await cli.exec("dev --port 0", { waitFor: "Listening on", timeout: 10_000 });
|
|
1217
1221
|
*/
|
|
1218
|
-
exec(args = "", options) {
|
|
1222
|
+
async exec(args = "", options) {
|
|
1219
1223
|
if (Array.isArray(args) && args.length === 0) throw new Error("exec([]) requires at least one command");
|
|
1220
1224
|
if (options && Array.isArray(args)) throw new Error(".exec(): waitFor/timeout options are not supported with a command sequence");
|
|
1221
|
-
return this.executeCommand({
|
|
1225
|
+
return await this.executeCommand({
|
|
1222
1226
|
args,
|
|
1223
1227
|
options
|
|
1224
1228
|
});
|
|
@@ -1238,9 +1242,9 @@ var SpecificationBuilder = class {
|
|
|
1238
1242
|
* const result = await cli.run('no-estate.spec.yaml');
|
|
1239
1243
|
* await expect(result.directory('out')).toMatch('scaffold');
|
|
1240
1244
|
*/
|
|
1241
|
-
run(file, options) {
|
|
1245
|
+
async run(file, options) {
|
|
1242
1246
|
const workDir = this.prepareWorkDir();
|
|
1243
|
-
return this.executeSetup(workDir, () => this.runLiterateAction(workDir, file, options));
|
|
1247
|
+
return await this.executeSetup(workDir, async () => await this.runLiterateAction(workDir, file, options));
|
|
1244
1248
|
}
|
|
1245
1249
|
/**
|
|
1246
1250
|
* Perform one raw HTTP GET against the website under test and resolve
|
|
@@ -1253,8 +1257,8 @@ var SpecificationBuilder = class {
|
|
|
1253
1257
|
* expect(result.status).toBe(200);
|
|
1254
1258
|
* expect(result.body).toMatch('robots.txt');
|
|
1255
1259
|
*/
|
|
1256
|
-
fetch(path) {
|
|
1257
|
-
return this.executeSetup(null, () => this.runFetchAction(path));
|
|
1260
|
+
async fetch(path) {
|
|
1261
|
+
return await this.executeSetup(null, async () => await this.runFetchAction(path));
|
|
1258
1262
|
}
|
|
1259
1263
|
/**
|
|
1260
1264
|
* Render the page in the shared browser instance and resolve with the
|
|
@@ -1271,8 +1275,8 @@ var SpecificationBuilder = class {
|
|
|
1271
1275
|
* });
|
|
1272
1276
|
* expect(result.url).toContain('/articles');
|
|
1273
1277
|
*/
|
|
1274
|
-
visit(path, scenario) {
|
|
1275
|
-
return this.executeSetup(null, () => this.runVisitAction(path, scenario));
|
|
1278
|
+
async visit(path, scenario) {
|
|
1279
|
+
return await this.executeSetup(null, async () => await this.runVisitAction(path, scenario));
|
|
1276
1280
|
}
|
|
1277
1281
|
/**
|
|
1278
1282
|
* Terminate and relaunch the app on the simulator, apply the deep link,
|
|
@@ -1289,8 +1293,8 @@ var SpecificationBuilder = class {
|
|
|
1289
1293
|
* await visitor.see(content('rapports'));
|
|
1290
1294
|
* });
|
|
1291
1295
|
*/
|
|
1292
|
-
open(deepLink, scenario) {
|
|
1293
|
-
return this.executeSetup(null, () => this.runOpenAction(deepLink, scenario));
|
|
1296
|
+
async open(deepLink, scenario) {
|
|
1297
|
+
return await this.executeSetup(null, async () => await this.runOpenAction(deepLink, scenario));
|
|
1294
1298
|
}
|
|
1295
1299
|
/**
|
|
1296
1300
|
* Execute the named job registered via the `jobs` option of
|
|
@@ -1300,14 +1304,14 @@ var SpecificationBuilder = class {
|
|
|
1300
1304
|
* const result = await jobs.intercept(classifyArticle).trigger('report-refresh');
|
|
1301
1305
|
*/
|
|
1302
1306
|
async trigger(name) {
|
|
1303
|
-
return this.executeSetup(null, () => this.runJobAction(name));
|
|
1307
|
+
return await this.executeSetup(null, async () => await this.runJobAction(name));
|
|
1304
1308
|
}
|
|
1305
1309
|
async executeHttp(request) {
|
|
1306
|
-
return this.executeSetup(null, () => this.runHttpAction(request));
|
|
1310
|
+
return await this.executeSetup(null, async () => await this.runHttpAction(request));
|
|
1307
1311
|
}
|
|
1308
1312
|
async executeCommand(action) {
|
|
1309
1313
|
const workDir = this.prepareWorkDir();
|
|
1310
|
-
return this.executeSetup(workDir, () => this.runCommandAction(workDir, action));
|
|
1314
|
+
return await this.executeSetup(workDir, async () => await this.runCommandAction(workDir, action));
|
|
1311
1315
|
}
|
|
1312
1316
|
/**
|
|
1313
1317
|
* Shared setup pipeline: reset databases, run seeds, copy fixtures,
|
|
@@ -1378,9 +1382,8 @@ var SpecificationBuilder = class {
|
|
|
1378
1382
|
}
|
|
1379
1383
|
async runFetchAction(path) {
|
|
1380
1384
|
const baseUrl = this.requireBaseUrl("fetch");
|
|
1381
|
-
const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
|
|
1382
1385
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
1383
|
-
headers,
|
|
1386
|
+
headers: this.requestHeaders,
|
|
1384
1387
|
redirect: "manual"
|
|
1385
1388
|
});
|
|
1386
1389
|
const body = await response.text();
|
|
@@ -1453,7 +1456,7 @@ var SpecificationBuilder = class {
|
|
|
1453
1456
|
* redis). `.env()` overrides; `null` unsets.
|
|
1454
1457
|
*/
|
|
1455
1458
|
serviceEnv() {
|
|
1456
|
-
const services = this.config
|
|
1459
|
+
const { services } = this.config;
|
|
1457
1460
|
if (!services || Object.keys(services).length === 0) return;
|
|
1458
1461
|
const env = {};
|
|
1459
1462
|
const sqlHandles = [];
|
|
@@ -1463,8 +1466,10 @@ var SpecificationBuilder = class {
|
|
|
1463
1466
|
if (handle.createDatabaseAdapter() !== null) sqlHandles.push(handle);
|
|
1464
1467
|
if (handle.type === "redis") redisHandles.push(handle);
|
|
1465
1468
|
}
|
|
1466
|
-
|
|
1467
|
-
if (
|
|
1469
|
+
const [onlySql] = sqlHandles;
|
|
1470
|
+
if (sqlHandles.length === 1 && onlySql !== void 0) env.DATABASE_URL = onlySql.connectionString;
|
|
1471
|
+
const [onlyRedis] = redisHandles;
|
|
1472
|
+
if (redisHandles.length === 1 && onlyRedis !== void 0) env.REDIS_URL = onlyRedis.connectionString;
|
|
1468
1473
|
return env;
|
|
1469
1474
|
}
|
|
1470
1475
|
/**
|
|
@@ -1474,7 +1479,7 @@ var SpecificationBuilder = class {
|
|
|
1474
1479
|
*/
|
|
1475
1480
|
childEnv(workDir) {
|
|
1476
1481
|
let env = this.serviceEnv();
|
|
1477
|
-
const dockerConfig = this.config
|
|
1482
|
+
const { dockerConfig } = this.config;
|
|
1478
1483
|
if (dockerConfig && this.config.dockerTestRunId) env = {
|
|
1479
1484
|
...env,
|
|
1480
1485
|
[dockerConfig.envVar]: this.config.dockerTestRunId
|
|
@@ -1486,9 +1491,9 @@ var SpecificationBuilder = class {
|
|
|
1486
1491
|
};
|
|
1487
1492
|
return env;
|
|
1488
1493
|
}
|
|
1489
|
-
runLiterateAction(workDir, file, options) {
|
|
1494
|
+
async runLiterateAction(workDir, file, options) {
|
|
1490
1495
|
const filePath = resolve(this.testDir, file);
|
|
1491
|
-
return runSpecDocument({
|
|
1496
|
+
return await runSpecDocument({
|
|
1492
1497
|
baseEnv: this.childEnv(workDir),
|
|
1493
1498
|
frozen: options?.frozen,
|
|
1494
1499
|
config: this.config,
|
|
@@ -1500,7 +1505,7 @@ var SpecificationBuilder = class {
|
|
|
1500
1505
|
}
|
|
1501
1506
|
async runCommandAction(workDir, action) {
|
|
1502
1507
|
if (!this.config.command) throw new Error("Command actions require a command adapter");
|
|
1503
|
-
const dockerConfig = this.config
|
|
1508
|
+
const { dockerConfig } = this.config;
|
|
1504
1509
|
const testRunId = this.config.dockerTestRunId;
|
|
1505
1510
|
const env = this.childEnv(workDir);
|
|
1506
1511
|
let commandOutput;
|
|
@@ -1549,13 +1554,13 @@ function withDockerTestRunId(config) {
|
|
|
1549
1554
|
function createApiFacet(config) {
|
|
1550
1555
|
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
1551
1556
|
return {
|
|
1552
|
-
delete: (path) => start().delete(path),
|
|
1553
|
-
get: (path) => start().get(path),
|
|
1557
|
+
delete: async (path) => await start().delete(path),
|
|
1558
|
+
get: async (path) => await start().get(path),
|
|
1554
1559
|
headers: (headers) => start().headers(headers),
|
|
1555
1560
|
intercept: interceptOn(start),
|
|
1556
|
-
post: (path, body) => start().post(path, body),
|
|
1557
|
-
put: (path, body) => start().put(path, body),
|
|
1558
|
-
request: (file) => start().request(file),
|
|
1561
|
+
post: async (path, body) => await start().post(path, body),
|
|
1562
|
+
put: async (path, body) => await start().put(path, body),
|
|
1563
|
+
request: async (file) => await start().request(file),
|
|
1559
1564
|
seed: (file, options) => start().seed(file, options)
|
|
1560
1565
|
};
|
|
1561
1566
|
}
|
|
@@ -1567,7 +1572,7 @@ function createJobsFacet(config) {
|
|
|
1567
1572
|
return {
|
|
1568
1573
|
intercept: interceptOn(start),
|
|
1569
1574
|
seed: (file, options) => start().seed(file, options),
|
|
1570
|
-
trigger: (name) => start().trigger(name)
|
|
1575
|
+
trigger: async (name) => await start().trigger(name)
|
|
1571
1576
|
};
|
|
1572
1577
|
}
|
|
1573
1578
|
/**
|
|
@@ -1576,10 +1581,10 @@ function createJobsFacet(config) {
|
|
|
1576
1581
|
function createWebsiteFacet(config) {
|
|
1577
1582
|
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
1578
1583
|
return {
|
|
1579
|
-
fetch: (path) => start().fetch(path),
|
|
1584
|
+
fetch: async (path) => await start().fetch(path),
|
|
1580
1585
|
headers: (headers) => start().headers(headers),
|
|
1581
1586
|
intercept: interceptOn(start),
|
|
1582
|
-
visit: (path, scenario) => start().visit(path, scenario)
|
|
1587
|
+
visit: async (path, scenario) => await start().visit(path, scenario)
|
|
1583
1588
|
};
|
|
1584
1589
|
}
|
|
1585
1590
|
/**
|
|
@@ -1589,7 +1594,7 @@ function createMobileFacet(config) {
|
|
|
1589
1594
|
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
1590
1595
|
return {
|
|
1591
1596
|
intercept: interceptOn(start),
|
|
1592
|
-
open: (deepLink, scenario) => start().open(deepLink, scenario)
|
|
1597
|
+
open: async (deepLink, scenario) => await start().open(deepLink, scenario)
|
|
1593
1598
|
};
|
|
1594
1599
|
}
|
|
1595
1600
|
/**
|
|
@@ -1600,14 +1605,14 @@ function createCliFacet(config) {
|
|
|
1600
1605
|
const start = () => new SpecificationBuilder(resolved, getCallerDir());
|
|
1601
1606
|
return {
|
|
1602
1607
|
env: (env) => start().env(env),
|
|
1603
|
-
exec: (args, options) => start().exec(args, options),
|
|
1608
|
+
exec: async (args, options) => await start().exec(args, options),
|
|
1604
1609
|
fixture: (path) => start().fixture(path),
|
|
1605
|
-
run: (file, options) => start().run(file, options),
|
|
1610
|
+
run: async (file, options) => await start().run(file, options),
|
|
1606
1611
|
seed: (file, options) => start().seed(file, options)
|
|
1607
1612
|
};
|
|
1608
1613
|
}
|
|
1609
1614
|
//#endregion
|
|
1610
|
-
//#region src/
|
|
1615
|
+
//#region src/specification/facets/_common/docker-reader.ts
|
|
1611
1616
|
/**
|
|
1612
1617
|
* Build the `docker(containerId)` reader handed out by `specification.api()`
|
|
1613
1618
|
* and `specification.cli()` — a lazy accessor over `docker inspect`.
|
|
@@ -1622,7 +1627,7 @@ function createDockerReader(testDir) {
|
|
|
1622
1627
|
};
|
|
1623
1628
|
}
|
|
1624
1629
|
//#endregion
|
|
1625
|
-
//#region src/
|
|
1630
|
+
//#region src/specification/facets/_common/compose-file.ts
|
|
1626
1631
|
/**
|
|
1627
1632
|
* Detect the service type from the image name.
|
|
1628
1633
|
*/
|
|
@@ -1648,7 +1653,7 @@ function findComposeFile(projectRoot) {
|
|
|
1648
1653
|
return null;
|
|
1649
1654
|
}
|
|
1650
1655
|
//#endregion
|
|
1651
|
-
//#region src/
|
|
1656
|
+
//#region src/specification/facets/_common/orchestrator.ts
|
|
1652
1657
|
/**
|
|
1653
1658
|
* Orchestrator for test infrastructure.
|
|
1654
1659
|
* Integration: starts services via testcontainers.
|
|
@@ -1721,7 +1726,9 @@ var Orchestrator = class {
|
|
|
1721
1726
|
handle
|
|
1722
1727
|
});
|
|
1723
1728
|
}
|
|
1724
|
-
await Promise.all([...containerServices.map(({ container }) =>
|
|
1729
|
+
await Promise.all([...containerServices.map(async ({ container }) => {
|
|
1730
|
+
await container.start();
|
|
1731
|
+
}), ...embeddedServices.map(async (handle) => {
|
|
1725
1732
|
await handle.initialize(composeDir, this.root);
|
|
1726
1733
|
handle.started = true;
|
|
1727
1734
|
this.running.push({
|
|
@@ -1880,13 +1887,14 @@ var Orchestrator = class {
|
|
|
1880
1887
|
getAppUrl() {
|
|
1881
1888
|
const composePath = findComposeFile(this.root);
|
|
1882
1889
|
if (!composePath || !this.composeStack) return null;
|
|
1883
|
-
const appService = getContainerIntegrations().parseComposeFile(composePath)
|
|
1884
|
-
|
|
1885
|
-
|
|
1890
|
+
const { appService } = getContainerIntegrations().parseComposeFile(composePath);
|
|
1891
|
+
const [firstPort] = appService?.ports ?? [];
|
|
1892
|
+
if (!appService || firstPort === void 0) return null;
|
|
1893
|
+
return `http://localhost:${this.composeStack.getMappedPort(appService.name, firstPort.container)}`;
|
|
1886
1894
|
}
|
|
1887
1895
|
};
|
|
1888
1896
|
//#endregion
|
|
1889
|
-
//#region src/
|
|
1897
|
+
//#region src/specification/facets/_common/services.ts
|
|
1890
1898
|
function getWorkerId() {
|
|
1891
1899
|
return process.env.VITEST_POOL_ID ?? "0";
|
|
1892
1900
|
}
|
|
@@ -1898,7 +1906,7 @@ async function releaseIsolation(services) {
|
|
|
1898
1906
|
for (const service of Object.values(services)) await service.isolation().release();
|
|
1899
1907
|
}
|
|
1900
1908
|
function declaredDatabaseKeys(services) {
|
|
1901
|
-
return Object.
|
|
1909
|
+
return Object.entries(services).filter(([, service]) => service.createDatabaseAdapter() !== null).map(([key]) => key);
|
|
1902
1910
|
}
|
|
1903
1911
|
/** Start a services record via testcontainers and acquire worker isolation. */
|
|
1904
1912
|
async function startServices(services, root) {
|
|
@@ -1917,7 +1925,7 @@ async function startServices(services, root) {
|
|
|
1917
1925
|
};
|
|
1918
1926
|
}
|
|
1919
1927
|
//#endregion
|
|
1920
|
-
//#region src/
|
|
1928
|
+
//#region src/specification/facets/api/fetch.adapter.ts
|
|
1921
1929
|
/**
|
|
1922
1930
|
* Transient connection-level failures against a live compose app. Under
|
|
1923
1931
|
* parallel load (every `api-stack` worker hammering its own compose project)
|
|
@@ -1940,7 +1948,9 @@ function isTransientConnectionError(error) {
|
|
|
1940
1948
|
const cause = error.cause;
|
|
1941
1949
|
return cause?.code !== void 0 && TRANSIENT_CONNECTION_CODES.has(cause.code) || error.message.includes("fetch failed");
|
|
1942
1950
|
}
|
|
1943
|
-
const delay = (ms) =>
|
|
1951
|
+
const delay = async (ms) => {
|
|
1952
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1953
|
+
};
|
|
1944
1954
|
/**
|
|
1945
1955
|
* Server adapter that sends real HTTP requests via the Fetch API.
|
|
1946
1956
|
* Used by the `e2e()` specification runner to hit a live server.
|
|
@@ -1948,7 +1958,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
1948
1958
|
var FetchAdapter = class {
|
|
1949
1959
|
baseUrl;
|
|
1950
1960
|
constructor(url) {
|
|
1951
|
-
this.baseUrl = url.replace(
|
|
1961
|
+
this.baseUrl = url.replace(/\/$/u, "");
|
|
1952
1962
|
}
|
|
1953
1963
|
async request(method, path, body, headers) {
|
|
1954
1964
|
const init = {
|
|
@@ -1986,7 +1996,7 @@ var FetchAdapter = class {
|
|
|
1986
1996
|
}
|
|
1987
1997
|
};
|
|
1988
1998
|
//#endregion
|
|
1989
|
-
//#region src/
|
|
1999
|
+
//#region src/specification/facets/api/start-api.ts
|
|
1990
2000
|
function resolveMode(explicit) {
|
|
1991
2001
|
const value = explicit ?? process.env.TEST_MODE ?? "node";
|
|
1992
2002
|
if (value !== "compose" && value !== "node") throw new Error(`Invalid test mode "${value}" — expected 'node' or 'compose' (options.mode or TEST_MODE).`);
|
|
@@ -2035,13 +2045,15 @@ async function startApi(options) {
|
|
|
2035
2045
|
interceptDisabledReason: "intercepts are in-process (MSW) and not available in compose mode — keep intercept specs in node-only vitest projects.",
|
|
2036
2046
|
server: new FetchAdapter(appUrl)
|
|
2037
2047
|
}),
|
|
2038
|
-
cleanup: () =>
|
|
2048
|
+
cleanup: async () => {
|
|
2049
|
+
await orchestrator.stopCompose();
|
|
2050
|
+
},
|
|
2039
2051
|
docker: createDockerReader(callerDir),
|
|
2040
2052
|
orchestrator
|
|
2041
2053
|
};
|
|
2042
2054
|
}
|
|
2043
2055
|
//#endregion
|
|
2044
|
-
//#region src/
|
|
2056
|
+
//#region src/specification/facets/cli/exec.adapter.ts
|
|
2045
2057
|
const DEFAULT_WATCH_TIMEOUT = 1e4;
|
|
2046
2058
|
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
2047
2059
|
const KILL_GRACE_MS = 2e3;
|
|
@@ -2070,17 +2082,19 @@ function buildEnv(extra) {
|
|
|
2070
2082
|
*
|
|
2071
2083
|
* @internal Exported for unit tests (driven with a fake child).
|
|
2072
2084
|
*/
|
|
2073
|
-
function observeProcess(child, options) {
|
|
2085
|
+
async function observeProcess(child, options) {
|
|
2074
2086
|
const timeout = options.timeout ?? DEFAULT_WATCH_TIMEOUT;
|
|
2075
|
-
const waitFor = options
|
|
2076
|
-
return new Promise((resolve) => {
|
|
2087
|
+
const { waitFor } = options;
|
|
2088
|
+
return await new Promise((resolve) => {
|
|
2077
2089
|
let stdout = "";
|
|
2078
2090
|
let stderr = "";
|
|
2079
2091
|
let resolved = false;
|
|
2080
2092
|
let exited = false;
|
|
2081
2093
|
let patternMatched = false;
|
|
2082
2094
|
let killTimer = null;
|
|
2083
|
-
const timeoutTimer = setTimeout(() =>
|
|
2095
|
+
const timeoutTimer = setTimeout(() => {
|
|
2096
|
+
finish(124);
|
|
2097
|
+
}, timeout);
|
|
2084
2098
|
timeoutTimer.unref?.();
|
|
2085
2099
|
const terminate = () => {
|
|
2086
2100
|
if (exited) return;
|
|
@@ -2185,7 +2199,7 @@ var ExecAdapter = class {
|
|
|
2185
2199
|
} catch {}
|
|
2186
2200
|
return child.kill(signal);
|
|
2187
2201
|
};
|
|
2188
|
-
return observeProcess({
|
|
2202
|
+
return await observeProcess({
|
|
2189
2203
|
kill,
|
|
2190
2204
|
on: child.on.bind(child),
|
|
2191
2205
|
stderr: child.stderr,
|
|
@@ -2194,7 +2208,7 @@ var ExecAdapter = class {
|
|
|
2194
2208
|
}
|
|
2195
2209
|
};
|
|
2196
2210
|
//#endregion
|
|
2197
|
-
//#region src/
|
|
2211
|
+
//#region src/specification/facets/cli/start-cli.ts
|
|
2198
2212
|
async function startCli(bin, options = {}) {
|
|
2199
2213
|
const callerDir = getCallerDir();
|
|
2200
2214
|
await registerMatchers();
|
|
@@ -2226,7 +2240,7 @@ async function startCli(bin, options = {}) {
|
|
|
2226
2240
|
};
|
|
2227
2241
|
}
|
|
2228
2242
|
//#endregion
|
|
2229
|
-
//#region src/
|
|
2243
|
+
//#region src/specification/facets/jobs/start-jobs.ts
|
|
2230
2244
|
async function startJobs(options) {
|
|
2231
2245
|
const callerDir = getCallerDir();
|
|
2232
2246
|
await registerMatchers();
|
|
@@ -2251,7 +2265,7 @@ async function startJobs(options) {
|
|
|
2251
2265
|
};
|
|
2252
2266
|
}
|
|
2253
2267
|
//#endregion
|
|
2254
|
-
//#region src/
|
|
2268
|
+
//#region src/specification/facets/_common/stub-backend.ts
|
|
2255
2269
|
/**
|
|
2256
2270
|
* The declared stub backend — a small `node:http` server the website and
|
|
2257
2271
|
* mobile runners start when their `backend` option is present. It serves the
|
|
@@ -2295,7 +2309,9 @@ var StubBackend = class {
|
|
|
2295
2309
|
this.server = server;
|
|
2296
2310
|
await new Promise((resolve, reject) => {
|
|
2297
2311
|
server.once("error", reject);
|
|
2298
|
-
server.listen(this.options.port ?? 0, "127.0.0.1", () =>
|
|
2312
|
+
server.listen(this.options.port ?? 0, "127.0.0.1", () => {
|
|
2313
|
+
resolve();
|
|
2314
|
+
});
|
|
2299
2315
|
});
|
|
2300
2316
|
const address = server.address();
|
|
2301
2317
|
if (address === null || typeof address === "string") throw new Error("stub backend: could not determine the listening port");
|
|
@@ -2304,12 +2320,14 @@ var StubBackend = class {
|
|
|
2304
2320
|
}
|
|
2305
2321
|
/** Stop the server (idempotent). */
|
|
2306
2322
|
async stop() {
|
|
2307
|
-
const server = this
|
|
2323
|
+
const { server } = this;
|
|
2308
2324
|
if (!server) return;
|
|
2309
2325
|
this.server = null;
|
|
2310
2326
|
server.closeAllConnections();
|
|
2311
2327
|
await new Promise((resolve) => {
|
|
2312
|
-
server.close(() =>
|
|
2328
|
+
server.close(() => {
|
|
2329
|
+
resolve();
|
|
2330
|
+
});
|
|
2313
2331
|
});
|
|
2314
2332
|
}
|
|
2315
2333
|
/**
|
|
@@ -2423,7 +2441,7 @@ async function readBody(request) {
|
|
|
2423
2441
|
}
|
|
2424
2442
|
}
|
|
2425
2443
|
//#endregion
|
|
2426
|
-
//#region src/
|
|
2444
|
+
//#region src/specification/facets/mobile/appium-server.ts
|
|
2427
2445
|
const APPIUM_READY_TIMEOUT_MS = 6e4;
|
|
2428
2446
|
/**
|
|
2429
2447
|
* The guidance thrown when the optional peers are missing — the exact fix,
|
|
@@ -2447,12 +2465,14 @@ async function startAppiumServer(root) {
|
|
|
2447
2465
|
timeout: APPIUM_READY_TIMEOUT_MS
|
|
2448
2466
|
}, root, "mobile");
|
|
2449
2467
|
return {
|
|
2450
|
-
stop: () =>
|
|
2468
|
+
stop: async () => {
|
|
2469
|
+
await serve.stop();
|
|
2470
|
+
},
|
|
2451
2471
|
url: await serve.start()
|
|
2452
2472
|
};
|
|
2453
2473
|
}
|
|
2454
2474
|
//#endregion
|
|
2455
|
-
//#region src/
|
|
2475
|
+
//#region src/specification/facets/mobile/simulator.ts
|
|
2456
2476
|
/**
|
|
2457
2477
|
* Simulator resolution — `device: { name, os? }` → one booted UDID.
|
|
2458
2478
|
*
|
|
@@ -2465,8 +2485,10 @@ const run = promisify(execFile);
|
|
|
2465
2485
|
const BOOT_TIMEOUT_MS = 12e4;
|
|
2466
2486
|
/** `com.apple.CoreSimulator.SimRuntime.iOS-26-5` → `iOS 26.5`. */
|
|
2467
2487
|
function runtimeLabel(runtime) {
|
|
2468
|
-
const match = /SimRuntime\.(?<platform>[A-Za-z]+)-(?<version>[\d-]+)
|
|
2469
|
-
|
|
2488
|
+
const match = /SimRuntime\.(?<platform>[A-Za-z]+)-(?<version>[\d-]+)$/u.exec(runtime);
|
|
2489
|
+
const platform = match?.groups?.platform;
|
|
2490
|
+
const version = match?.groups?.version;
|
|
2491
|
+
return platform === void 0 || version === void 0 ? runtime : `${platform} ${version.replaceAll("-", ".")}`;
|
|
2470
2492
|
}
|
|
2471
2493
|
/** Flatten the per-runtime record into one available-device list. */
|
|
2472
2494
|
function listSimulators(listJson) {
|
|
@@ -2495,9 +2517,10 @@ function selectSimulator(listJson, criteria) {
|
|
|
2495
2517
|
const available = listSimulators(listJson);
|
|
2496
2518
|
const matches = available.filter((device) => device.name === criteria.name && (criteria.os === void 0 || osMatches(device.os, criteria.os)));
|
|
2497
2519
|
const wanted = criteria.os === void 0 ? `"${criteria.name}"` : `"${criteria.name}" on ${criteria.os}`;
|
|
2498
|
-
|
|
2520
|
+
const [only] = matches;
|
|
2521
|
+
if (only === void 0) throw new Error(`specification.mobile(): no simulator matches ${wanted}.\nAvailable devices:\n${formatListing(available)}`);
|
|
2499
2522
|
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)}`);
|
|
2500
|
-
return
|
|
2523
|
+
return only;
|
|
2501
2524
|
}
|
|
2502
2525
|
/** Resolve `device: { name, os? }` to a UDID via `xcrun simctl`. */
|
|
2503
2526
|
async function resolveSimulatorUdid(criteria) {
|
|
@@ -2533,7 +2556,7 @@ async function ensureBooted(udid) {
|
|
|
2533
2556
|
], { timeout: BOOT_TIMEOUT_MS });
|
|
2534
2557
|
}
|
|
2535
2558
|
//#endregion
|
|
2536
|
-
//#region src/
|
|
2559
|
+
//#region src/specification/facets/mobile/start-mobile.ts
|
|
2537
2560
|
async function startMobile(options) {
|
|
2538
2561
|
const callerDir = getCallerDir();
|
|
2539
2562
|
await registerMatchers();
|
|
@@ -2580,7 +2603,7 @@ async function startMobile(options) {
|
|
|
2580
2603
|
};
|
|
2581
2604
|
}
|
|
2582
2605
|
//#endregion
|
|
2583
|
-
//#region src/
|
|
2606
|
+
//#region src/specification/facets/website/start-website.ts
|
|
2584
2607
|
async function startWebsite(options) {
|
|
2585
2608
|
const callerDir = getCallerDir();
|
|
2586
2609
|
await registerMatchers();
|
|
@@ -2602,7 +2625,7 @@ async function startWebsite(options) {
|
|
|
2602
2625
|
await backend?.stop();
|
|
2603
2626
|
throw error;
|
|
2604
2627
|
}
|
|
2605
|
-
} else baseUrl = options.url.replace(
|
|
2628
|
+
} else baseUrl = options.url.replace(/\/$/u, "");
|
|
2606
2629
|
let browser = null;
|
|
2607
2630
|
const getBrowser = async () => {
|
|
2608
2631
|
if (!browser) {
|
|
@@ -2632,31 +2655,7 @@ async function startWebsite(options) {
|
|
|
2632
2655
|
};
|
|
2633
2656
|
}
|
|
2634
2657
|
//#endregion
|
|
2635
|
-
//#region src/
|
|
2636
|
-
/**
|
|
2637
|
-
* The five specification constructors (CONVENTIONS A2) — created in a
|
|
2638
|
-
* `*.specification.ts` file under `specs/`, destructured with canonical
|
|
2639
|
-
* names, and cleaned up via `afterAll(cleanup)` (A1/A3/A4).
|
|
2640
|
-
*
|
|
2641
|
-
* @example
|
|
2642
|
-
* // specs/api/api.specification.ts
|
|
2643
|
-
* export const { api, cleanup } = await specification.api({
|
|
2644
|
-
* services: { db: postgres() },
|
|
2645
|
-
* server: ({ db }) => createApp({ databaseUrl: db.connectionString }),
|
|
2646
|
-
* });
|
|
2647
|
-
* afterAll(cleanup);
|
|
2648
|
-
*
|
|
2649
|
-
* // specs/jobs/jobs.specification.ts
|
|
2650
|
-
* export const { jobs, cleanup } = await specification.jobs({
|
|
2651
|
-
* services: { db: postgres() },
|
|
2652
|
-
* jobs: ({ db }) => [nightlyReport(db)],
|
|
2653
|
-
* });
|
|
2654
|
-
* afterAll(cleanup);
|
|
2655
|
-
*
|
|
2656
|
-
* // specs/setup/cli.specification.ts
|
|
2657
|
-
* export const { cli, cleanup } = await specification.cli('my-cli');
|
|
2658
|
-
* afterAll(cleanup);
|
|
2659
|
-
*/
|
|
2658
|
+
//#region src/specification/facets/_common/specification.ts
|
|
2660
2659
|
const specification = {
|
|
2661
2660
|
/**
|
|
2662
2661
|
* Test an HTTP app. Mode `'node'` (default) starts the declared services
|
|
@@ -2694,7 +2693,7 @@ const specification = {
|
|
|
2694
2693
|
website: startWebsite
|
|
2695
2694
|
};
|
|
2696
2695
|
//#endregion
|
|
2697
|
-
//#region src/
|
|
2696
|
+
//#region src/specification/facets/website/elements.ts
|
|
2698
2697
|
const named = (kind) => (name, options) => ({
|
|
2699
2698
|
kind,
|
|
2700
2699
|
name,
|
|
@@ -3078,7 +3077,7 @@ function sqlite(options = {}) {
|
|
|
3078
3077
|
return new SqliteHandle(options);
|
|
3079
3078
|
}
|
|
3080
3079
|
//#endregion
|
|
3081
|
-
//#region src/
|
|
3080
|
+
//#region src/specification/contracts/filters.ts
|
|
3082
3081
|
/** Does an observed text satisfy a declared {@link TextFilter}? */
|
|
3083
3082
|
function matchesText(filter, actual) {
|
|
3084
3083
|
if (filter === void 0) return true;
|
|
@@ -3152,7 +3151,9 @@ const anthropic = {
|
|
|
3152
3151
|
};
|
|
3153
3152
|
},
|
|
3154
3153
|
/** Response: wrap data in Anthropic messages format. */
|
|
3155
|
-
reply
|
|
3154
|
+
reply(data) {
|
|
3155
|
+
return buildReply(data);
|
|
3156
|
+
},
|
|
3156
3157
|
/** Response: return an Anthropic error. */
|
|
3157
3158
|
error(status, message) {
|
|
3158
3159
|
return {
|
|
@@ -3176,7 +3177,7 @@ const anthropic = {
|
|
|
3176
3177
|
}
|
|
3177
3178
|
};
|
|
3178
3179
|
//#endregion
|
|
3179
|
-
//#region src/
|
|
3180
|
+
//#region src/specification/contracts/http.ts
|
|
3180
3181
|
function wrapJson(data) {
|
|
3181
3182
|
return {
|
|
3182
3183
|
status: 200,
|
|
@@ -3426,7 +3427,9 @@ const openai = {
|
|
|
3426
3427
|
* @example
|
|
3427
3428
|
* openai.reply({ categories: ['TECH'] })
|
|
3428
3429
|
*/
|
|
3429
|
-
reply
|
|
3430
|
+
reply(data) {
|
|
3431
|
+
return buildChatReply(data);
|
|
3432
|
+
},
|
|
3430
3433
|
/** Response: return an OpenAI error. */
|
|
3431
3434
|
error(status, message) {
|
|
3432
3435
|
return {
|