@jterrazz/test 18.2.0 → 18.3.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 +2 -3
- package/dist/browser-shared-index.browser.js +1 -2
- package/dist/checker.js +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -2
- package/dist/index.d.ts +196 -194
- package/dist/index.js +1742 -1690
- package/dist/oxlint.cjs +5 -8
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +1 -3
- package/dist/shared-exchange.js +1 -1
- package/dist/shared-in-place.js +1 -1
- package/dist/shared-manifest.js +4 -4
- package/dist/shared-route-coverage.global-setup.js +1 -1
- package/dist/shared-session-ledger.js +1 -1
- package/dist/shared-specifications.js +0 -1
- package/dist/shared-stub-server.js +13 -0
- package/dist/shared-testcontainers.adapter.js +1 -1
- package/dist/stubs.js +1 -1
- package/dist/vitest.d.ts +1 -6
- package/dist/vitest.js +2 -8
- package/docs/README.md +1 -2
- package/docs/concepts/architecture.md +15 -16
- package/docs/concepts/conventions.md +2 -2
- package/docs/guides/api-specs.md +172 -3
- package/docs/guides/component-specs.md +1 -1
- package/docs/guides/website-specs.md +1 -1
- package/docs/reference/assertions.md +2 -2
- package/docs/reference/glossary.md +1 -1
- package/docs/reference/linting.md +4 -4
- package/docs/reference/tokens.md +1 -1
- package/docs/start/getting-started.md +14 -15
- package/docs/start/set-up-a-project.md +2 -2
- package/package.json +1 -1
- package/docs/guides/deployment-specs.md +0 -265
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { a as endGroup, i as SIGNALS_GROUPS, o as signalGroup, r as KILL_GRACE_MS, t as ServedProcess } from "./shared-serve.js";
|
|
2
2
|
import { i as safeRealpath, r as resolveRoot } from "./shared-resolve.js";
|
|
3
3
|
import { a as getCallerDir, n as deferRewritesTo, r as writeInPlace, t as callSiteOf } from "./shared-in-place.js";
|
|
4
|
-
import { t as isRecord } from "./shared-json-shape.js";
|
|
5
4
|
import { n as refusal$1, t as invariant } from "./shared-refusal.js";
|
|
5
|
+
import { t as isRecord } from "./shared-json-shape.js";
|
|
6
6
|
import { a as field$1, c as containerLogs, d as findContainersByLabel, f as inspectContainer, h as requireDockerDaemon, l as execInContainer, m as removeContainers, p as readContainerFile, r as TestcontainersAdapter, u as fieldOf } from "./shared-testcontainers.adapter.js";
|
|
7
7
|
import { _ as Matcher, d as structuralEquals, g as placeholderStringMatches, h as hasPlaceholders, l as isPlainObject, m as textEquals, n as isStreamBody, p as textContains, r as declared, u as renderExpected, v as match, y as CaptureScope } from "./shared-exchange.js";
|
|
8
8
|
import { a as RUN_KEYS, s as SpecSyntaxError } from "./shared-spec-grammar.js";
|
|
@@ -1207,7 +1207,7 @@ function unreachableFile(reason, more, fileName) {
|
|
|
1207
1207
|
if (more) throw new Error(refusal$1({
|
|
1208
1208
|
fix: "keep the one line `unreachable: <reason>`, or state the status the exchange answers",
|
|
1209
1209
|
found: `${fileName}: "unreachable: ${reason}" is followed by headers or a body`,
|
|
1210
|
-
read: "docs/guides/
|
|
1210
|
+
read: "docs/guides/api-specs.md#unreachable-is-a-result",
|
|
1211
1211
|
why: "An exchange that never answered carries no header and no body, so its golden is one line."
|
|
1212
1212
|
}));
|
|
1213
1213
|
return unreachableResponse(reason);
|
|
@@ -2574,1871 +2574,1925 @@ const apiDefinition = defineSpecification()({
|
|
|
2574
2574
|
locate: getCallerDir
|
|
2575
2575
|
});
|
|
2576
2576
|
//#endregion
|
|
2577
|
-
//#region src/specifications/api/
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
app;
|
|
2589
|
-
reached = [];
|
|
2590
|
-
constructor(app) {
|
|
2591
|
-
this.app = app;
|
|
2577
|
+
//#region src/specifications/api/deployed/connect.capability.ts
|
|
2578
|
+
/** Read by the exchange, never armed: the address is where one request is sent, not a state of the world. */
|
|
2579
|
+
const connect$2 = defineCapability({
|
|
2580
|
+
initial: null,
|
|
2581
|
+
name: "connect",
|
|
2582
|
+
phase: 0,
|
|
2583
|
+
reduce: (_held, [address]) => address,
|
|
2584
|
+
row: {
|
|
2585
|
+
group: "Setup",
|
|
2586
|
+
name: ".connect()",
|
|
2587
|
+
probe: ".connect("
|
|
2592
2588
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2589
|
+
});
|
|
2590
|
+
//#endregion
|
|
2591
|
+
//#region src/specifications/api/deployed/exchange.result.ts
|
|
2592
|
+
const EXCHANGE_RESULT_ROWS = [{
|
|
2593
|
+
group: "Result accessor",
|
|
2594
|
+
name: ".response",
|
|
2595
|
+
probe: "result.response"
|
|
2596
|
+
}, {
|
|
2597
|
+
group: "Result accessor",
|
|
2598
|
+
name: ".tls",
|
|
2599
|
+
probe: "result.tls"
|
|
2600
|
+
}];
|
|
2601
|
+
/** What one request to a deployed api hands back: the response, or why none came, and the certificate it came over; no table and no disk, which a deployed api keeps to itself. */
|
|
2602
|
+
var ExchangeResult = class {
|
|
2603
|
+
answer;
|
|
2604
|
+
recording;
|
|
2605
|
+
scope = new CaptureScope();
|
|
2606
|
+
testDir;
|
|
2607
|
+
/** The certificate an https request was presented and the trust check's verdict; undefined over http, or when no handshake completed. */
|
|
2608
|
+
tls;
|
|
2609
|
+
constructor(options) {
|
|
2610
|
+
this.answer = options.response;
|
|
2611
|
+
this.recording = options.recording;
|
|
2612
|
+
this.testDir = options.testDir;
|
|
2613
|
+
this.tls = options.tls;
|
|
2599
2614
|
}
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
const init = {
|
|
2604
|
-
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
2605
|
-
headers: sent,
|
|
2606
|
-
method
|
|
2607
|
-
};
|
|
2608
|
-
this.reached.push(template === void 0 ? {
|
|
2609
|
-
method,
|
|
2610
|
-
path
|
|
2611
|
-
} : {
|
|
2612
|
-
method,
|
|
2613
|
-
path,
|
|
2614
|
-
template
|
|
2615
|
-
});
|
|
2616
|
-
const response = await this.app.request(path, init);
|
|
2617
|
-
const contentType = response.headers.get("content-type") ?? void 0;
|
|
2618
|
-
const responseBody = bodyKind(contentType) === "binary" ? null : readBody$1(await response.text(), contentType) ?? null;
|
|
2619
|
-
const responseHeaders = Object.fromEntries(response.headers);
|
|
2620
|
-
return {
|
|
2621
|
-
status: response.status,
|
|
2622
|
-
body: responseBody,
|
|
2623
|
-
headers: responseHeaders
|
|
2624
|
-
};
|
|
2615
|
+
/** The whole response (status, headers, body), or `unreachable: <reason>`, compared whole: `toMatch(<.http text>)` or `toMatch(file(...))`. */
|
|
2616
|
+
get response() {
|
|
2617
|
+
return new ResponseAccessor(this.answer, this.testDir, this.scope, this.recording);
|
|
2625
2618
|
}
|
|
2626
2619
|
};
|
|
2627
2620
|
//#endregion
|
|
2628
|
-
//#region src/specifications/api/
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2621
|
+
//#region src/specifications/api/deployed/target.ts
|
|
2622
|
+
const SCHEME = /^[a-z][a-z\d+.-]*:\/\//iu;
|
|
2623
|
+
const BRACKETED = /^\[(?<host>[^\]]+)\](?::(?<port>\d+))?$/u;
|
|
2624
|
+
const HOST_AND_PORT = /^(?<host>[^:[\]\s]+)(?::(?<port>\d+))?$/u;
|
|
2625
|
+
/** The host a URL names, an IPv6 literal without its brackets. */
|
|
2626
|
+
function hostOf(url) {
|
|
2627
|
+
return url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
|
|
2628
|
+
}
|
|
2629
|
+
/** The port a URL names, or its scheme's. */
|
|
2630
|
+
function portOf(url) {
|
|
2631
|
+
if (url.port !== "") return Number(url.port);
|
|
2632
|
+
return url.protocol === "https:" ? 443 : 80;
|
|
2633
|
+
}
|
|
2634
|
+
function refusedTarget(target, found, fix) {
|
|
2635
|
+
return new Error(refusal$1({
|
|
2636
|
+
fix,
|
|
2637
|
+
found: `${JSON.stringify(target)}, ${found}`,
|
|
2638
|
+
read: "docs/guides/api-specs.md#the-target",
|
|
2639
|
+
why: "A request to a deployed api goes to one absolute http(s) URL: written whole, or a path from the `url` the specification names."
|
|
2640
|
+
}));
|
|
2641
|
+
}
|
|
2642
|
+
/** A path is read from the base URL as written, its own path kept: `https://site.test/app` and `/health` are `https://site.test/app/health`. */
|
|
2643
|
+
function targetUrl(target, base) {
|
|
2644
|
+
if (SCHEME.test(target)) {
|
|
2645
|
+
const url = new URL(target);
|
|
2646
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw refusedTarget(target, `a ${url.protocol.slice(0, -1)} URL`, "name an http:// or https:// URL");
|
|
2647
|
+
return url;
|
|
2648
|
+
}
|
|
2649
|
+
if (!target.startsWith("/")) throw refusedTarget(target, "neither an absolute URL nor a path", "write the URL whole, `https://site.test/health`, or a path from the `url` option, `/health`");
|
|
2650
|
+
return new URL(`${base.replace(/\/+$/u, "")}${target}`);
|
|
2651
|
+
}
|
|
2652
|
+
/** `host:port`, `[v6]:port`, or a bare host, which keeps the URL's port. */
|
|
2653
|
+
function connectAddress(written, url) {
|
|
2654
|
+
const address = written.trim();
|
|
2655
|
+
const match = BRACKETED.exec(address) ?? HOST_AND_PORT.exec(address);
|
|
2656
|
+
const host = match?.groups?.host;
|
|
2657
|
+
if (host === void 0 || address.startsWith("[") && isIP(host) !== 6) throw new Error(refusal$1({
|
|
2658
|
+
fix: "write the address as `.connect('10.0.0.5:30443')`, `.connect('[fd7a::1]:443')` or `.connect('node.test')`",
|
|
2659
|
+
found: `.connect(${JSON.stringify(written)}), no address`,
|
|
2660
|
+
read: "docs/guides/api-specs.md#setup-connect",
|
|
2661
|
+
why: "`.connect()` names the one address the socket opens on: a host, and a port when it is not the port of the URL."
|
|
2662
|
+
}));
|
|
2663
|
+
const port = match?.groups?.port;
|
|
2664
|
+
return {
|
|
2665
|
+
host,
|
|
2666
|
+
port: port === void 0 ? portOf(url) : Number(port)
|
|
2642
2667
|
};
|
|
2668
|
+
}
|
|
2669
|
+
//#endregion
|
|
2670
|
+
//#region src/specifications/api/deployed/tls-facts.ts
|
|
2671
|
+
const DAY_MS = 864e5;
|
|
2672
|
+
function firstOf(value) {
|
|
2673
|
+
return Array.isArray(value) ? value[0] : value;
|
|
2674
|
+
}
|
|
2675
|
+
function altNamesOf(certificate) {
|
|
2676
|
+
return (certificate.subjectaltname ?? "").split(",").map((name) => name.trim().slice(name.trim().indexOf(":") + 1)).filter((name) => name !== "");
|
|
2677
|
+
}
|
|
2678
|
+
/** Read at the moment of the exchange, so `daysRemaining` is the margin the deployed api had when it answered. */
|
|
2679
|
+
function tlsFacts(certificate, verdict, now) {
|
|
2680
|
+
const validTo = new Date(certificate.valid_to);
|
|
2643
2681
|
return {
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2682
|
+
altNames: altNamesOf(certificate),
|
|
2683
|
+
authorizationError: verdict.authorizationError,
|
|
2684
|
+
authorized: verdict.authorizationError === void 0,
|
|
2685
|
+
covers: (host) => checkServerIdentity(host, certificate) === void 0,
|
|
2686
|
+
daysRemaining: Math.floor((validTo.getTime() - now) / DAY_MS),
|
|
2687
|
+
issuer: firstOf(certificate.issuer.CN) ?? firstOf(certificate.issuer.O) ?? "",
|
|
2688
|
+
protocol: verdict.protocol,
|
|
2689
|
+
subject: firstOf(certificate.subject.CN) ?? "",
|
|
2690
|
+
validFrom: new Date(certificate.valid_from),
|
|
2691
|
+
validTo
|
|
2654
2692
|
};
|
|
2655
2693
|
}
|
|
2656
2694
|
//#endregion
|
|
2657
|
-
//#region src/
|
|
2658
|
-
|
|
2659
|
-
const
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2695
|
+
//#region src/specifications/api/deployed/unreachable.ts
|
|
2696
|
+
/** Why an exchange never answered, as the `unreachable: <reason>` line of its golden reads it. */
|
|
2697
|
+
const TIMEOUT = "timeout";
|
|
2698
|
+
const TLS = "tls";
|
|
2699
|
+
const REASONS = {
|
|
2700
|
+
EAI_AGAIN: "dns",
|
|
2701
|
+
EAI_FAIL: "dns",
|
|
2702
|
+
EAI_NODATA: "dns",
|
|
2703
|
+
EAI_NONAME: "dns",
|
|
2704
|
+
ECONNABORTED: "reset",
|
|
2705
|
+
ECONNREFUSED: "refused",
|
|
2706
|
+
ECONNRESET: "reset",
|
|
2707
|
+
EHOSTDOWN: "no route",
|
|
2708
|
+
EHOSTUNREACH: "no route",
|
|
2709
|
+
ENETDOWN: "no route",
|
|
2710
|
+
ENETUNREACH: "no route",
|
|
2711
|
+
ENODATA: "dns",
|
|
2712
|
+
ENOTFOUND: "dns",
|
|
2713
|
+
EPIPE: "reset"
|
|
2714
|
+
};
|
|
2715
|
+
/** The code node raised, when it raised one. */
|
|
2716
|
+
function codeOf(error) {
|
|
2717
|
+
if (typeof error === "string") return error === "" ? void 0 : error;
|
|
2718
|
+
const code = error !== null && typeof error === "object" ? Reflect.get(error, "code") : void 0;
|
|
2719
|
+
return typeof code === "string" && code !== "" ? code : void 0;
|
|
2720
|
+
}
|
|
2721
|
+
/** `dns`, `refused`, `reset`, `no route`, or the code node raised; a handshake's own failure is `tls <CODE>`, its network's reads as the network's. */
|
|
2722
|
+
function unreachableReason(error, duringHandshake = false) {
|
|
2723
|
+
const code = codeOf(error);
|
|
2724
|
+
const reason = code === void 0 ? void 0 : REASONS[code];
|
|
2725
|
+
if (reason !== void 0) return reason;
|
|
2726
|
+
if (duringHandshake) return `${TLS} ${code ?? "handshake"}`;
|
|
2727
|
+
return code ?? (error instanceof Error ? error.message : String(error));
|
|
2728
|
+
}
|
|
2729
|
+
/** A certificate the trust check refused, by the code it refused with. */
|
|
2730
|
+
function refusedCertificate(code) {
|
|
2731
|
+
return `${TLS} ${code}`;
|
|
2732
|
+
}
|
|
2733
|
+
//#endregion
|
|
2734
|
+
//#region src/specifications/api/deployed/transport.ts
|
|
2735
|
+
const USER_AGENT = "@jterrazz/test";
|
|
2736
|
+
function flatHeaders(headers) {
|
|
2737
|
+
return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, Array.isArray(value) ? value.join(", ") : String(value)]));
|
|
2738
|
+
}
|
|
2739
|
+
function headersOf(sent) {
|
|
2740
|
+
const headers = {
|
|
2741
|
+
host: sent.url.host,
|
|
2742
|
+
"user-agent": USER_AGENT
|
|
2743
|
+
};
|
|
2744
|
+
for (const [name, value] of Object.entries(sent.headers)) headers[name.toLowerCase()] = value;
|
|
2745
|
+
return headers;
|
|
2746
|
+
}
|
|
2747
|
+
async function opened(socket, event) {
|
|
2748
|
+
await new Promise((resolve, reject) => {
|
|
2749
|
+
socket.once(event, () => {
|
|
2750
|
+
resolve();
|
|
2690
2751
|
});
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
const declared = this.queue.declaredRoutes().map((route) => ` - ${route}`).join("\n");
|
|
2712
|
-
const strict = new Error(refusal$1("network-strict-after-first-stub", {
|
|
2713
|
-
fix: "add a stub for each to the chain's composite",
|
|
2714
|
-
found: `unmatched request(s) hit the declared backend during the chain:\n${unmatched}\ndeclared stubs:\n${declared}`
|
|
2715
|
-
}));
|
|
2716
|
-
return this.queue.verdict(strict);
|
|
2717
|
-
}
|
|
2718
|
-
async handle(request, response) {
|
|
2719
|
-
const method = (request.method ?? "GET").toUpperCase();
|
|
2720
|
-
const cors = corsHeaders(request);
|
|
2721
|
-
if (method === "OPTIONS") {
|
|
2722
|
-
response.writeHead(204, cors);
|
|
2723
|
-
response.end();
|
|
2724
|
-
return;
|
|
2725
|
-
}
|
|
2726
|
-
const url = request.url ?? "/";
|
|
2727
|
-
const observed = {
|
|
2728
|
-
body: await readBody(request),
|
|
2729
|
-
headers: stringHeaders(request),
|
|
2730
|
-
method,
|
|
2731
|
-
url
|
|
2752
|
+
socket.once("error", reject);
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
async function openSecure(address, url, route, hold) {
|
|
2756
|
+
const name = hostOf(url);
|
|
2757
|
+
const socket = connect$1({
|
|
2758
|
+
checkServerIdentity: (_host, certificate) => checkServerIdentity(name, certificate),
|
|
2759
|
+
host: address.host,
|
|
2760
|
+
port: address.port,
|
|
2761
|
+
rejectUnauthorized: false,
|
|
2762
|
+
...route.ca === void 0 ? {} : { ca: [...getCACertificates("default"), route.ca] },
|
|
2763
|
+
...isIP(name) === 0 ? { servername: name } : {}
|
|
2764
|
+
});
|
|
2765
|
+
hold(socket);
|
|
2766
|
+
try {
|
|
2767
|
+
await opened(socket, "secureConnect");
|
|
2768
|
+
} catch (error) {
|
|
2769
|
+
return {
|
|
2770
|
+
tls: void 0,
|
|
2771
|
+
unreachable: unreachableReason(error, true)
|
|
2732
2772
|
};
|
|
2733
|
-
this.received.push(observed);
|
|
2734
|
-
const stub = this.queue.take(observed);
|
|
2735
|
-
if (!stub) {
|
|
2736
|
-
this.record(method, url);
|
|
2737
|
-
const error = refusedReply(unmatchedSummary(method, url));
|
|
2738
|
-
response.writeHead(501, {
|
|
2739
|
-
...cors,
|
|
2740
|
-
"content-type": "application/json"
|
|
2741
|
-
});
|
|
2742
|
-
response.end(JSON.stringify({
|
|
2743
|
-
declared: this.queue.declaredRoutes(),
|
|
2744
|
-
error
|
|
2745
|
-
}));
|
|
2746
|
-
return;
|
|
2747
|
-
}
|
|
2748
|
-
const reply = await replyOf(stub.response, observed);
|
|
2749
|
-
if (reply.transport === "network-error") {
|
|
2750
|
-
request.socket.destroy();
|
|
2751
|
-
return;
|
|
2752
|
-
}
|
|
2753
|
-
await writeReply(response, reply, cors);
|
|
2754
2773
|
}
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
return Object.fromEntries(Object.entries(request.headers).filter((entry) => typeof entry[1] === "string"));
|
|
2774
|
+
const refused = socket.authorized ? void 0 : codeOf(socket.authorizationError);
|
|
2775
|
+
const tls = tlsFacts(socket.getPeerCertificate(), {
|
|
2776
|
+
authorizationError: refused,
|
|
2777
|
+
protocol: socket.getProtocol() ?? void 0
|
|
2778
|
+
}, Date.now());
|
|
2779
|
+
return refused === void 0 || route.insecure ? {
|
|
2780
|
+
socket,
|
|
2781
|
+
tls
|
|
2782
|
+
} : {
|
|
2783
|
+
tls,
|
|
2784
|
+
unreachable: refusedCertificate(refused)
|
|
2785
|
+
};
|
|
2768
2786
|
}
|
|
2769
|
-
async function
|
|
2770
|
-
const
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2787
|
+
async function open$1(sent, route, hold) {
|
|
2788
|
+
const address = route.connect ?? {
|
|
2789
|
+
host: hostOf(sent.url),
|
|
2790
|
+
port: portOf(sent.url)
|
|
2791
|
+
};
|
|
2792
|
+
if (sent.url.protocol === "https:") return await openSecure(address, sent.url, route, hold);
|
|
2793
|
+
const socket = connect(address);
|
|
2794
|
+
hold(socket);
|
|
2795
|
+
await opened(socket, "connect");
|
|
2796
|
+
return {
|
|
2797
|
+
socket,
|
|
2798
|
+
tls: void 0
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
async function exchanged(sent, socket) {
|
|
2802
|
+
return await new Promise((resolve, reject) => {
|
|
2803
|
+
const outgoing = request({
|
|
2804
|
+
createConnection: () => socket,
|
|
2805
|
+
headers: headersOf(sent),
|
|
2806
|
+
method: sent.method,
|
|
2807
|
+
path: `${sent.url.pathname}${sent.url.search}`
|
|
2808
|
+
}, (response) => {
|
|
2809
|
+
const chunks = [];
|
|
2810
|
+
response.on("data", (chunk) => {
|
|
2811
|
+
chunks.push(chunk);
|
|
2812
|
+
});
|
|
2813
|
+
response.once("end", () => {
|
|
2814
|
+
resolve({
|
|
2815
|
+
response,
|
|
2816
|
+
text: Buffer.concat(chunks).toString("utf8")
|
|
2817
|
+
});
|
|
2818
|
+
});
|
|
2819
|
+
response.once("error", reject);
|
|
2792
2820
|
});
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
}
|
|
2796
|
-
const contentType = typeof body === "string" ? "text/plain; charset=utf-8" : "application/json";
|
|
2797
|
-
response.writeHead(status, {
|
|
2798
|
-
...cors,
|
|
2799
|
-
"content-type": contentType,
|
|
2800
|
-
...reply.headers
|
|
2821
|
+
outgoing.once("error", reject);
|
|
2822
|
+
outgoing.end(sent.body);
|
|
2801
2823
|
});
|
|
2802
|
-
response.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
2803
|
-
}
|
|
2804
|
-
async function readBody(request) {
|
|
2805
|
-
const chunks = [];
|
|
2806
|
-
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
2807
|
-
return requestBody(Buffer.concat(chunks).toString("utf8"));
|
|
2808
2824
|
}
|
|
2809
|
-
function
|
|
2825
|
+
async function answered(sent, route, hold, presented) {
|
|
2826
|
+
const opening = await open$1(sent, route, hold);
|
|
2827
|
+
presented(opening.tls);
|
|
2828
|
+
if (!("socket" in opening)) return opening;
|
|
2829
|
+
const { response, text } = await exchanged(sent, opening.socket);
|
|
2810
2830
|
return {
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2831
|
+
headers: flatHeaders(response.headers),
|
|
2832
|
+
status: response.statusCode ?? 0,
|
|
2833
|
+
text,
|
|
2834
|
+
tls: opening.tls
|
|
2815
2835
|
};
|
|
2816
2836
|
}
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2837
|
+
/** One request and its whole response, under one deadline; a redirect is the answer, never followed. */
|
|
2838
|
+
async function exchange(sent, route) {
|
|
2839
|
+
const sockets = [];
|
|
2840
|
+
let tls;
|
|
2841
|
+
let timer;
|
|
2842
|
+
const deadline = new Promise((resolve) => {
|
|
2843
|
+
timer = setTimeout(() => {
|
|
2844
|
+
resolve({
|
|
2845
|
+
tls,
|
|
2846
|
+
unreachable: TIMEOUT
|
|
2847
|
+
});
|
|
2848
|
+
}, route.timeout);
|
|
2849
|
+
});
|
|
2850
|
+
try {
|
|
2851
|
+
return await Promise.race([answered(sent, route, (held) => {
|
|
2852
|
+
sockets.push(held);
|
|
2853
|
+
}, (facts) => {
|
|
2854
|
+
tls = facts;
|
|
2855
|
+
}), deadline]);
|
|
2856
|
+
} catch (error) {
|
|
2857
|
+
return {
|
|
2858
|
+
tls,
|
|
2859
|
+
unreachable: unreachableReason(error)
|
|
2860
|
+
};
|
|
2861
|
+
} finally {
|
|
2862
|
+
clearTimeout(timer);
|
|
2863
|
+
for (const socket of sockets) socket.destroy();
|
|
2833
2864
|
}
|
|
2834
|
-
});
|
|
2835
|
-
//#endregion
|
|
2836
|
-
//#region src/disk/fs-fixtures.adapter.ts
|
|
2837
|
-
function copyPlan(path, specDir, workDir) {
|
|
2838
|
-
const src = resolveSpecPath("fixture", path, specDir);
|
|
2839
|
-
if (!existsSync(src)) throw new Error(refusal$1("disk-named-by-relative-path-at-runtime", {
|
|
2840
|
-
fix: `create ${src}, or correct the path`,
|
|
2841
|
-
found: `fixture: ${JSON.stringify(path)} names nothing: ${src} does not exist`
|
|
2842
|
-
}));
|
|
2843
|
-
const spread = path.endsWith("/");
|
|
2844
|
-
const isDirectory = statSync(src).isDirectory();
|
|
2845
|
-
if (isDirectory !== spread) throw new TypeError(refusal$1("disk-named-by-relative-path-at-runtime", isDirectory ? {
|
|
2846
|
-
fix: `end its path in /: directory('${path}/'), whose contents are spread into the working directory`,
|
|
2847
|
-
found: `fixture: ${JSON.stringify(path)} names a directory`
|
|
2848
|
-
} : {
|
|
2849
|
-
fix: `drop the /: file('${path.replace(/\/+$/u, "")}')`,
|
|
2850
|
-
found: `fixture: ${JSON.stringify(path)} names a file`
|
|
2851
|
-
}));
|
|
2852
|
-
return {
|
|
2853
|
-
dest: spread ? workDir : resolve(workDir, basename(src)),
|
|
2854
|
-
src
|
|
2855
|
-
};
|
|
2856
2865
|
}
|
|
2857
|
-
const fsFixtures = { layer: (path, testDir, workDir) => {
|
|
2858
|
-
const { dest, src } = copyPlan(path, testDir, workDir);
|
|
2859
|
-
cpSync(src, dest, { recursive: true });
|
|
2860
|
-
} };
|
|
2861
2866
|
//#endregion
|
|
2862
|
-
//#region src/
|
|
2863
|
-
|
|
2864
|
-
function
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2867
|
+
//#region src/specifications/api/deployed/request.action.ts
|
|
2868
|
+
const NO_WORKDIR = { workDir: null };
|
|
2869
|
+
function responseOf(answer) {
|
|
2870
|
+
if (answer.unreachable !== void 0) return {
|
|
2871
|
+
body: null,
|
|
2872
|
+
headers: {},
|
|
2873
|
+
status: 0,
|
|
2874
|
+
unreachable: answer.unreachable
|
|
2875
|
+
};
|
|
2876
|
+
const contentType = answer.headers["content-type"];
|
|
2877
|
+
return {
|
|
2878
|
+
body: bodyKind(contentType) === "binary" ? null : readBody$1(answer.text, contentType) ?? null,
|
|
2879
|
+
headers: answer.headers,
|
|
2880
|
+
status: answer.status
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
2883
|
+
async function send(written, read, { config, testDir }) {
|
|
2884
|
+
const url = targetUrl(written.target, config.baseUrl);
|
|
2885
|
+
const address = read(connect$2);
|
|
2886
|
+
const answer = await exchange({
|
|
2887
|
+
body: written.body,
|
|
2888
|
+
headers: {
|
|
2889
|
+
...written.headers,
|
|
2890
|
+
...read(headers)
|
|
2891
|
+
},
|
|
2892
|
+
method: written.method,
|
|
2893
|
+
url
|
|
2894
|
+
}, {
|
|
2895
|
+
ca: config.ca,
|
|
2896
|
+
connect: address === null ? void 0 : connectAddress(address, url),
|
|
2897
|
+
insecure: config.insecure,
|
|
2898
|
+
timeout: config.timeout
|
|
2899
|
+
});
|
|
2900
|
+
return new ExchangeResult({
|
|
2901
|
+
recording: config.recording,
|
|
2902
|
+
response: responseOf(answer),
|
|
2903
|
+
testDir,
|
|
2904
|
+
tls: answer.tls
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
function writtenRequest(source, testDir) {
|
|
2908
|
+
const parsed = typeof source === "string" ? parseRequestFile(dedent(source), "the request in place") : parseRequestFile(readFileSync(resolve(testDir, source.path), "utf8"), source.path);
|
|
2909
|
+
return {
|
|
2910
|
+
body: parsed.body,
|
|
2911
|
+
headers: parsed.headers,
|
|
2912
|
+
method: parsed.method,
|
|
2913
|
+
target: parsed.path
|
|
2914
|
+
};
|
|
2873
2915
|
}
|
|
2916
|
+
/** A redirect is the answer, never followed; an exchange that never answered is the answer too, `unreachable: <reason>`. */
|
|
2917
|
+
const get = defineAction({
|
|
2918
|
+
accessors: EXCHANGE_RESULT_ROWS,
|
|
2919
|
+
name: "get",
|
|
2920
|
+
perform: async ([target], read, _adapters, run) => await send({
|
|
2921
|
+
headers: {},
|
|
2922
|
+
method: "GET",
|
|
2923
|
+
target
|
|
2924
|
+
}, read, run),
|
|
2925
|
+
row: {
|
|
2926
|
+
group: "Terminal action",
|
|
2927
|
+
name: ".get()",
|
|
2928
|
+
probe: ".get('/"
|
|
2929
|
+
}
|
|
2930
|
+
});
|
|
2874
2931
|
//#endregion
|
|
2875
|
-
//#region src/
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2932
|
+
//#region src/specifications/api/deployed/deployed.composition.ts
|
|
2933
|
+
const composition$6 = {
|
|
2934
|
+
actions: [defineAction({
|
|
2935
|
+
accessors: EXCHANGE_RESULT_ROWS,
|
|
2936
|
+
name: "request",
|
|
2937
|
+
perform: async ([source], read, _adapters, run) => await send(writtenRequest(source, run.testDir), read, run),
|
|
2938
|
+
prepare: ([source]) => {
|
|
2939
|
+
if (typeof source !== "string") requireDiskFile("request", source, ".http");
|
|
2940
|
+
return NO_WORKDIR;
|
|
2941
|
+
},
|
|
2942
|
+
row: {
|
|
2943
|
+
group: "Terminal action",
|
|
2944
|
+
name: ".request()",
|
|
2945
|
+
probe: ".request(file("
|
|
2946
|
+
}
|
|
2947
|
+
}), get],
|
|
2948
|
+
capabilities: [
|
|
2949
|
+
connect$2,
|
|
2950
|
+
headers,
|
|
2951
|
+
sharing
|
|
2952
|
+
],
|
|
2953
|
+
options: {
|
|
2954
|
+
ca: {
|
|
2955
|
+
group: "Constructor option",
|
|
2956
|
+
name: "ca",
|
|
2957
|
+
probe: "ca:"
|
|
2958
|
+
},
|
|
2959
|
+
insecure: {
|
|
2960
|
+
group: "Constructor option",
|
|
2961
|
+
name: "insecure",
|
|
2962
|
+
probe: "insecure:"
|
|
2963
|
+
},
|
|
2964
|
+
recordedHeaders: {
|
|
2965
|
+
group: "Constructor option",
|
|
2966
|
+
name: "recordedHeaders",
|
|
2967
|
+
probe: "recordedHeaders:"
|
|
2968
|
+
},
|
|
2969
|
+
timeout: {
|
|
2970
|
+
group: "Constructor option",
|
|
2971
|
+
name: "timeout",
|
|
2972
|
+
probe: "timeout:"
|
|
2973
|
+
},
|
|
2974
|
+
url: {
|
|
2975
|
+
group: "Constructor option",
|
|
2976
|
+
name: "url",
|
|
2977
|
+
probe: "url:"
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
};
|
|
2981
|
+
const apiDeployedDefinition = defineSpecification()({
|
|
2982
|
+
...composition$6,
|
|
2983
|
+
adapters: (_config) => ({}),
|
|
2984
|
+
locate: getCallerDir
|
|
2985
|
+
});
|
|
2986
|
+
//#endregion
|
|
2987
|
+
//#region src/specifications/api/deployed/refused-setups.ts
|
|
2988
|
+
const IN_PROCESS_ONLY = /* @__PURE__ */ new Set([
|
|
2989
|
+
"clock",
|
|
2990
|
+
"delete",
|
|
2991
|
+
"intercept",
|
|
2992
|
+
"post",
|
|
2993
|
+
"put",
|
|
2994
|
+
"seed"
|
|
2995
|
+
]);
|
|
2996
|
+
function refusedSetup(name) {
|
|
2997
|
+
return new Error(refusal$1({
|
|
2998
|
+
fix: "seed, stub and pin the app with `server`, in this process; against a `url`, send the request (`.get()`, `.request()`) and compare the response",
|
|
2999
|
+
found: `.${name}() on the chain of specification.api({ url })`,
|
|
3000
|
+
read: "docs/guides/api-specs.md#deployed-target-url",
|
|
3001
|
+
why: "A deployed api runs elsewhere: its database, its outbound network and its clock are its own, so its chain offers what a request can do, and a request other than a GET is written whole."
|
|
2886
3002
|
}));
|
|
2887
|
-
return src;
|
|
2888
3003
|
}
|
|
2889
|
-
/**
|
|
2890
|
-
function
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
const
|
|
2896
|
-
if (
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
if (entry.isSymbolicLink()) throw new Error(refusal$1({
|
|
2903
|
-
fix: `drop the symlink at ${ancestor} from the fixture or the command that lays it, so the working directory holds its own node_modules`,
|
|
2904
|
-
found: `link('${name}'): ${ancestor} is a symlink, so the link would replace ${name} wherever it points`,
|
|
2905
|
-
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
2906
|
-
why: "A link is laid inside the working directory only; through a symlinked folder it would delete a real install outside it."
|
|
2907
|
-
}));
|
|
2908
|
-
}
|
|
3004
|
+
/** The type already holds it; an untyped caller is told why rather than that the method is undefined. */
|
|
3005
|
+
function refusingInProcessSetups(chain) {
|
|
3006
|
+
return new Proxy(chain, { get: (target, name, receiver) => {
|
|
3007
|
+
if (typeof name === "string" && IN_PROCESS_ONLY.has(name)) return () => {
|
|
3008
|
+
throw refusedSetup(name);
|
|
3009
|
+
};
|
|
3010
|
+
const value = Reflect.get(target, name, receiver);
|
|
3011
|
+
if (typeof value !== "function") return value;
|
|
3012
|
+
return (...args) => {
|
|
3013
|
+
const next = Reflect.apply(value, target, args);
|
|
3014
|
+
return next !== null && typeof next === "object" && !(next instanceof Promise) ? refusingInProcessSetups(next) : next;
|
|
3015
|
+
};
|
|
3016
|
+
} });
|
|
2909
3017
|
}
|
|
2910
3018
|
//#endregion
|
|
2911
|
-
//#region src/
|
|
2912
|
-
|
|
2913
|
-
|
|
3019
|
+
//#region src/specifications/api/server/hono.adapter.ts
|
|
3020
|
+
const COMPOSED_HANDLER = "__COMPOSED_HANDLER";
|
|
3021
|
+
function targetOf(handler) {
|
|
3022
|
+
const composed = typeof handler === "function" ? Reflect.get(handler, COMPOSED_HANDLER) : void 0;
|
|
3023
|
+
return composed === void 0 ? handler : targetOf(composed);
|
|
2914
3024
|
}
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
return Object.fromEntries(Object.entries(isRecord(bin) ? bin : {}).filter((entry) => typeof entry[1] === "string"));
|
|
3025
|
+
function isMiddleware(entry) {
|
|
3026
|
+
const handler = targetOf(entry.handler);
|
|
3027
|
+
return typeof handler === "function" && handler.length > 1;
|
|
2919
3028
|
}
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
throw new Error(refusal$1({
|
|
2926
|
-
fix: `correct ${path}, so npm and Node read the package it describes`,
|
|
2927
|
-
found: `packed(): ${path} is no JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
2928
|
-
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
2929
|
-
why: "packed() reads the name, the bin and the runtime closure the package declares in its package.json."
|
|
2930
|
-
}), { cause: error });
|
|
3029
|
+
var HonoAdapter = class {
|
|
3030
|
+
app;
|
|
3031
|
+
reached = [];
|
|
3032
|
+
constructor(app) {
|
|
3033
|
+
this.app = app;
|
|
2931
3034
|
}
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
const name = field("name");
|
|
2938
|
-
const meta = field("peerDependenciesMeta");
|
|
2939
|
-
const optional = (peer) => isRecord(meta) && isRecord(meta[peer]) && meta[peer].optional === true;
|
|
2940
|
-
return {
|
|
2941
|
-
bin: binOf$1(name, field("bin")),
|
|
2942
|
-
name: typeof name === "string" ? name : void 0,
|
|
2943
|
-
runtime: [
|
|
2944
|
-
...keysOf(field("dependencies")),
|
|
2945
|
-
...keysOf(field("optionalDependencies")),
|
|
2946
|
-
...keysOf(field("peerDependencies")).filter((peer) => !optional(peer))
|
|
2947
|
-
]
|
|
2948
|
-
};
|
|
2949
|
-
}
|
|
2950
|
-
//#endregion
|
|
2951
|
-
//#region src/disk/npm-packed.adapter.ts
|
|
2952
|
-
function projectOf(testDir) {
|
|
2953
|
-
for (let directory = testDir;; directory = dirname(directory)) {
|
|
2954
|
-
if (existsSync(join(directory, "package.json"))) return directory;
|
|
2955
|
-
if (dirname(directory) === directory) throw new Error(refusal$1({
|
|
2956
|
-
fix: "run the spec inside the package it specifies, or name the package: packed(directory('./<path>/'))",
|
|
2957
|
-
found: `packed(): no package.json from ${testDir} up`,
|
|
2958
|
-
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
2959
|
-
why: "packed() packs the project the spec belongs to, which its nearest package.json names."
|
|
3035
|
+
/** Hono's `app.routes`, less the middleware entries; `undefined` for an app with no table. */
|
|
3036
|
+
declared() {
|
|
3037
|
+
return this.app.routes?.filter((entry) => !isMiddleware(entry)).map((entry) => ({
|
|
3038
|
+
method: entry.method,
|
|
3039
|
+
path: entry.path
|
|
2960
3040
|
}));
|
|
2961
3041
|
}
|
|
2962
|
-
}
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
2970
|
-
why: "packed() packs a package, and a package is the directory its package.json describes."
|
|
2971
|
-
}));
|
|
2972
|
-
return directory;
|
|
2973
|
-
}
|
|
2974
|
-
const UNPACKED = /* @__PURE__ */ new Map();
|
|
2975
|
-
function unpackedOf(packageRoot) {
|
|
2976
|
-
const known = UNPACKED.get(packageRoot);
|
|
2977
|
-
if (known !== void 0) return known;
|
|
2978
|
-
const into = mkdtempSync(join(tmpdir(), "jterrazz-packed-"));
|
|
2979
|
-
process.once("exit", () => {
|
|
2980
|
-
rmSync(into, {
|
|
2981
|
-
force: true,
|
|
2982
|
-
recursive: true
|
|
2983
|
-
});
|
|
2984
|
-
});
|
|
2985
|
-
const filename = packInto(packageRoot, into);
|
|
2986
|
-
execFileSync("tar", [
|
|
2987
|
-
"-xzf",
|
|
2988
|
-
join(into, filename),
|
|
2989
|
-
"-C",
|
|
2990
|
-
into
|
|
2991
|
-
]);
|
|
2992
|
-
const unpacked = join(into, "package");
|
|
2993
|
-
UNPACKED.set(packageRoot, unpacked);
|
|
2994
|
-
return unpacked;
|
|
2995
|
-
}
|
|
2996
|
-
function packInto(packageRoot, into) {
|
|
2997
|
-
const packing = spawnSync("npm", [
|
|
2998
|
-
"pack",
|
|
2999
|
-
"--ignore-scripts",
|
|
3000
|
-
"--silent",
|
|
3001
|
-
"--pack-destination",
|
|
3002
|
-
into
|
|
3003
|
-
], {
|
|
3004
|
-
cwd: packageRoot,
|
|
3005
|
-
encoding: "utf8",
|
|
3006
|
-
stdio: [
|
|
3007
|
-
"ignore",
|
|
3008
|
-
"pipe",
|
|
3009
|
-
"pipe"
|
|
3010
|
-
]
|
|
3011
|
-
});
|
|
3012
|
-
if (packing.status !== 0) throw new Error(refusal$1({
|
|
3013
|
-
fix: `run \`npm pack\` in ${packageRoot} and settle what npm says, so the package packs as it would be published`,
|
|
3014
|
-
found: `packed(): \`npm pack\` failed in ${packageRoot}${packing.error === void 0 ? "" : ` (${packing.error.message})`}:\n${packing.stderr.trim()}`,
|
|
3015
|
-
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3016
|
-
why: "packed() lays the tarball npm writes for the package, so a package npm cannot pack gives it nothing to lay."
|
|
3017
|
-
}));
|
|
3018
|
-
return packing.stdout.trim().split("\n").at(-1) ?? "";
|
|
3019
|
-
}
|
|
3020
|
-
function linkInto(workDir, name, source) {
|
|
3021
|
-
const destination = join(workDir, "node_modules", name);
|
|
3022
|
-
if (existsSync(destination)) return;
|
|
3023
|
-
mkdirSync(dirname(destination), { recursive: true });
|
|
3024
|
-
symlinkSync(source, destination, "dir");
|
|
3025
|
-
}
|
|
3026
|
-
function requiredPackages(name, runtime, { packageRoot, testDir }) {
|
|
3027
|
-
return runtime.map((dependency) => {
|
|
3028
|
-
const installed = installedPackage(dependency, packageRoot);
|
|
3029
|
-
if (installed === null) throw new Error(refusal$1({
|
|
3030
|
-
fix: `install ${dependency} where ${name} is developed, as its consumers' install would`,
|
|
3031
|
-
found: `link('${name}', packed()): ${name} requires ${dependency}, which no node_modules above ${relative(testDir, packageRoot) || "."} holds`,
|
|
3032
|
-
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3033
|
-
why: "The packed package is laid beside what its manifest requires at runtime, taken from where it is developed."
|
|
3034
|
-
}));
|
|
3035
|
-
return {
|
|
3036
|
-
dependency,
|
|
3037
|
-
installed
|
|
3042
|
+
async request(method, path, body, { headers, template } = {}) {
|
|
3043
|
+
const sent = new Headers({ "content-type": "application/json" });
|
|
3044
|
+
for (const [name, value] of Object.entries(headers ?? {})) sent.set(name, value);
|
|
3045
|
+
const init = {
|
|
3046
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
3047
|
+
headers: sent,
|
|
3048
|
+
method
|
|
3038
3049
|
};
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
fix: `link it under the name its package.json declares: link('${declared.name ?? "<no name>"}', packed(...))`,
|
|
3047
|
-
found: `link('${name}', packed()) packs ${relative(testDir, packageRoot) || "."}, whose package.json names ${declared.name ?? "no package"}`,
|
|
3048
|
-
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3049
|
-
why: "A consumer imports a package by the name it is published under, so the install lays it there."
|
|
3050
|
-
}));
|
|
3051
|
-
const modules = join(workDir, "node_modules");
|
|
3052
|
-
const destination = join(modules, name);
|
|
3053
|
-
for (const written of [
|
|
3054
|
-
destination,
|
|
3055
|
-
...declared.runtime.map((dependency) => join(modules, dependency)),
|
|
3056
|
-
...Object.keys(declared.bin).map((command) => join(modules, ".bin", command))
|
|
3057
|
-
]) refuseUnusableAncestor(name, workDir, written);
|
|
3058
|
-
const required = requiredPackages(name, declared.runtime, {
|
|
3059
|
-
packageRoot,
|
|
3060
|
-
testDir
|
|
3061
|
-
});
|
|
3062
|
-
const unpacked = unpackedOf(packageRoot);
|
|
3063
|
-
rmSync(destination, {
|
|
3064
|
-
force: true,
|
|
3065
|
-
recursive: true
|
|
3066
|
-
});
|
|
3067
|
-
mkdirSync(dirname(destination), { recursive: true });
|
|
3068
|
-
cpSync(unpacked, destination, { recursive: true });
|
|
3069
|
-
const manifest = manifestAt(unpacked);
|
|
3070
|
-
for (const { dependency, installed } of required) linkInto(workDir, dependency, installed);
|
|
3071
|
-
mkdirSync(join(modules, ".bin"), { recursive: true });
|
|
3072
|
-
for (const [command, target] of Object.entries(manifest.bin)) {
|
|
3073
|
-
const link = join(modules, ".bin", command);
|
|
3074
|
-
rmSync(link, { force: true });
|
|
3075
|
-
symlinkSync(relative(join(modules, ".bin"), join(destination, target)), link);
|
|
3076
|
-
}
|
|
3077
|
-
}
|
|
3078
|
-
//#endregion
|
|
3079
|
-
//#region src/disk/fs-links.adapter.ts
|
|
3080
|
-
function sourceOf(name, path, testDir) {
|
|
3081
|
-
if (path === null) {
|
|
3082
|
-
const installed = installedPackage(name, testDir);
|
|
3083
|
-
if (installed === null) throw new Error(refusal$1({
|
|
3084
|
-
fix: `install ${name} in the project the spec belongs to, or name the package's directory: link('${name}', directory('./__fixtures__/<package>/'))`,
|
|
3085
|
-
found: `link('${name}'): no node_modules from ${testDir} up holds ${name}`,
|
|
3086
|
-
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
3087
|
-
why: "A bare name links the package the spec's own project resolves, found as Node finds it."
|
|
3088
|
-
}));
|
|
3089
|
-
return installed;
|
|
3090
|
-
}
|
|
3091
|
-
return linkedDirectory(`link('${name}', directory(${JSON.stringify(path)}))`, path, testDir);
|
|
3092
|
-
}
|
|
3093
|
-
const fsLinks = {
|
|
3094
|
-
packed: installPacked,
|
|
3095
|
-
link: (name, path, testDir, workDir) => {
|
|
3096
|
-
const src = sourceOf(name, path, testDir);
|
|
3097
|
-
const dest = resolve(workDir, "node_modules", name);
|
|
3098
|
-
refuseUnusableAncestor(name, workDir, dest);
|
|
3099
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
3100
|
-
rmSync(dest, {
|
|
3101
|
-
force: true,
|
|
3102
|
-
recursive: true
|
|
3050
|
+
this.reached.push(template === void 0 ? {
|
|
3051
|
+
method,
|
|
3052
|
+
path
|
|
3053
|
+
} : {
|
|
3054
|
+
method,
|
|
3055
|
+
path,
|
|
3056
|
+
template
|
|
3103
3057
|
});
|
|
3104
|
-
|
|
3058
|
+
const response = await this.app.request(path, init);
|
|
3059
|
+
const contentType = response.headers.get("content-type") ?? void 0;
|
|
3060
|
+
const responseBody = bodyKind(contentType) === "binary" ? null : readBody$1(await response.text(), contentType) ?? null;
|
|
3061
|
+
const responseHeaders = Object.fromEntries(response.headers);
|
|
3062
|
+
return {
|
|
3063
|
+
status: response.status,
|
|
3064
|
+
body: responseBody,
|
|
3065
|
+
headers: responseHeaders
|
|
3066
|
+
};
|
|
3105
3067
|
}
|
|
3106
3068
|
};
|
|
3107
3069
|
//#endregion
|
|
3108
|
-
//#region src/
|
|
3109
|
-
|
|
3110
|
-
const
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
function
|
|
3118
|
-
return
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3070
|
+
//#region src/specifications/api/api.handle.ts
|
|
3071
|
+
const DEFAULT_TIMEOUT_MS$1 = 5e3;
|
|
3072
|
+
const DEFAULT_RECORDED_HEADERS = ["content-type", "location"];
|
|
3073
|
+
const DEPLOYED_ONLY = [
|
|
3074
|
+
"ca",
|
|
3075
|
+
"insecure",
|
|
3076
|
+
"recordedHeaders",
|
|
3077
|
+
"timeout"
|
|
3078
|
+
];
|
|
3079
|
+
function refused(found, why, fix) {
|
|
3080
|
+
return new Error(refusal$1({
|
|
3081
|
+
fix,
|
|
3082
|
+
found,
|
|
3083
|
+
read: "docs/guides/api-specs.md#options",
|
|
3084
|
+
why
|
|
3085
|
+
}));
|
|
3122
3086
|
}
|
|
3123
|
-
function
|
|
3124
|
-
|
|
3087
|
+
function refuseTarget(options) {
|
|
3088
|
+
const hasServer = options.server !== void 0;
|
|
3089
|
+
if (hasServer && options.url !== void 0) throw refused("specification.api({ server, url }), an app built here beside a deployed one", "`server` and `url` are exclusive: a request goes to the app this process builds, or to the one deployed at the url.", "keep `server` to test the app in this process, or `url` to probe the deployed one");
|
|
3090
|
+
const deployedOnly = DEPLOYED_ONLY.filter((name) => options[name] !== void 0);
|
|
3091
|
+
if (hasServer && deployedOnly.length > 0) throw refused(`specification.api({ server, ${deployedOnly.join(", ")} }), an option of a deployed api beside an app built here`, "`ca`, `insecure`, `recordedHeaders` and `timeout` shape requests sent over the network, which a `url` names; an app built in this process is answered without one.", "drop them, or name the deployed api with `url`");
|
|
3092
|
+
if (!hasServer && (options.services !== void 0 || options.root !== void 0)) throw refused("specification.api({ url, services }), services beside a deployed url", "A deployed api is wired to its own services: services started here never reach it.", "drop `services` and `root`, or build the app with `server` to wire them");
|
|
3125
3093
|
}
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
function requirePackageName(name, linked) {
|
|
3130
|
-
if (typeof name !== "string" || !PACKAGE_NAME.test(name) || name.toLowerCase() !== name) throw new TypeError(refusal$1({
|
|
3131
|
-
fix: "name it as a package.json `dependencies` key does: link('@scope/name') or link('name')",
|
|
3132
|
-
found: `link(${JSON.stringify(name)}), no package name`,
|
|
3133
|
-
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
3134
|
-
why: "The name is where the package lands, `node_modules/<name>`, and what the binary imports it by."
|
|
3135
|
-
}));
|
|
3136
|
-
if (linked.some((entry) => entry.name === name)) throw new TypeError(refusal$1({
|
|
3137
|
-
fix: `link ${name} once, from the one source the binary should resolve`,
|
|
3138
|
-
found: `link(${JSON.stringify(name)}), stated twice on one chain`,
|
|
3139
|
-
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
3140
|
-
why: "A name resolves to one package: a second link of it would silently replace the first."
|
|
3141
|
-
}));
|
|
3142
|
-
return name;
|
|
3094
|
+
function refuseUrl(url) {
|
|
3095
|
+
if (typeof url !== "string" || url.trim() === "") throw refused(`specification.api({ url: ${JSON.stringify(url)} }), no app to specify`, "An api spec names the app it requests: a `server` it builds in this process, or a deployed `url`.", "build the app with `server`, or pass the absolute URL of the deployed api as `url`");
|
|
3096
|
+
if (!/^https?:\/\/[^/\s]/u.test(url)) throw refused(`specification.api({ url: ${JSON.stringify(url)} }), no http(s) URL`, "A path is read from the base `url`, so the base is an absolute http:// or https:// URL.", "name the deployed api's base as an absolute URL, `url: 'https://site.test'`");
|
|
3143
3097
|
}
|
|
3144
|
-
function
|
|
3145
|
-
if (
|
|
3146
|
-
if (isPackedPackage(source)) return source.path;
|
|
3147
|
-
if (isDiskDirectory(source)) return source.path;
|
|
3148
|
-
throw new TypeError(refusal$1("disk-named-by-relative-path-at-runtime", {
|
|
3149
|
-
fix: `name the package's directory: link('${name}', directory('./__fixtures__/${name.split("/").at(-1)}/'))`,
|
|
3150
|
-
found: isDiskFile(source) ? `link('${name}', file(${JSON.stringify(source.path)})), a file where a package's directory is read` : `link('${name}', ${typeof source === "string" ? JSON.stringify(source) : typeof source}), a value where a package's directory is read`
|
|
3151
|
-
}));
|
|
3098
|
+
function refuseTimeout(timeout) {
|
|
3099
|
+
if (timeout !== void 0 && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) throw refused(`specification.api({ url, timeout: ${typeof timeout === "number" ? String(timeout) : JSON.stringify(timeout)} }), no deadline`, "A request that never answers reads `unreachable: timeout` once its deadline passes, so the deadline is a positive number of milliseconds.", "pass the milliseconds one request may take, `timeout: 2000`, or leave the 5000 default");
|
|
3152
3100
|
}
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
name: "link",
|
|
3160
|
-
phase: 25,
|
|
3161
|
-
reduce: (linked, [name, source]) => {
|
|
3162
|
-
const checked = requirePackageName(name, linked);
|
|
3163
|
-
return [...linked, {
|
|
3164
|
-
name: checked,
|
|
3165
|
-
packed: isPackedPackage(source),
|
|
3166
|
-
path: requireSource(checked, source)
|
|
3167
|
-
}];
|
|
3168
|
-
},
|
|
3169
|
-
row: {
|
|
3170
|
-
group: "Setup",
|
|
3171
|
-
name: ".link()",
|
|
3172
|
-
probe: ".link("
|
|
3101
|
+
/** Refused before anything starts: a target that is neither or both, and what a deployed target cannot carry. */
|
|
3102
|
+
function refuseOptions$1(options) {
|
|
3103
|
+
refuseTarget(options);
|
|
3104
|
+
if (options.server === void 0) {
|
|
3105
|
+
refuseUrl(options.url);
|
|
3106
|
+
refuseTimeout(options.timeout);
|
|
3173
3107
|
}
|
|
3174
|
-
});
|
|
3175
|
-
//#endregion
|
|
3176
|
-
//#region src/disk/prepare.capability.ts
|
|
3177
|
-
function requireStep(step) {
|
|
3178
|
-
if (typeof step === "function") return step;
|
|
3179
|
-
throw new TypeError(refusal$1({
|
|
3180
|
-
fix: "hand it a function of the working directory: .prepare((workdir) => { execFileSync('git', ['init'], { cwd: workdir }); })",
|
|
3181
|
-
found: `prepare(${JSON.stringify(step) ?? typeof step}), no step to run`,
|
|
3182
|
-
read: "docs/guides/cli-specs.md#prepare-what-no-fixture-can-carry",
|
|
3183
|
-
why: "A step is run over the working directory once the fixtures are laid; only a function can be run."
|
|
3184
|
-
}));
|
|
3185
3108
|
}
|
|
3186
|
-
|
|
3187
|
-
const
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
group: "Setup",
|
|
3198
|
-
name: ".prepare()",
|
|
3199
|
-
probe: ".prepare("
|
|
3200
|
-
}
|
|
3201
|
-
});
|
|
3202
|
-
//#endregion
|
|
3203
|
-
//#region src/stubs/delivery/other-process/stub-backend.adapter.ts
|
|
3204
|
-
/** Each chain's stubs replace the previous chain's, even when it declares none; a runner without a backend refuses `.intercept()`. */
|
|
3205
|
-
function stubBackend(backend, specification) {
|
|
3109
|
+
function openDeployed(options) {
|
|
3110
|
+
const config = {
|
|
3111
|
+
baseUrl: options.url,
|
|
3112
|
+
ca: options.ca,
|
|
3113
|
+
insecure: options.insecure ?? false,
|
|
3114
|
+
recording: {
|
|
3115
|
+
bodyKeptAbsent: true,
|
|
3116
|
+
headers: options.recordedHeaders ?? DEFAULT_RECORDED_HEADERS
|
|
3117
|
+
},
|
|
3118
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT_MS$1
|
|
3119
|
+
};
|
|
3206
3120
|
return {
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3121
|
+
api: refusingInProcessSetups(apiDeployedDefinition.open(config)),
|
|
3122
|
+
cleanup: async () => {}
|
|
3123
|
+
};
|
|
3124
|
+
}
|
|
3125
|
+
async function startApi(options) {
|
|
3126
|
+
const callerDir = getCallerDir();
|
|
3127
|
+
await registerMatchers();
|
|
3128
|
+
refuseOptions$1(options);
|
|
3129
|
+
if (options.server === void 0) return openDeployed(options);
|
|
3130
|
+
return await startServed(options, callerDir);
|
|
3131
|
+
}
|
|
3132
|
+
async function startServed(options, callerDir) {
|
|
3133
|
+
const root = resolveRoot(options.root, callerDir);
|
|
3134
|
+
const services = declaredServices(options.services);
|
|
3135
|
+
const databaseKeys = declaredDatabaseKeys(services);
|
|
3136
|
+
const { database, databases, stop } = await startServices(services, root);
|
|
3137
|
+
const server = new HonoAdapter(options.server(services));
|
|
3138
|
+
const config = {
|
|
3139
|
+
database,
|
|
3140
|
+
databaseKeys,
|
|
3141
|
+
databases,
|
|
3142
|
+
server
|
|
3143
|
+
};
|
|
3144
|
+
return {
|
|
3145
|
+
api: apiDefinition.open(config),
|
|
3146
|
+
cleanup: async () => {
|
|
3147
|
+
const ledgers = await routesLedger();
|
|
3148
|
+
if (ledgers !== void 0) writeLedger(ledgers, {
|
|
3149
|
+
declared: server.declared(),
|
|
3150
|
+
reached: server.reached
|
|
3151
|
+
});
|
|
3152
|
+
await stop();
|
|
3225
3153
|
},
|
|
3226
|
-
|
|
3227
|
-
if (backend === void 0) return {};
|
|
3228
|
-
backend.beginChain(stubs);
|
|
3229
|
-
return { violation: () => backend.violation() };
|
|
3230
|
-
}
|
|
3154
|
+
docker: createDockerReader(callerDir)
|
|
3231
3155
|
};
|
|
3232
3156
|
}
|
|
3233
3157
|
//#endregion
|
|
3234
|
-
//#region src/
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3158
|
+
//#region src/stubs/delivery/other-process/stub-backend.ts
|
|
3159
|
+
const CORS_METHODS = "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS";
|
|
3160
|
+
const PREFLIGHT_MAX_AGE = "600";
|
|
3161
|
+
/** A request matching no stub gets 501 and is recorded; once the chain declared a stub, the action throws naming every unmatched request. Every response carries permissive CORS headers. */
|
|
3162
|
+
var StubBackend = class {
|
|
3163
|
+
guarded = false;
|
|
3164
|
+
options;
|
|
3165
|
+
queue = new StubQueue([]);
|
|
3166
|
+
received = [];
|
|
3167
|
+
server = null;
|
|
3168
|
+
unmatchedRequests = /* @__PURE__ */ new Map();
|
|
3169
|
+
constructor(options = {}) {
|
|
3170
|
+
this.options = options;
|
|
3171
|
+
}
|
|
3172
|
+
async start() {
|
|
3173
|
+
const server = createServer((request, response) => {
|
|
3174
|
+
this.handle(request, response);
|
|
3175
|
+
});
|
|
3176
|
+
this.server = server;
|
|
3177
|
+
return `http://127.0.0.1:${await listenedPort(server, {
|
|
3178
|
+
host: "127.0.0.1",
|
|
3179
|
+
port: this.options.port ?? 0
|
|
3180
|
+
})}`;
|
|
3181
|
+
}
|
|
3182
|
+
async stop() {
|
|
3183
|
+
const { server } = this;
|
|
3184
|
+
if (!server) return;
|
|
3185
|
+
this.server = null;
|
|
3186
|
+
server.closeAllConnections();
|
|
3187
|
+
await new Promise((resolve) => {
|
|
3188
|
+
server.close(() => {
|
|
3189
|
+
resolve();
|
|
3190
|
+
});
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
/** The chain's stubs replace the previous chain's wholesale; the queue and the unmatched log restart. */
|
|
3194
|
+
beginChain(stubs) {
|
|
3195
|
+
this.guarded = stubs.length > 0;
|
|
3196
|
+
this.queue = new StubQueue(stubs);
|
|
3197
|
+
this.unmatchedRequests.clear();
|
|
3198
|
+
this.received.length = 0;
|
|
3199
|
+
}
|
|
3200
|
+
/** Every request the backend was sent since the chain began, in arrival order, matched or not: what a binary SENT, to compare whole. A preflight is no request of the program's. */
|
|
3201
|
+
requests() {
|
|
3202
|
+
return this.received.map((request) => ({
|
|
3203
|
+
...request,
|
|
3204
|
+
headers: { ...request.headers }
|
|
3205
|
+
}));
|
|
3206
|
+
}
|
|
3207
|
+
/** Non-null when the chain declared a stub and a request went unmatched, a never stub was reached, or a `required` stub was never met. */
|
|
3208
|
+
violation() {
|
|
3209
|
+
if (!this.guarded) return null;
|
|
3210
|
+
if (this.unmatchedRequests.size === 0) return this.queue.verdict();
|
|
3211
|
+
const unmatched = [...this.unmatchedRequests.values()].map((entry) => ` - ${entry.method} ${entry.path}${entry.count > 1 ? ` (${entry.count} times)` : ""}`).join("\n");
|
|
3212
|
+
const declared = this.queue.declaredRoutes().map((route) => ` - ${route}`).join("\n");
|
|
3213
|
+
const strict = new Error(refusal$1("network-strict-after-first-stub", {
|
|
3214
|
+
fix: "add a stub for each to the chain's composite",
|
|
3215
|
+
found: `unmatched request(s) hit the declared backend during the chain:\n${unmatched}\ndeclared stubs:\n${declared}`
|
|
3216
|
+
}));
|
|
3217
|
+
return this.queue.verdict(strict);
|
|
3218
|
+
}
|
|
3219
|
+
async handle(request, response) {
|
|
3220
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
3221
|
+
const cors = corsHeaders(request);
|
|
3222
|
+
if (method === "OPTIONS") {
|
|
3223
|
+
response.writeHead(204, cors);
|
|
3224
|
+
response.end();
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3227
|
+
const url = request.url ?? "/";
|
|
3228
|
+
const observed = {
|
|
3229
|
+
body: await readBody(request),
|
|
3230
|
+
headers: stringHeaders(request),
|
|
3231
|
+
method,
|
|
3232
|
+
url
|
|
3233
|
+
};
|
|
3234
|
+
this.received.push(observed);
|
|
3235
|
+
const stub = this.queue.take(observed);
|
|
3236
|
+
if (!stub) {
|
|
3237
|
+
this.record(method, url);
|
|
3238
|
+
const error = refusedReply(unmatchedSummary(method, url));
|
|
3239
|
+
response.writeHead(501, {
|
|
3240
|
+
...cors,
|
|
3241
|
+
"content-type": "application/json"
|
|
3242
|
+
});
|
|
3243
|
+
response.end(JSON.stringify({
|
|
3244
|
+
declared: this.queue.declaredRoutes(),
|
|
3245
|
+
error
|
|
3246
|
+
}));
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
3249
|
+
const reply = await replyOf(stub.response, observed);
|
|
3250
|
+
if (reply.transport === "network-error") {
|
|
3251
|
+
request.socket.destroy();
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
await writeReply(response, reply, cors);
|
|
3255
|
+
}
|
|
3256
|
+
record(method, path) {
|
|
3257
|
+
const key = `${method} ${path}`;
|
|
3258
|
+
const existing = this.unmatchedRequests.get(key);
|
|
3259
|
+
if (existing) existing.count += 1;
|
|
3260
|
+
else this.unmatchedRequests.set(key, {
|
|
3261
|
+
count: 1,
|
|
3262
|
+
method,
|
|
3263
|
+
path
|
|
3264
|
+
});
|
|
3265
|
+
}
|
|
3266
|
+
};
|
|
3267
|
+
function stringHeaders(request) {
|
|
3268
|
+
return Object.fromEntries(Object.entries(request.headers).filter((entry) => typeof entry[1] === "string"));
|
|
3269
|
+
}
|
|
3270
|
+
async function writeReply(response, reply, cors) {
|
|
3271
|
+
const status = reply.status ?? 200;
|
|
3272
|
+
const { body } = reply;
|
|
3273
|
+
if (isStreamBody(body)) {
|
|
3274
|
+
response.writeHead(status, {
|
|
3275
|
+
...cors,
|
|
3276
|
+
"content-type": body.contentType,
|
|
3277
|
+
...reply.headers
|
|
3278
|
+
});
|
|
3279
|
+
await toReadableStream(body).pipeTo(new WritableStream({
|
|
3280
|
+
close() {
|
|
3281
|
+
response.end();
|
|
3282
|
+
},
|
|
3283
|
+
write(chunk) {
|
|
3284
|
+
response.write(chunk);
|
|
3285
|
+
}
|
|
3286
|
+
}));
|
|
3287
|
+
return;
|
|
3288
|
+
}
|
|
3289
|
+
if (body === null || body === void 0) {
|
|
3290
|
+
response.writeHead(status, {
|
|
3291
|
+
...cors,
|
|
3292
|
+
...reply.headers
|
|
3244
3293
|
});
|
|
3294
|
+
response.end();
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
const contentType = typeof body === "string" ? "text/plain; charset=utf-8" : "application/json";
|
|
3298
|
+
response.writeHead(status, {
|
|
3299
|
+
...cors,
|
|
3300
|
+
"content-type": contentType,
|
|
3301
|
+
...reply.headers
|
|
3245
3302
|
});
|
|
3246
|
-
|
|
3303
|
+
response.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
3304
|
+
}
|
|
3305
|
+
async function readBody(request) {
|
|
3306
|
+
const chunks = [];
|
|
3307
|
+
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
3308
|
+
return requestBody(Buffer.concat(chunks).toString("utf8"));
|
|
3309
|
+
}
|
|
3310
|
+
function corsHeaders(request) {
|
|
3311
|
+
return {
|
|
3312
|
+
"access-control-allow-headers": request.headers["access-control-request-headers"] ?? "*",
|
|
3313
|
+
"access-control-allow-methods": CORS_METHODS,
|
|
3314
|
+
"access-control-allow-origin": "*",
|
|
3315
|
+
"access-control-max-age": PREFLIGHT_MAX_AGE
|
|
3316
|
+
};
|
|
3247
3317
|
}
|
|
3248
3318
|
//#endregion
|
|
3249
|
-
//#region src/
|
|
3250
|
-
/**
|
|
3251
|
-
const
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3319
|
+
//#region src/disk/fixture.capability.ts
|
|
3320
|
+
/** Judged when stated, resolved on disk when armed; a later fixture overwrites an earlier one. */
|
|
3321
|
+
const fixture = defineCapability({
|
|
3322
|
+
arm: (targets, { workdir }, { testDir, workDir }) => {
|
|
3323
|
+
if (workDir !== null) for (const target of targets) workdir.layer(target.path, testDir, workDir);
|
|
3324
|
+
return {};
|
|
3325
|
+
},
|
|
3326
|
+
initial: [],
|
|
3327
|
+
name: "fixture",
|
|
3328
|
+
phase: 20,
|
|
3329
|
+
reduce: (targets, [source]) => [...targets, requireDiskTarget("fixture", source)],
|
|
3260
3330
|
row: {
|
|
3261
3331
|
group: "Setup",
|
|
3262
|
-
name: ".
|
|
3263
|
-
probe: ".
|
|
3332
|
+
name: ".fixture()",
|
|
3333
|
+
probe: ".fixture("
|
|
3264
3334
|
}
|
|
3265
3335
|
});
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
}
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
if (onlySql !== void 0) injected.DATABASE_URL = onlySql.connectionString;
|
|
3287
|
-
const onlyRedis = onlyOf(redis);
|
|
3288
|
-
if (onlyRedis !== void 0) injected.REDIS_URL = onlyRedis.connectionString;
|
|
3289
|
-
return injected;
|
|
3290
|
-
}
|
|
3291
|
-
/** Merge order: `defaults`, services' URLs, docker run id, then the chain's `.env()`; `undefined` when nothing is set. */
|
|
3292
|
-
function childEnv(config, chained, workdir) {
|
|
3293
|
-
const declared = config.defaultEnv;
|
|
3294
|
-
const services = config.services === void 0 || Object.keys(config.services).length === 0 ? void 0 : serviceEnv(config.services);
|
|
3295
|
-
let merged = declared ? {
|
|
3296
|
-
...expandWorkdir(declared, workdir),
|
|
3297
|
-
...services
|
|
3298
|
-
} : services;
|
|
3299
|
-
const { dockerConfig, dockerTestRunId } = config;
|
|
3300
|
-
if (dockerConfig && dockerTestRunId !== void 0 && dockerTestRunId !== "") merged = {
|
|
3301
|
-
...merged,
|
|
3302
|
-
[dockerConfig.envVar]: dockerTestRunId
|
|
3303
|
-
};
|
|
3304
|
-
if (Object.keys(chained).length > 0) merged = {
|
|
3305
|
-
...merged,
|
|
3306
|
-
...expandWorkdir(chained, workdir)
|
|
3336
|
+
//#endregion
|
|
3337
|
+
//#region src/disk/fs-fixtures.adapter.ts
|
|
3338
|
+
function copyPlan(path, specDir, workDir) {
|
|
3339
|
+
const src = resolveSpecPath("fixture", path, specDir);
|
|
3340
|
+
if (!existsSync(src)) throw new Error(refusal$1("disk-named-by-relative-path-at-runtime", {
|
|
3341
|
+
fix: `create ${src}, or correct the path`,
|
|
3342
|
+
found: `fixture: ${JSON.stringify(path)} names nothing: ${src} does not exist`
|
|
3343
|
+
}));
|
|
3344
|
+
const spread = path.endsWith("/");
|
|
3345
|
+
const isDirectory = statSync(src).isDirectory();
|
|
3346
|
+
if (isDirectory !== spread) throw new TypeError(refusal$1("disk-named-by-relative-path-at-runtime", isDirectory ? {
|
|
3347
|
+
fix: `end its path in /: directory('${path}/'), whose contents are spread into the working directory`,
|
|
3348
|
+
found: `fixture: ${JSON.stringify(path)} names a directory`
|
|
3349
|
+
} : {
|
|
3350
|
+
fix: `drop the /: file('${path.replace(/\/+$/u, "")}')`,
|
|
3351
|
+
found: `fixture: ${JSON.stringify(path)} names a file`
|
|
3352
|
+
}));
|
|
3353
|
+
return {
|
|
3354
|
+
dest: spread ? workDir : resolve(workDir, basename(src)),
|
|
3355
|
+
src
|
|
3307
3356
|
};
|
|
3308
|
-
return merged;
|
|
3309
3357
|
}
|
|
3358
|
+
const fsFixtures = { layer: (path, testDir, workDir) => {
|
|
3359
|
+
const { dest, src } = copyPlan(path, testDir, workDir);
|
|
3360
|
+
cpSync(src, dest, { recursive: true });
|
|
3361
|
+
} };
|
|
3310
3362
|
//#endregion
|
|
3311
|
-
//#region
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
}
|
|
3322
|
-
/** The YAML document is edited, not re-emitted, so everything but exit codes and streams comes back byte-identical. */
|
|
3323
|
-
function updateSpecFile(file, updates) {
|
|
3324
|
-
const root = file.source.document.contents;
|
|
3325
|
-
const runs = isMap(root) ? root.get("runs", true) : void 0;
|
|
3326
|
-
if (!isSeq(runs)) return renderYamlSource(file.source);
|
|
3327
|
-
for (const [index, update] of updates.entries()) {
|
|
3328
|
-
const run = runs.items[index];
|
|
3329
|
-
if (!isMap(run)) continue;
|
|
3330
|
-
setOrdered(run, "exit", update.exitCode, RUN_KEYS);
|
|
3331
|
-
for (const [key, text] of [["stdout", update.stdout], ["stderr", update.stderr]]) {
|
|
3332
|
-
if (text.length === 0) {
|
|
3333
|
-
run.delete(key);
|
|
3334
|
-
continue;
|
|
3335
|
-
}
|
|
3336
|
-
setOrdered(run, key, blockScalar(text), RUN_KEYS);
|
|
3337
|
-
}
|
|
3363
|
+
//#region src/disk/installed-package.ts
|
|
3364
|
+
/** The directory of the package `name`, found as Node finds it from `from`: in the nearest `node_modules` holding it. */
|
|
3365
|
+
function installedPackage(name, testDir) {
|
|
3366
|
+
let dir = testDir;
|
|
3367
|
+
for (;;) {
|
|
3368
|
+
const candidate = resolve(dir, "node_modules", name);
|
|
3369
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) return candidate;
|
|
3370
|
+
const parent = dirname(dir);
|
|
3371
|
+
if (parent === dir) return null;
|
|
3372
|
+
dir = parent;
|
|
3338
3373
|
}
|
|
3339
|
-
return renderYamlSource(file.source);
|
|
3340
3374
|
}
|
|
3341
3375
|
//#endregion
|
|
3342
|
-
//#region src/
|
|
3343
|
-
/**
|
|
3344
|
-
|
|
3345
|
-
const
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
const known = Object.keys(registry);
|
|
3356
|
-
throw new Error(refusal$1({
|
|
3357
|
-
fix: known.length === 0 ? "register it in the `serve` option of specification.cli()" : `serve a registered server: ${known.join(", ")}`,
|
|
3358
|
-
found: `${displayPath}:${entry.line}: serve: "${entry.name}" is not registered`,
|
|
3359
|
-
read: "docs/guides/cli-specs.md#registration-once-per-app",
|
|
3360
|
-
why: "A document serves what the app registered once, in the options of specification.cli()."
|
|
3361
|
-
}));
|
|
3362
|
-
}
|
|
3363
|
-
const adapter = new ServedProcess({
|
|
3364
|
-
command: registration.command,
|
|
3365
|
-
ready: registration.ready
|
|
3366
|
-
}, config.root ?? process.cwd(), "cli", {
|
|
3367
|
-
TEST_WORKDIR: workdir,
|
|
3368
|
-
...entry.env
|
|
3369
|
-
});
|
|
3370
|
-
started.push(adapter);
|
|
3371
|
-
try {
|
|
3372
|
-
await adapter.start();
|
|
3373
|
-
} catch (error) {
|
|
3374
|
-
await stop();
|
|
3375
|
-
throw error;
|
|
3376
|
-
}
|
|
3377
|
-
if (adapter.port === null) throw invariant("a served process answered its readiness without a port");
|
|
3378
|
-
env[registration.env] = registration.url(adapter.port);
|
|
3379
|
-
}
|
|
3380
|
-
return {
|
|
3381
|
-
env,
|
|
3382
|
-
stop
|
|
3383
|
-
};
|
|
3376
|
+
//#region src/disk/link-destination.ts
|
|
3377
|
+
/** The directory a `directory()` names for a link, refused when it names nothing or a file; `call` is the gesture as the spec wrote it. */
|
|
3378
|
+
function linkedDirectory(call, path, testDir) {
|
|
3379
|
+
const src = resolveSpecPath("link", path, testDir);
|
|
3380
|
+
if (!existsSync(src)) throw new Error(refusal$1("disk-named-by-relative-path-at-runtime", {
|
|
3381
|
+
fix: `create ${src}, or correct the path`,
|
|
3382
|
+
found: `${call} names nothing: ${src} does not exist`
|
|
3383
|
+
}));
|
|
3384
|
+
if (!statSync(src).isDirectory()) throw new Error(refusal$1("disk-named-by-relative-path-at-runtime", {
|
|
3385
|
+
fix: `name the directory that holds the package's package.json: directory('${dirname(path)}/')`,
|
|
3386
|
+
found: `${call} names a file`
|
|
3387
|
+
}));
|
|
3388
|
+
return src;
|
|
3384
3389
|
}
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
for (const
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
}
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3390
|
+
/** Refuses a destination under the working directory whose path crosses a file or a symlink: through a symlinked folder, the delete before the lay would reach a real install outside it. */
|
|
3391
|
+
function refuseUnusableAncestor(name, workDir, dest) {
|
|
3392
|
+
let ancestor = workDir;
|
|
3393
|
+
for (const segment of relative(workDir, dirname(dest)).split(/[\\/]/u)) {
|
|
3394
|
+
ancestor = resolve(ancestor, segment);
|
|
3395
|
+
if (!existsSync(ancestor)) return;
|
|
3396
|
+
const entry = lstatSync(ancestor);
|
|
3397
|
+
if (!entry.isSymbolicLink() && !entry.isDirectory()) throw new Error(refusal$1({
|
|
3398
|
+
fix: `drop the file at ${ancestor} from the fixture or the command that lays it, so the link can lay its folder there`,
|
|
3399
|
+
found: `link('${name}'): ${ancestor} is a file, not a folder`,
|
|
3400
|
+
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
3401
|
+
why: "A link is laid at node_modules/<name> inside the working directory, and a file on that path leaves it no folder to lay it in."
|
|
3402
|
+
}));
|
|
3403
|
+
if (entry.isSymbolicLink()) throw new Error(refusal$1({
|
|
3404
|
+
fix: `drop the symlink at ${ancestor} from the fixture or the command that lays it, so the working directory holds its own node_modules`,
|
|
3405
|
+
found: `link('${name}'): ${ancestor} is a symlink, so the link would replace ${name} wherever it points`,
|
|
3406
|
+
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
3407
|
+
why: "A link is laid inside the working directory only; through a symlinked folder it would delete a real install outside it."
|
|
3408
|
+
}));
|
|
3404
3409
|
}
|
|
3405
|
-
return env;
|
|
3406
3410
|
}
|
|
3407
3411
|
//#endregion
|
|
3408
|
-
//#region src/
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
const stripped = stripAnsiCodes(raw);
|
|
3412
|
-
return transform ? transform(stripped) : stripped;
|
|
3413
|
-
}
|
|
3414
|
-
function tokenAwareEquals(scope) {
|
|
3415
|
-
return (expected, actual) => textEquals(expected, actual, new CaptureScope(scope.workdir));
|
|
3416
|
-
}
|
|
3417
|
-
function resolveUnderWorkDir(path, context) {
|
|
3418
|
-
const { workDir } = context;
|
|
3419
|
-
const target = resolve(workDir, path);
|
|
3420
|
-
const inside = relative(workDir, target);
|
|
3421
|
-
if (isAbsolute(path) || inside.startsWith("..") || isAbsolute(inside)) throw new Error(refusal$1({
|
|
3422
|
-
fix: "name a relative path under the working directory",
|
|
3423
|
-
found: `${context.displayPath}: files: "${path}" leaves the working directory`,
|
|
3424
|
-
read: "docs/guides/spec-documents.md#files-what-the-run-left-behind",
|
|
3425
|
-
why: "A `files:` key is a relative path under the working directory."
|
|
3426
|
-
}));
|
|
3427
|
-
return target;
|
|
3412
|
+
//#region src/disk/package-manifest.ts
|
|
3413
|
+
function keysOf(value) {
|
|
3414
|
+
return isRecord(value) ? Object.keys(value) : [];
|
|
3428
3415
|
}
|
|
3429
|
-
|
|
3430
|
-
|
|
3416
|
+
const SCOPE = /^@[^/]+\//u;
|
|
3417
|
+
function binOf$1(name, bin) {
|
|
3418
|
+
if (typeof bin === "string") return typeof name === "string" ? { [name.replace(SCOPE, "")]: bin } : {};
|
|
3419
|
+
return Object.fromEntries(Object.entries(isRecord(bin) ? bin : {}).filter((entry) => typeof entry[1] === "string"));
|
|
3431
3420
|
}
|
|
3432
|
-
function
|
|
3421
|
+
function parsedManifest(directory) {
|
|
3422
|
+
const path = join(directory, "package.json");
|
|
3433
3423
|
try {
|
|
3434
|
-
return
|
|
3435
|
-
} catch {
|
|
3436
|
-
|
|
3424
|
+
return JSON.parse(readFileSync(path).toString());
|
|
3425
|
+
} catch (error) {
|
|
3426
|
+
throw new Error(refusal$1({
|
|
3427
|
+
fix: `correct ${path}, so npm and Node read the package it describes`,
|
|
3428
|
+
found: `packed(): ${path} is no JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
3429
|
+
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3430
|
+
why: "packed() reads the name, the bin and the runtime closure the package declares in its package.json."
|
|
3431
|
+
}), { cause: error });
|
|
3437
3432
|
}
|
|
3438
3433
|
}
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
const
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
return null;
|
|
3434
|
+
/** What `packed()` reads of a package's manifest: its name, the commands its `bin` lays, and what it requires at runtime; a manifest that is no JSON is refused. */
|
|
3435
|
+
function manifestAt(directory) {
|
|
3436
|
+
const parsed = parsedManifest(directory);
|
|
3437
|
+
const field = (key) => isRecord(parsed) ? parsed[key] : void 0;
|
|
3438
|
+
const name = field("name");
|
|
3439
|
+
const meta = field("peerDependenciesMeta");
|
|
3440
|
+
const optional = (peer) => isRecord(meta) && isRecord(meta[peer]) && meta[peer].optional === true;
|
|
3441
|
+
return {
|
|
3442
|
+
bin: binOf$1(name, field("bin")),
|
|
3443
|
+
name: typeof name === "string" ? name : void 0,
|
|
3444
|
+
runtime: [
|
|
3445
|
+
...keysOf(field("dependencies")),
|
|
3446
|
+
...keysOf(field("optionalDependencies")),
|
|
3447
|
+
...keysOf(field("peerDependencies")).filter((peer) => !optional(peer))
|
|
3448
|
+
]
|
|
3449
|
+
};
|
|
3456
3450
|
}
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
""
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
read: "docs/guides/spec-documents.md#failure-and-update",
|
|
3470
|
-
why: "A spec document states what its runs produce: the exit code and the streams, whole."
|
|
3471
|
-
})), context.filePath, run.commandLine);
|
|
3451
|
+
//#endregion
|
|
3452
|
+
//#region src/disk/npm-packed.adapter.ts
|
|
3453
|
+
function projectOf(testDir) {
|
|
3454
|
+
for (let directory = testDir;; directory = dirname(directory)) {
|
|
3455
|
+
if (existsSync(join(directory, "package.json"))) return directory;
|
|
3456
|
+
if (dirname(directory) === directory) throw new Error(refusal$1({
|
|
3457
|
+
fix: "run the spec inside the package it specifies, or name the package: packed(directory('./<path>/'))",
|
|
3458
|
+
found: `packed(): no package.json from ${testDir} up`,
|
|
3459
|
+
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3460
|
+
why: "packed() packs the project the spec belongs to, which its nearest package.json names."
|
|
3461
|
+
}));
|
|
3462
|
+
}
|
|
3472
3463
|
}
|
|
3473
|
-
function
|
|
3474
|
-
|
|
3475
|
-
|
|
3464
|
+
function packageDirectory(name, path, testDir) {
|
|
3465
|
+
const call = `link('${name}', packed(directory(${JSON.stringify(path)})))`;
|
|
3466
|
+
const directory = linkedDirectory(call, path, testDir);
|
|
3467
|
+
if (!existsSync(join(directory, "package.json"))) throw new Error(refusal$1({
|
|
3468
|
+
fix: `name the directory that holds the package's package.json, or give ${directory} one`,
|
|
3469
|
+
found: `${call}: ${directory} holds no package.json`,
|
|
3470
|
+
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3471
|
+
why: "packed() packs a package, and a package is the directory its package.json describes."
|
|
3472
|
+
}));
|
|
3473
|
+
return directory;
|
|
3476
3474
|
}
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
const
|
|
3480
|
-
if (
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
run.stderr?.text ?? "",
|
|
3499
|
-
outcome.stderr
|
|
3500
|
-
]]) if (!textEquals(expected, received, scope)) details.push(formatStdoutDiff(name, expected, received, { equals }));
|
|
3501
|
-
details.push(...filesDetail(outcome, context));
|
|
3502
|
-
if (details.length > 0) throw failure(run, context, details);
|
|
3475
|
+
const UNPACKED = /* @__PURE__ */ new Map();
|
|
3476
|
+
function unpackedOf(packageRoot) {
|
|
3477
|
+
const known = UNPACKED.get(packageRoot);
|
|
3478
|
+
if (known !== void 0) return known;
|
|
3479
|
+
const into = mkdtempSync(join(tmpdir(), "jterrazz-packed-"));
|
|
3480
|
+
process.once("exit", () => {
|
|
3481
|
+
rmSync(into, {
|
|
3482
|
+
force: true,
|
|
3483
|
+
recursive: true
|
|
3484
|
+
});
|
|
3485
|
+
});
|
|
3486
|
+
const filename = packInto(packageRoot, into);
|
|
3487
|
+
execFileSync("tar", [
|
|
3488
|
+
"-xzf",
|
|
3489
|
+
join(into, filename),
|
|
3490
|
+
"-C",
|
|
3491
|
+
into
|
|
3492
|
+
]);
|
|
3493
|
+
const unpacked = join(into, "package");
|
|
3494
|
+
UNPACKED.set(packageRoot, unpacked);
|
|
3495
|
+
return unpacked;
|
|
3503
3496
|
}
|
|
3504
|
-
function
|
|
3505
|
-
const
|
|
3506
|
-
|
|
3507
|
-
"
|
|
3508
|
-
"",
|
|
3509
|
-
|
|
3510
|
-
|
|
3497
|
+
function packInto(packageRoot, into) {
|
|
3498
|
+
const packing = spawnSync("npm", [
|
|
3499
|
+
"pack",
|
|
3500
|
+
"--ignore-scripts",
|
|
3501
|
+
"--silent",
|
|
3502
|
+
"--pack-destination",
|
|
3503
|
+
into
|
|
3504
|
+
], {
|
|
3505
|
+
cwd: packageRoot,
|
|
3506
|
+
encoding: "utf8",
|
|
3507
|
+
stdio: [
|
|
3508
|
+
"ignore",
|
|
3509
|
+
"pipe",
|
|
3510
|
+
"pipe"
|
|
3511
|
+
]
|
|
3512
|
+
});
|
|
3513
|
+
if (packing.status !== 0) throw new Error(refusal$1({
|
|
3514
|
+
fix: `run \`npm pack\` in ${packageRoot} and settle what npm says, so the package packs as it would be published`,
|
|
3515
|
+
found: `packed(): \`npm pack\` failed in ${packageRoot}${packing.error === void 0 ? "" : ` (${packing.error.message})`}:\n${packing.stderr.trim()}`,
|
|
3516
|
+
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3517
|
+
why: "packed() lays the tarball npm writes for the package, so a package npm cannot pack gives it nothing to lay."
|
|
3518
|
+
}));
|
|
3519
|
+
return packing.stdout.trim().split("\n").at(-1) ?? "";
|
|
3511
3520
|
}
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3521
|
+
function linkInto(workDir, name, source) {
|
|
3522
|
+
const destination = join(workDir, "node_modules", name);
|
|
3523
|
+
if (existsSync(destination)) return;
|
|
3524
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
3525
|
+
symlinkSync(source, destination, "dir");
|
|
3516
3526
|
}
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3527
|
+
function requiredPackages(name, runtime, { packageRoot, testDir }) {
|
|
3528
|
+
return runtime.map((dependency) => {
|
|
3529
|
+
const installed = installedPackage(dependency, packageRoot);
|
|
3530
|
+
if (installed === null) throw new Error(refusal$1({
|
|
3531
|
+
fix: `install ${dependency} where ${name} is developed, as its consumers' install would`,
|
|
3532
|
+
found: `link('${name}', packed()): ${name} requires ${dependency}, which no node_modules above ${relative(testDir, packageRoot) || "."} holds`,
|
|
3533
|
+
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3534
|
+
why: "The packed package is laid beside what its manifest requires at runtime, taken from where it is developed."
|
|
3535
|
+
}));
|
|
3536
|
+
return {
|
|
3537
|
+
dependency,
|
|
3538
|
+
installed
|
|
3539
|
+
};
|
|
3540
|
+
});
|
|
3541
|
+
}
|
|
3542
|
+
/** Lays the packed package at `node_modules/<name>` as a copy, beside a link to each package its manifest requires at runtime, resolved from where the package is developed. */
|
|
3543
|
+
function installPacked(name, path, testDir, workDir) {
|
|
3544
|
+
const packageRoot = path === null ? projectOf(testDir) : packageDirectory(name, path, testDir);
|
|
3545
|
+
const declared = manifestAt(packageRoot);
|
|
3546
|
+
if (declared.name !== name) throw new Error(refusal$1({
|
|
3547
|
+
fix: `link it under the name its package.json declares: link('${declared.name ?? "<no name>"}', packed(...))`,
|
|
3548
|
+
found: `link('${name}', packed()) packs ${relative(testDir, packageRoot) || "."}, whose package.json names ${declared.name ?? "no package"}`,
|
|
3549
|
+
read: "docs/guides/cli-specs.md#a-package-specified-from-its-artefact",
|
|
3550
|
+
why: "A consumer imports a package by the name it is published under, so the install lays it there."
|
|
3523
3551
|
}));
|
|
3552
|
+
const modules = join(workDir, "node_modules");
|
|
3553
|
+
const destination = join(modules, name);
|
|
3554
|
+
for (const written of [
|
|
3555
|
+
destination,
|
|
3556
|
+
...declared.runtime.map((dependency) => join(modules, dependency)),
|
|
3557
|
+
...Object.keys(declared.bin).map((command) => join(modules, ".bin", command))
|
|
3558
|
+
]) refuseUnusableAncestor(name, workDir, written);
|
|
3559
|
+
const required = requiredPackages(name, declared.runtime, {
|
|
3560
|
+
packageRoot,
|
|
3561
|
+
testDir
|
|
3562
|
+
});
|
|
3563
|
+
const unpacked = unpackedOf(packageRoot);
|
|
3564
|
+
rmSync(destination, {
|
|
3565
|
+
force: true,
|
|
3566
|
+
recursive: true
|
|
3567
|
+
});
|
|
3568
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
3569
|
+
cpSync(unpacked, destination, { recursive: true });
|
|
3570
|
+
const manifest = manifestAt(unpacked);
|
|
3571
|
+
for (const { dependency, installed } of required) linkInto(workDir, dependency, installed);
|
|
3572
|
+
mkdirSync(join(modules, ".bin"), { recursive: true });
|
|
3573
|
+
for (const [command, target] of Object.entries(manifest.bin)) {
|
|
3574
|
+
const link = join(modules, ".bin", command);
|
|
3575
|
+
rmSync(link, { force: true });
|
|
3576
|
+
symlinkSync(relative(join(modules, ".bin"), join(destination, target)), link);
|
|
3577
|
+
}
|
|
3524
3578
|
}
|
|
3525
3579
|
//#endregion
|
|
3526
|
-
//#region src/
|
|
3527
|
-
function
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3580
|
+
//#region src/disk/fs-links.adapter.ts
|
|
3581
|
+
function sourceOf(name, path, testDir) {
|
|
3582
|
+
if (path === null) {
|
|
3583
|
+
const installed = installedPackage(name, testDir);
|
|
3584
|
+
if (installed === null) throw new Error(refusal$1({
|
|
3585
|
+
fix: `install ${name} in the project the spec belongs to, or name the package's directory: link('${name}', directory('./__fixtures__/<package>/'))`,
|
|
3586
|
+
found: `link('${name}'): no node_modules from ${testDir} up holds ${name}`,
|
|
3587
|
+
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
3588
|
+
why: "A bare name links the package the spec's own project resolves, found as Node finds it."
|
|
3589
|
+
}));
|
|
3590
|
+
return installed;
|
|
3532
3591
|
}
|
|
3592
|
+
return linkedDirectory(`link('${name}', directory(${JSON.stringify(path)}))`, path, testDir);
|
|
3533
3593
|
}
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
const
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
workDir
|
|
3594
|
+
const fsLinks = {
|
|
3595
|
+
packed: installPacked,
|
|
3596
|
+
link: (name, path, testDir, workDir) => {
|
|
3597
|
+
const src = sourceOf(name, path, testDir);
|
|
3598
|
+
const dest = resolve(workDir, "node_modules", name);
|
|
3599
|
+
refuseUnusableAncestor(name, workDir, dest);
|
|
3600
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
3601
|
+
rmSync(dest, {
|
|
3602
|
+
force: true,
|
|
3603
|
+
recursive: true
|
|
3604
|
+
});
|
|
3605
|
+
symlinkSync(src, dest, "dir");
|
|
3606
|
+
}
|
|
3607
|
+
};
|
|
3608
|
+
//#endregion
|
|
3609
|
+
//#region src/disk/in-process-steps.adapter.ts
|
|
3610
|
+
/** A step is the test's own code: it runs in the test's process, one after the other. */
|
|
3611
|
+
const inProcessSteps = { run: async (steps, workDir) => {
|
|
3612
|
+
for (const step of steps) await step(workDir);
|
|
3613
|
+
} };
|
|
3614
|
+
//#endregion
|
|
3615
|
+
//#region src/disk/packed.ts
|
|
3616
|
+
const PACKED = Symbol.for("@jterrazz/test:packed");
|
|
3617
|
+
/** Name the package a consumer installs: the project the spec belongs to, or the package a directory holds, packed as `npm pack` packs it. */
|
|
3618
|
+
function packed(source) {
|
|
3619
|
+
return Object.freeze({
|
|
3620
|
+
[PACKED]: true,
|
|
3621
|
+
path: isDiskDirectory(source) ? source.path : null
|
|
3563
3622
|
});
|
|
3564
3623
|
}
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
const { config, displayPath, filePath, workDir } = options;
|
|
3568
|
-
const { command } = config;
|
|
3569
|
-
if (!command) throw invariant(".run() reached a chain with no command adapter");
|
|
3570
|
-
const file = readDocument(filePath, displayPath);
|
|
3571
|
-
const { document } = file;
|
|
3572
|
-
const fileDir = filePath.replace(/[/\\][^/\\]*$/u, "");
|
|
3573
|
-
for (const fixture of document.fixtures) fsFixtures.layer(fixture.path, fileDir, workDir);
|
|
3574
|
-
const workdir = safeRealpath(workDir);
|
|
3575
|
-
const servers = await startServers(document, config, displayPath, workdir);
|
|
3576
|
-
const scope = new CaptureScope(workdir);
|
|
3577
|
-
const env = expandWorkdir({
|
|
3578
|
-
...options.baseEnv,
|
|
3579
|
-
...servers.env,
|
|
3580
|
-
...documentEnv(document, config, displayPath)
|
|
3581
|
-
}, workdir);
|
|
3582
|
-
const judgement = {
|
|
3583
|
-
displayPath,
|
|
3584
|
-
document,
|
|
3585
|
-
filePath,
|
|
3586
|
-
scope,
|
|
3587
|
-
workDir
|
|
3588
|
-
};
|
|
3589
|
-
const session = {
|
|
3590
|
-
command,
|
|
3591
|
-
env,
|
|
3592
|
-
transform: config.transform,
|
|
3593
|
-
workDir
|
|
3594
|
-
};
|
|
3595
|
-
const rewriting = shouldUpdateSnapshots() && options.frozen !== true;
|
|
3596
|
-
const outcomes = [];
|
|
3597
|
-
try {
|
|
3598
|
-
for (const run of document.runs) {
|
|
3599
|
-
const outcome = await execute(run, session);
|
|
3600
|
-
outcomes.push(outcome);
|
|
3601
|
-
if (rewriting) assertFiles(outcome, judgement);
|
|
3602
|
-
else assertRun(outcome, judgement);
|
|
3603
|
-
}
|
|
3604
|
-
} finally {
|
|
3605
|
-
await servers.stop();
|
|
3606
|
-
}
|
|
3607
|
-
if (rewriting) writeFileSync(filePath, updateSpecFile(file, updates(outcomes, scope)));
|
|
3608
|
-
return lastResult(outcomes, options);
|
|
3624
|
+
function isPackedPackage(value) {
|
|
3625
|
+
return value !== null && typeof value === "object" && Reflect.get(value, PACKED) === true;
|
|
3609
3626
|
}
|
|
3610
3627
|
//#endregion
|
|
3611
|
-
//#region src/
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
+
//#region src/disk/link.capability.ts
|
|
3629
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9~-][\w.~-]*\/)?[a-z0-9~-][\w.~-]*$/u;
|
|
3630
|
+
function requirePackageName(name, linked) {
|
|
3631
|
+
if (typeof name !== "string" || !PACKAGE_NAME.test(name) || name.toLowerCase() !== name) throw new TypeError(refusal$1({
|
|
3632
|
+
fix: "name it as a package.json `dependencies` key does: link('@scope/name') or link('name')",
|
|
3633
|
+
found: `link(${JSON.stringify(name)}), no package name`,
|
|
3634
|
+
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
3635
|
+
why: "The name is where the package lands, `node_modules/<name>`, and what the binary imports it by."
|
|
3636
|
+
}));
|
|
3637
|
+
if (linked.some((entry) => entry.name === name)) throw new TypeError(refusal$1({
|
|
3638
|
+
fix: `link ${name} once, from the one source the binary should resolve`,
|
|
3639
|
+
found: `link(${JSON.stringify(name)}), stated twice on one chain`,
|
|
3640
|
+
read: "docs/guides/cli-specs.md#link-a-package-the-working-directory-resolves",
|
|
3641
|
+
why: "A name resolves to one package: a second link of it would silently replace the first."
|
|
3642
|
+
}));
|
|
3643
|
+
return name;
|
|
3644
|
+
}
|
|
3645
|
+
function requireSource(name, source) {
|
|
3646
|
+
if (source === void 0) return null;
|
|
3647
|
+
if (isPackedPackage(source)) return source.path;
|
|
3648
|
+
if (isDiskDirectory(source)) return source.path;
|
|
3649
|
+
throw new TypeError(refusal$1("disk-named-by-relative-path-at-runtime", {
|
|
3650
|
+
fix: `name the package's directory: link('${name}', directory('./__fixtures__/${name.split("/").at(-1)}/'))`,
|
|
3651
|
+
found: isDiskFile(source) ? `link('${name}', file(${JSON.stringify(source.path)})), a file where a package's directory is read` : `link('${name}', ${typeof source === "string" ? JSON.stringify(source) : typeof source}), a value where a package's directory is read`
|
|
3652
|
+
}));
|
|
3653
|
+
}
|
|
3654
|
+
const link$1 = defineCapability({
|
|
3655
|
+
arm: (linked, { links }, { testDir, workDir }) => {
|
|
3656
|
+
if (workDir !== null) for (const { name, packed, path } of linked) (packed ? links.packed : links.link)(name, path, testDir, workDir);
|
|
3657
|
+
return {};
|
|
3628
3658
|
},
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3659
|
+
initial: [],
|
|
3660
|
+
name: "link",
|
|
3661
|
+
phase: 25,
|
|
3662
|
+
reduce: (linked, [name, source]) => {
|
|
3663
|
+
const checked = requirePackageName(name, linked);
|
|
3664
|
+
return [...linked, {
|
|
3665
|
+
name: checked,
|
|
3666
|
+
packed: isPackedPackage(source),
|
|
3667
|
+
path: requireSource(checked, source)
|
|
3668
|
+
}];
|
|
3632
3669
|
},
|
|
3633
3670
|
row: {
|
|
3634
|
-
group: "
|
|
3635
|
-
name: ".
|
|
3636
|
-
probe: ".
|
|
3671
|
+
group: "Setup",
|
|
3672
|
+
name: ".link()",
|
|
3673
|
+
probe: ".link("
|
|
3637
3674
|
}
|
|
3638
3675
|
});
|
|
3639
3676
|
//#endregion
|
|
3640
|
-
//#region src/
|
|
3641
|
-
function
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
function
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
fix: "name a folder below the working directory, relative to it: .exec('check', { cwd: 'packages/site' })",
|
|
3649
|
-
found: `.exec(..., { cwd: ${JSON.stringify(cwd)} }), a folder outside the working directory`,
|
|
3650
|
-
read: "docs/guides/cli-specs.md#cwd-a-folder-of-the-working-directory",
|
|
3651
|
-
why: "A command runs in the working directory the chain laid, or in one of its folders: anywhere else is the machine, not the fixture."
|
|
3652
|
-
}));
|
|
3653
|
-
}
|
|
3654
|
-
function runFolder(root, cwd) {
|
|
3655
|
-
if (cwd === void 0) return root;
|
|
3656
|
-
const folder = resolve(root, cwd);
|
|
3657
|
-
if (statSync(folder, { throwIfNoEntry: false })?.isDirectory() !== true) throw new Error(refusal$1({
|
|
3658
|
-
fix: "lay the folder with a fixture, or name one the fixtures laid",
|
|
3659
|
-
found: `.exec(..., { cwd: ${JSON.stringify(cwd)} }), a folder the working directory does not hold`,
|
|
3660
|
-
read: "docs/guides/cli-specs.md#cwd-a-folder-of-the-working-directory",
|
|
3661
|
-
why: "A command runs in a folder the chain laid: one that does not exist has no files of the fixture."
|
|
3677
|
+
//#region src/disk/prepare.capability.ts
|
|
3678
|
+
function requireStep(step) {
|
|
3679
|
+
if (typeof step === "function") return step;
|
|
3680
|
+
throw new TypeError(refusal$1({
|
|
3681
|
+
fix: "hand it a function of the working directory: .prepare((workdir) => { execFileSync('git', ['init'], { cwd: workdir }); })",
|
|
3682
|
+
found: `prepare(${JSON.stringify(step) ?? typeof step}), no step to run`,
|
|
3683
|
+
read: "docs/guides/cli-specs.md#prepare-what-no-fixture-can-carry",
|
|
3684
|
+
why: "A step is run over the working directory once the fixtures are laid; only a function can be run."
|
|
3662
3685
|
}));
|
|
3663
|
-
return folder;
|
|
3664
3686
|
}
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
exitCode: 0,
|
|
3680
|
-
stderr: "",
|
|
3681
|
-
stdout: ""
|
|
3682
|
-
};
|
|
3683
|
-
for (const one of args) {
|
|
3684
|
-
output = await command.exec(one, cwd, env$1);
|
|
3685
|
-
if (output.exitCode !== 0) break;
|
|
3686
|
-
}
|
|
3687
|
-
} else output = await command.exec(args, cwd, env$1);
|
|
3688
|
-
return new CliResult({
|
|
3689
|
-
commandOutput: output,
|
|
3690
|
-
config,
|
|
3691
|
-
dockerConfig: config.dockerConfig ?? void 0,
|
|
3692
|
-
requests: config.backend?.requests(),
|
|
3693
|
-
testDir,
|
|
3694
|
-
testRunId: config.dockerTestRunId ?? void 0,
|
|
3695
|
-
transform: config.transform,
|
|
3696
|
-
workDir: root
|
|
3697
|
-
});
|
|
3698
|
-
},
|
|
3699
|
-
prepare: ([args = "", options]) => {
|
|
3700
|
-
if (Array.isArray(args) && args.length === 0) throw new Error(refusal$1({
|
|
3701
|
-
fix: "name at least one command: .exec(['build', 'test'])",
|
|
3702
|
-
found: ".exec([]), a sequence of no command",
|
|
3703
|
-
read: "docs/guides/cli-specs.md#sequences-array-form",
|
|
3704
|
-
why: "A sequence runs its commands in order, and runs at least one."
|
|
3705
|
-
}));
|
|
3706
|
-
if (isLongRunning(options) && Array.isArray(args)) throw new Error(refusal$1({
|
|
3707
|
-
fix: "wait on one long-running command: .exec(args, { waitFor, timeout }), or drop the options",
|
|
3708
|
-
found: ".exec([...], { waitFor/timeout }), options on a command sequence",
|
|
3709
|
-
read: "docs/guides/cli-specs.md#long-running-processes-waitfor--timeout",
|
|
3710
|
-
why: "`waitFor` and `timeout` wait on one long-running process."
|
|
3711
|
-
}));
|
|
3712
|
-
requireRelativeFolder(options?.cwd);
|
|
3713
|
-
return { workDir: scratchDirectory("spec-command-") };
|
|
3714
|
-
},
|
|
3715
|
-
row: {
|
|
3716
|
-
group: "Terminal action",
|
|
3717
|
-
name: ".exec()",
|
|
3718
|
-
probe: ".exec("
|
|
3719
|
-
}
|
|
3720
|
-
}), run$1],
|
|
3721
|
-
capabilities: [
|
|
3722
|
-
env,
|
|
3723
|
-
fixture,
|
|
3724
|
-
intercept,
|
|
3725
|
-
link$1,
|
|
3726
|
-
prepare,
|
|
3727
|
-
seed,
|
|
3728
|
-
sharing
|
|
3729
|
-
],
|
|
3730
|
-
options: {
|
|
3731
|
-
backend: {
|
|
3732
|
-
group: "Constructor option",
|
|
3733
|
-
name: "backend",
|
|
3734
|
-
probe: "backend:"
|
|
3735
|
-
},
|
|
3736
|
-
commands: {
|
|
3737
|
-
group: "Constructor option",
|
|
3738
|
-
name: "commands",
|
|
3739
|
-
probe: "commands:"
|
|
3740
|
-
},
|
|
3741
|
-
defaults: {
|
|
3742
|
-
group: "Constructor option",
|
|
3743
|
-
name: "defaults",
|
|
3744
|
-
probe: "defaults:"
|
|
3745
|
-
},
|
|
3746
|
-
docker: {
|
|
3747
|
-
group: "Constructor option",
|
|
3748
|
-
name: "docker",
|
|
3749
|
-
probe: "docker:"
|
|
3750
|
-
},
|
|
3751
|
-
env: {
|
|
3752
|
-
group: "Constructor option",
|
|
3753
|
-
name: "env",
|
|
3754
|
-
probe: "env:"
|
|
3755
|
-
},
|
|
3756
|
-
root: {
|
|
3757
|
-
group: "Constructor option",
|
|
3758
|
-
name: "root",
|
|
3759
|
-
probe: "root:"
|
|
3760
|
-
},
|
|
3761
|
-
serve: {
|
|
3762
|
-
group: "Constructor option",
|
|
3763
|
-
name: "serve",
|
|
3764
|
-
probe: "serve:"
|
|
3765
|
-
},
|
|
3766
|
-
services: {
|
|
3767
|
-
group: "Constructor option",
|
|
3768
|
-
name: "services",
|
|
3769
|
-
probe: "services:"
|
|
3770
|
-
},
|
|
3771
|
-
transform: {
|
|
3772
|
-
group: "Constructor option",
|
|
3773
|
-
name: "transform",
|
|
3774
|
-
probe: "transform:"
|
|
3775
|
-
}
|
|
3687
|
+
/** Armed after `.fixture()` and `.link()`: a step shapes the tree they laid, never the reverse. */
|
|
3688
|
+
const prepare = defineCapability({
|
|
3689
|
+
arm: async (prepared, { steps }, { workDir }) => {
|
|
3690
|
+
if (workDir !== null) await steps.run(prepared, workDir);
|
|
3691
|
+
return {};
|
|
3692
|
+
},
|
|
3693
|
+
initial: [],
|
|
3694
|
+
name: "prepare",
|
|
3695
|
+
phase: 26,
|
|
3696
|
+
reduce: (steps, [step]) => [...steps, requireStep(step)],
|
|
3697
|
+
row: {
|
|
3698
|
+
group: "Setup",
|
|
3699
|
+
name: ".prepare()",
|
|
3700
|
+
probe: ".prepare("
|
|
3776
3701
|
}
|
|
3777
|
-
};
|
|
3778
|
-
const cliDefinition = defineSpecification()({
|
|
3779
|
-
...composition$6,
|
|
3780
|
-
adapters: (config) => ({
|
|
3781
|
-
command: config.command,
|
|
3782
|
-
databases: databaseSet(config),
|
|
3783
|
-
links: fsLinks,
|
|
3784
|
-
network: stubBackend(config.backend, "cli"),
|
|
3785
|
-
steps: inProcessSteps,
|
|
3786
|
-
workdir: fsFixtures
|
|
3787
|
-
}),
|
|
3788
|
-
locate: getCallerDir
|
|
3789
3702
|
});
|
|
3790
3703
|
//#endregion
|
|
3791
|
-
//#region src/
|
|
3792
|
-
/**
|
|
3793
|
-
function
|
|
3794
|
-
return [root, process.cwd()].map((directory) => resolve(directory, "node_modules/.bin", command)).find((path) => existsSync(path)) ?? command;
|
|
3795
|
-
}
|
|
3796
|
-
//#endregion
|
|
3797
|
-
//#region src/specifications/cli/process/exec.adapter.ts
|
|
3798
|
-
const DEFAULT_WATCH_TIMEOUT = 1e4;
|
|
3799
|
-
const TIMED_OUT = 124;
|
|
3800
|
-
const COVERAGE_REPORTER = /--require="[^"]*child-coverage\.cjs"/u;
|
|
3801
|
-
function withCoverageReporter(extra) {
|
|
3802
|
-
const stated = extra?.NODE_OPTIONS;
|
|
3803
|
-
const reporter = COVERAGE_REPORTER.exec(process.env.NODE_OPTIONS ?? "")?.[0];
|
|
3804
|
-
if (extra === void 0 || typeof stated !== "string" || reporter === void 0) return extra;
|
|
3805
|
-
return stated.includes(reporter) ? extra : {
|
|
3806
|
-
...extra,
|
|
3807
|
-
NODE_OPTIONS: `${stated} ${reporter}`.trim()
|
|
3808
|
-
};
|
|
3809
|
-
}
|
|
3810
|
-
/** `null` overrides delete keys (`INIT_CWD: null`); a NODE_OPTIONS a chain states keeps the mutation reporter the run asked for. */
|
|
3811
|
-
function buildEnv(stated) {
|
|
3812
|
-
const extra = withCoverageReporter(stated);
|
|
3813
|
-
const unset = new Set(Object.entries(extra ?? {}).filter(([, value]) => value === null).map(([key]) => key));
|
|
3814
|
-
return Object.fromEntries(Object.entries({
|
|
3815
|
-
...process.env,
|
|
3816
|
-
INIT_CWD: void 0,
|
|
3817
|
-
...extra
|
|
3818
|
-
}).filter(([key]) => !unset.has(key)));
|
|
3819
|
-
}
|
|
3820
|
-
function terminate(child) {
|
|
3821
|
-
child.kill("SIGTERM");
|
|
3822
|
-
return setTimeout(() => {
|
|
3823
|
-
child.kill("SIGKILL");
|
|
3824
|
-
}, KILL_GRACE_MS);
|
|
3825
|
-
}
|
|
3826
|
-
/** invariant: with a pattern expected, even a clean exit is a failure; without one a clean exit is a success. */
|
|
3827
|
-
function exitCodeOf(code, waitFor) {
|
|
3828
|
-
if (waitFor !== void 0 && code === 0) return 1;
|
|
3829
|
-
return code ?? 1;
|
|
3830
|
-
}
|
|
3831
|
-
/** Resolves 0 when `waitFor` appears, the exit code when the process ends first, 124 at the timeout; the timeout timer is unref'd. */
|
|
3832
|
-
async function observeProcess(child, options) {
|
|
3833
|
-
const { waitFor } = options;
|
|
3834
|
-
return await new Promise((resolve) => {
|
|
3835
|
-
const output = {
|
|
3836
|
-
stderr: "",
|
|
3837
|
-
stdout: ""
|
|
3838
|
-
};
|
|
3839
|
-
let resolved = false;
|
|
3840
|
-
let exited = false;
|
|
3841
|
-
let killTimer;
|
|
3842
|
-
const timeoutTimer = setTimeout(() => {
|
|
3843
|
-
finish(TIMED_OUT);
|
|
3844
|
-
}, options.timeout ?? DEFAULT_WATCH_TIMEOUT);
|
|
3845
|
-
timeoutTimer.unref();
|
|
3846
|
-
function finish(exitCode) {
|
|
3847
|
-
if (resolved) return;
|
|
3848
|
-
resolved = true;
|
|
3849
|
-
clearTimeout(timeoutTimer);
|
|
3850
|
-
if (!exited) killTimer = terminate(child);
|
|
3851
|
-
resolve({
|
|
3852
|
-
exitCode,
|
|
3853
|
-
...output
|
|
3854
|
-
});
|
|
3855
|
-
}
|
|
3856
|
-
const collect = (stream) => (data) => {
|
|
3857
|
-
output[stream] += data.toString();
|
|
3858
|
-
if (waitFor !== void 0 && (output.stdout.includes(waitFor) || output.stderr.includes(waitFor))) finish(0);
|
|
3859
|
-
};
|
|
3860
|
-
child.stdout?.on("data", collect("stdout"));
|
|
3861
|
-
child.stderr?.on("data", collect("stderr"));
|
|
3862
|
-
child.on("exit", (code) => {
|
|
3863
|
-
exited = true;
|
|
3864
|
-
clearTimeout(killTimer);
|
|
3865
|
-
finish(exitCodeOf(code, waitFor));
|
|
3866
|
-
});
|
|
3867
|
-
});
|
|
3868
|
-
}
|
|
3869
|
-
/** Spawned, never `spawnSync`: the test's event loop keeps running while the binary does, so a server the test started (a collector, a stub) answers it. stdout and stderr are captured whatever the exit code, and many CLIs write banners to stderr. */
|
|
3870
|
-
var ExecAdapter = class {
|
|
3871
|
-
command;
|
|
3872
|
-
constructor(command) {
|
|
3873
|
-
this.command = command;
|
|
3874
|
-
}
|
|
3875
|
-
async exec(args, cwd, extraEnv, input) {
|
|
3876
|
-
const env = buildEnv(extraEnv);
|
|
3877
|
-
const child = spawn(`${this.command} ${args}`, [], {
|
|
3878
|
-
cwd,
|
|
3879
|
-
detached: SIGNALS_GROUPS,
|
|
3880
|
-
env,
|
|
3881
|
-
shell: true
|
|
3882
|
-
});
|
|
3883
|
-
onTestFinished(async () => {
|
|
3884
|
-
await endGroup(child);
|
|
3885
|
-
});
|
|
3886
|
-
return await new Promise((resolve) => {
|
|
3887
|
-
const output = {
|
|
3888
|
-
stderr: "",
|
|
3889
|
-
stdout: ""
|
|
3890
|
-
};
|
|
3891
|
-
let timedOut = false;
|
|
3892
|
-
const timer = input?.timeout === void 0 ? void 0 : setTimeout(() => {
|
|
3893
|
-
timedOut = true;
|
|
3894
|
-
endGroup(child);
|
|
3895
|
-
}, input.timeout);
|
|
3896
|
-
child.stdout.setEncoding("utf8").on("data", (data) => {
|
|
3897
|
-
output.stdout += data;
|
|
3898
|
-
});
|
|
3899
|
-
child.stderr.setEncoding("utf8").on("data", (data) => {
|
|
3900
|
-
output.stderr += data;
|
|
3901
|
-
});
|
|
3902
|
-
child.on("close", (code) => {
|
|
3903
|
-
clearTimeout(timer);
|
|
3904
|
-
resolve({
|
|
3905
|
-
exitCode: timedOut ? TIMED_OUT : code ?? 1,
|
|
3906
|
-
...output
|
|
3907
|
-
});
|
|
3908
|
-
});
|
|
3909
|
-
child.stdin.on("error", () => void 0);
|
|
3910
|
-
child.stdin.end(input?.stdin);
|
|
3911
|
-
});
|
|
3912
|
-
}
|
|
3913
|
-
async watch(args, cwd, options, extraEnv) {
|
|
3914
|
-
const env = buildEnv(extraEnv);
|
|
3915
|
-
const child = spawn(`${this.command} ${args}`, [], {
|
|
3916
|
-
cwd,
|
|
3917
|
-
detached: SIGNALS_GROUPS,
|
|
3918
|
-
env,
|
|
3919
|
-
shell: true,
|
|
3920
|
-
stdio: [
|
|
3921
|
-
"pipe",
|
|
3922
|
-
"pipe",
|
|
3923
|
-
"pipe"
|
|
3924
|
-
]
|
|
3925
|
-
});
|
|
3926
|
-
const kill = (signal) => signalGroup(child, signal);
|
|
3927
|
-
return await observeProcess({
|
|
3928
|
-
kill,
|
|
3929
|
-
on: child.on.bind(child),
|
|
3930
|
-
stderr: child.stderr,
|
|
3931
|
-
stdout: child.stdout
|
|
3932
|
-
}, options);
|
|
3933
|
-
}
|
|
3934
|
-
};
|
|
3935
|
-
//#endregion
|
|
3936
|
-
//#region src/specifications/cli/cli.handle.ts
|
|
3937
|
-
function dockerTestRunId() {
|
|
3938
|
-
return `t-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
|
3939
|
-
}
|
|
3940
|
-
async function startedBackend$1(declared, defaults, started) {
|
|
3941
|
-
if (declared === void 0) return {
|
|
3942
|
-
backend: null,
|
|
3943
|
-
defaults
|
|
3944
|
-
};
|
|
3945
|
-
const backend = new StubBackend({ port: declared.port });
|
|
3946
|
-
try {
|
|
3947
|
-
return {
|
|
3948
|
-
backend,
|
|
3949
|
-
defaults: {
|
|
3950
|
-
[declared.env]: await backend.start(),
|
|
3951
|
-
...defaults
|
|
3952
|
-
}
|
|
3953
|
-
};
|
|
3954
|
-
} catch (error) {
|
|
3955
|
-
await started?.stop();
|
|
3956
|
-
throw error;
|
|
3957
|
-
}
|
|
3958
|
-
}
|
|
3959
|
-
async function startCli(bin, options = {}) {
|
|
3960
|
-
const callerDir = getCallerDir();
|
|
3961
|
-
await registerMatchers();
|
|
3962
|
-
const root = resolveRoot(options.root, callerDir);
|
|
3963
|
-
const resolvedBin = resolveCommand(bin, root);
|
|
3964
|
-
const services = declaredServices(options.services);
|
|
3965
|
-
const databaseKeys = declaredDatabaseKeys(services);
|
|
3966
|
-
if (options.docker !== void 0) requireDockerDaemon();
|
|
3967
|
-
let started = null;
|
|
3968
|
-
if (Object.keys(services).length > 0) started = await startServices(services, root, { type: "none" });
|
|
3969
|
-
const { backend, defaults } = await startedBackend$1(options.backend, options.defaults, started);
|
|
3970
|
-
const dockerTestRunIdOf = options.docker === void 0 ? void 0 : dockerTestRunId();
|
|
3971
|
-
const declaredServicesOf = Object.keys(services).length > 0 ? services : void 0;
|
|
3972
|
-
const config = {
|
|
3973
|
-
backend: backend ?? void 0,
|
|
3974
|
-
command: new ExecAdapter(resolvedBin),
|
|
3975
|
-
database: started?.database,
|
|
3976
|
-
databaseKeys,
|
|
3977
|
-
databases: started?.databases,
|
|
3978
|
-
dockerConfig: options.docker,
|
|
3979
|
-
dockerTestRunId: dockerTestRunIdOf,
|
|
3980
|
-
defaultEnv: defaults,
|
|
3981
|
-
envSets: options.env,
|
|
3982
|
-
root,
|
|
3983
|
-
serveRegistry: options.serve,
|
|
3984
|
-
services: declaredServicesOf,
|
|
3985
|
-
transform: options.transform
|
|
3986
|
-
};
|
|
3704
|
+
//#region src/stubs/delivery/other-process/stub-backend.adapter.ts
|
|
3705
|
+
/** Each chain's stubs replace the previous chain's, even when it declares none; a runner without a backend refuses `.intercept()`. */
|
|
3706
|
+
function stubBackend(backend, specification) {
|
|
3987
3707
|
return {
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3708
|
+
accepts: () => {
|
|
3709
|
+
if (backend === void 0) {
|
|
3710
|
+
const lesson = {
|
|
3711
|
+
fix: `add \`backend\` to the specification options: specification.${specification}(${specification === "cli" ? "bin, " : ""}{ ..., backend: { env: 'API_URL' } })`,
|
|
3712
|
+
found: ".intercept(), and this runner has no declared backend",
|
|
3713
|
+
why: "A site, an app or a binary is served its stubs by a declared stub backend, the URL it reads in place of its API."
|
|
3714
|
+
};
|
|
3715
|
+
throw new Error(refusal$1(specification === "mobile" ? {
|
|
3716
|
+
...lesson,
|
|
3717
|
+
read: "docs/guides/mobile-specs.md#declared-backend"
|
|
3718
|
+
} : specification === "cli" ? {
|
|
3719
|
+
...lesson,
|
|
3720
|
+
read: "docs/guides/cli-specs.md#declared-backend"
|
|
3721
|
+
} : {
|
|
3722
|
+
...lesson,
|
|
3723
|
+
read: "docs/guides/website-specs.md#declared-backend"
|
|
3724
|
+
}));
|
|
3725
|
+
}
|
|
3726
|
+
},
|
|
3727
|
+
arm: async (stubs) => {
|
|
3728
|
+
if (backend === void 0) return {};
|
|
3729
|
+
backend.beginChain(stubs);
|
|
3730
|
+
return { violation: () => backend.violation() };
|
|
3731
|
+
}
|
|
3995
3732
|
};
|
|
3996
3733
|
}
|
|
3997
3734
|
//#endregion
|
|
3998
|
-
//#region src/
|
|
3999
|
-
/**
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
3735
|
+
//#region src/chain/paths/scratch.ts
|
|
3736
|
+
/** Removed after the test's `afterEach`; outside a test or under `test.concurrent` it lives until the run's root is removed. */
|
|
3737
|
+
function scratchDirectory(prefix) {
|
|
3738
|
+
const directory = mkdtempSync(resolve(tmpdir(), prefix));
|
|
3739
|
+
const current = TestRunner.getCurrentTest();
|
|
3740
|
+
if (current === void 0 || current.concurrent === true) return directory;
|
|
3741
|
+
onTestFinished(() => {
|
|
3742
|
+
rmSync(directory, {
|
|
3743
|
+
force: true,
|
|
3744
|
+
recursive: true
|
|
3745
|
+
});
|
|
3746
|
+
});
|
|
3747
|
+
return directory;
|
|
3748
|
+
}
|
|
3749
|
+
//#endregion
|
|
3750
|
+
//#region src/specifications/cli/process/env.capability.ts
|
|
3751
|
+
/** Chained calls merge, a later one winning; nothing to arm. */
|
|
3752
|
+
const env = defineCapability({
|
|
3753
|
+
context: "env",
|
|
3754
|
+
initial: {},
|
|
3755
|
+
name: "env",
|
|
4003
3756
|
phase: 0,
|
|
4004
|
-
reduce: (
|
|
3757
|
+
reduce: (merged, [added]) => ({
|
|
3758
|
+
...merged,
|
|
3759
|
+
...added
|
|
3760
|
+
}),
|
|
4005
3761
|
row: {
|
|
4006
3762
|
group: "Setup",
|
|
4007
|
-
name: ".
|
|
4008
|
-
probe: ".
|
|
3763
|
+
name: ".env()",
|
|
3764
|
+
probe: ".env("
|
|
4009
3765
|
}
|
|
4010
3766
|
});
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
const
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
probe: "result.response"
|
|
4017
|
-
}, {
|
|
4018
|
-
group: "Result accessor",
|
|
4019
|
-
name: ".tls",
|
|
4020
|
-
probe: "result.tls"
|
|
4021
|
-
}];
|
|
4022
|
-
/** What one exchange with a deployment hands back: the response, or why none came, and the certificate it was presented. */
|
|
4023
|
-
var ExchangeResult = class extends BaseResult {
|
|
4024
|
-
answer;
|
|
4025
|
-
recording;
|
|
4026
|
-
/** The certificate an https exchange was presented and the trust check's verdict; undefined over http, or when no handshake completed. */
|
|
4027
|
-
tls;
|
|
4028
|
-
constructor(options) {
|
|
4029
|
-
super(options);
|
|
4030
|
-
this.answer = options.response;
|
|
4031
|
-
this.recording = options.recording;
|
|
4032
|
-
this.tls = options.tls;
|
|
4033
|
-
}
|
|
4034
|
-
/** The whole response (status, headers, body), or `unreachable: <reason>`, compared whole: `toMatch(<.http text>)` or `toMatch(file(...))`. */
|
|
4035
|
-
get response() {
|
|
4036
|
-
return new ResponseAccessor(this.answer, this.testDir, this.captures, this.recording);
|
|
4037
|
-
}
|
|
4038
|
-
};
|
|
4039
|
-
//#endregion
|
|
4040
|
-
//#region src/specifications/deployment/exchange/target.ts
|
|
4041
|
-
const SCHEME = /^[a-z][a-z\d+.-]*:\/\//iu;
|
|
4042
|
-
const BRACKETED = /^\[(?<host>[^\]]+)\](?::(?<port>\d+))?$/u;
|
|
4043
|
-
const HOST_AND_PORT = /^(?<host>[^:[\]\s]+)(?::(?<port>\d+))?$/u;
|
|
4044
|
-
/** The host a URL names, an IPv6 literal without its brackets. */
|
|
4045
|
-
function hostOf(url) {
|
|
4046
|
-
return url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
|
|
4047
|
-
}
|
|
4048
|
-
/** The port a URL names, or its scheme's. */
|
|
4049
|
-
function portOf(url) {
|
|
4050
|
-
if (url.port !== "") return Number(url.port);
|
|
4051
|
-
return url.protocol === "https:" ? 443 : 80;
|
|
3767
|
+
/** The resolved directory, never the raw temp path: on macOS `/var/folders/...` would never match `{{workdir}}`'s `/private/var/folders/...`. */
|
|
3768
|
+
function expandWorkdir(variables, workdir) {
|
|
3769
|
+
const expanded = {};
|
|
3770
|
+
for (const [key, value] of Object.entries(variables)) expanded[key] = typeof value === "string" ? value.replaceAll("$WORKDIR", workdir) : value;
|
|
3771
|
+
return expanded;
|
|
4052
3772
|
}
|
|
4053
|
-
function
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
found: `${JSON.stringify(target)}, ${found}`,
|
|
4057
|
-
read: "docs/guides/deployment-specs.md#the-target",
|
|
4058
|
-
why: "An exchange goes to one absolute http(s) URL: written whole, or a path from the `url` the specification names."
|
|
4059
|
-
}));
|
|
3773
|
+
function onlyOf(handles) {
|
|
3774
|
+
const [only, ...others] = handles;
|
|
3775
|
+
return others.length === 0 ? only : void 0;
|
|
4060
3776
|
}
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
3777
|
+
function serviceEnv(services) {
|
|
3778
|
+
const injected = {};
|
|
3779
|
+
const sql = [];
|
|
3780
|
+
const redis = [];
|
|
3781
|
+
for (const [key, handle] of Object.entries(services ?? {})) {
|
|
3782
|
+
injected[`${toConstantCase(key)}_URL`] = handle.connectionString;
|
|
3783
|
+
if (handle.createDatabaseAdapter() !== null) sql.push(handle);
|
|
3784
|
+
if (handle.type === "redis") redis.push(handle);
|
|
4067
3785
|
}
|
|
4068
|
-
|
|
4069
|
-
if (
|
|
4070
|
-
|
|
3786
|
+
const onlySql = onlyOf(sql);
|
|
3787
|
+
if (onlySql !== void 0) injected.DATABASE_URL = onlySql.connectionString;
|
|
3788
|
+
const onlyRedis = onlyOf(redis);
|
|
3789
|
+
if (onlyRedis !== void 0) injected.REDIS_URL = onlyRedis.connectionString;
|
|
3790
|
+
return injected;
|
|
4071
3791
|
}
|
|
4072
|
-
/**
|
|
4073
|
-
function
|
|
4074
|
-
const
|
|
4075
|
-
const
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
3792
|
+
/** Merge order: `defaults`, services' URLs, docker run id, then the chain's `.env()`; `undefined` when nothing is set. */
|
|
3793
|
+
function childEnv(config, chained, workdir) {
|
|
3794
|
+
const declared = config.defaultEnv;
|
|
3795
|
+
const services = config.services === void 0 || Object.keys(config.services).length === 0 ? void 0 : serviceEnv(config.services);
|
|
3796
|
+
let merged = declared ? {
|
|
3797
|
+
...expandWorkdir(declared, workdir),
|
|
3798
|
+
...services
|
|
3799
|
+
} : services;
|
|
3800
|
+
const { dockerConfig, dockerTestRunId } = config;
|
|
3801
|
+
if (dockerConfig && dockerTestRunId !== void 0 && dockerTestRunId !== "") merged = {
|
|
3802
|
+
...merged,
|
|
3803
|
+
[dockerConfig.envVar]: dockerTestRunId
|
|
3804
|
+
};
|
|
3805
|
+
if (Object.keys(chained).length > 0) merged = {
|
|
3806
|
+
...merged,
|
|
3807
|
+
...expandWorkdir(chained, workdir)
|
|
4087
3808
|
};
|
|
3809
|
+
return merged;
|
|
4088
3810
|
}
|
|
4089
3811
|
//#endregion
|
|
4090
|
-
//#region
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
return Array.isArray(value) ? value[0] : value;
|
|
3812
|
+
//#region lint/kit/project/spec-update.ts
|
|
3813
|
+
function keyOf(pair) {
|
|
3814
|
+
return String(isScalar(pair.key) ? pair.key.value : pair.key);
|
|
4094
3815
|
}
|
|
4095
|
-
function
|
|
4096
|
-
|
|
3816
|
+
function setOrdered(map, key, value, order) {
|
|
3817
|
+
const later = new Set(order.slice(order.indexOf(key) + 1));
|
|
3818
|
+
const at = map.items.findIndex((pair) => later.has(keyOf(pair)));
|
|
3819
|
+
const isNew = !map.has(key);
|
|
3820
|
+
map.set(key, value);
|
|
3821
|
+
if (isNew && at !== -1) map.items.splice(at, 0, ...map.items.splice(-1));
|
|
4097
3822
|
}
|
|
4098
|
-
/**
|
|
4099
|
-
function
|
|
4100
|
-
const
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
3823
|
+
/** The YAML document is edited, not re-emitted, so everything but exit codes and streams comes back byte-identical. */
|
|
3824
|
+
function updateSpecFile(file, updates) {
|
|
3825
|
+
const root = file.source.document.contents;
|
|
3826
|
+
const runs = isMap(root) ? root.get("runs", true) : void 0;
|
|
3827
|
+
if (!isSeq(runs)) return renderYamlSource(file.source);
|
|
3828
|
+
for (const [index, update] of updates.entries()) {
|
|
3829
|
+
const run = runs.items[index];
|
|
3830
|
+
if (!isMap(run)) continue;
|
|
3831
|
+
setOrdered(run, "exit", update.exitCode, RUN_KEYS);
|
|
3832
|
+
for (const [key, text] of [["stdout", update.stdout], ["stderr", update.stderr]]) {
|
|
3833
|
+
if (text.length === 0) {
|
|
3834
|
+
run.delete(key);
|
|
3835
|
+
continue;
|
|
3836
|
+
}
|
|
3837
|
+
setOrdered(run, key, blockScalar(text), RUN_KEYS);
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
return renderYamlSource(file.source);
|
|
4113
3841
|
}
|
|
4114
3842
|
//#endregion
|
|
4115
|
-
//#region src/specifications/
|
|
4116
|
-
/**
|
|
4117
|
-
|
|
4118
|
-
const
|
|
4119
|
-
const
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
};
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
3843
|
+
//#region src/specifications/cli/documents/literate-env.ts
|
|
3844
|
+
/** Every child gets `TEST_WORKDIR`, the resolved working directory, with the fixtures already on disk; the document's own env wins. */
|
|
3845
|
+
async function startServers(document, config, displayPath, workdir) {
|
|
3846
|
+
const registry = config.serveRegistry ?? {};
|
|
3847
|
+
const started = [];
|
|
3848
|
+
const env = {};
|
|
3849
|
+
const stop = async () => {
|
|
3850
|
+
for (const adapter of started.toReversed()) await adapter.stop();
|
|
3851
|
+
};
|
|
3852
|
+
for (const entry of document.serve) {
|
|
3853
|
+
const registration = registry[entry.name];
|
|
3854
|
+
if (!registration) {
|
|
3855
|
+
await stop();
|
|
3856
|
+
const known = Object.keys(registry);
|
|
3857
|
+
throw new Error(refusal$1({
|
|
3858
|
+
fix: known.length === 0 ? "register it in the `serve` option of specification.cli()" : `serve a registered server: ${known.join(", ")}`,
|
|
3859
|
+
found: `${displayPath}:${entry.line}: serve: "${entry.name}" is not registered`,
|
|
3860
|
+
read: "docs/guides/cli-specs.md#registration-once-per-app",
|
|
3861
|
+
why: "A document serves what the app registered once, in the options of specification.cli()."
|
|
3862
|
+
}));
|
|
3863
|
+
}
|
|
3864
|
+
const adapter = new ServedProcess({
|
|
3865
|
+
command: registration.command,
|
|
3866
|
+
ready: registration.ready
|
|
3867
|
+
}, config.root ?? process.cwd(), "cli", {
|
|
3868
|
+
TEST_WORKDIR: workdir,
|
|
3869
|
+
...entry.env
|
|
3870
|
+
});
|
|
3871
|
+
started.push(adapter);
|
|
3872
|
+
try {
|
|
3873
|
+
await adapter.start();
|
|
3874
|
+
} catch (error) {
|
|
3875
|
+
await stop();
|
|
3876
|
+
throw error;
|
|
3877
|
+
}
|
|
3878
|
+
if (adapter.port === null) throw invariant("a served process answered its readiness without a port");
|
|
3879
|
+
env[registration.env] = registration.url(adapter.port);
|
|
3880
|
+
}
|
|
3881
|
+
return {
|
|
3882
|
+
env,
|
|
3883
|
+
stop
|
|
3884
|
+
};
|
|
4148
3885
|
}
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
3886
|
+
function documentEnv(document, config, displayPath) {
|
|
3887
|
+
const sets = config.envSets ?? {};
|
|
3888
|
+
const env = {};
|
|
3889
|
+
for (const token of document.env) {
|
|
3890
|
+
if (token.kind === "set") {
|
|
3891
|
+
const declared = sets[token.name];
|
|
3892
|
+
if (!declared) {
|
|
3893
|
+
const known = Object.keys(sets);
|
|
3894
|
+
throw new Error(refusal$1({
|
|
3895
|
+
fix: known.length === 0 ? "register it in the `env` option of specification.cli(), or write it inline: KEY=value" : `name a registered set: ${known.join(", ")}, or write it inline: KEY=value`,
|
|
3896
|
+
found: `${displayPath}:${token.line}: env: "${token.name}" is not a registered env set`,
|
|
3897
|
+
read: "docs/guides/cli-specs.md#registration-once-per-app",
|
|
3898
|
+
why: "A bare word under `env:` names a set the app registered once, in the options of specification.cli()."
|
|
3899
|
+
}));
|
|
3900
|
+
}
|
|
3901
|
+
Object.assign(env, declared);
|
|
3902
|
+
continue;
|
|
3903
|
+
}
|
|
3904
|
+
env[token.key] = token.value;
|
|
3905
|
+
}
|
|
3906
|
+
return env;
|
|
4152
3907
|
}
|
|
4153
3908
|
//#endregion
|
|
4154
|
-
//#region src/specifications/
|
|
4155
|
-
|
|
4156
|
-
function
|
|
4157
|
-
|
|
3909
|
+
//#region src/specifications/cli/documents/literate-judge.ts
|
|
3910
|
+
/** ANSI stripped (`no-transform-duplicating-tokens`) and `transform` applied, nothing else: a block scalar states its own final newline. */
|
|
3911
|
+
function comparable(raw, transform) {
|
|
3912
|
+
const stripped = stripAnsiCodes(raw);
|
|
3913
|
+
return transform ? transform(stripped) : stripped;
|
|
4158
3914
|
}
|
|
4159
|
-
function
|
|
4160
|
-
|
|
4161
|
-
host: sent.url.host,
|
|
4162
|
-
"user-agent": USER_AGENT
|
|
4163
|
-
};
|
|
4164
|
-
for (const [name, value] of Object.entries(sent.headers)) headers[name.toLowerCase()] = value;
|
|
4165
|
-
return headers;
|
|
3915
|
+
function tokenAwareEquals(scope) {
|
|
3916
|
+
return (expected, actual) => textEquals(expected, actual, new CaptureScope(scope.workdir));
|
|
4166
3917
|
}
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
3918
|
+
function resolveUnderWorkDir(path, context) {
|
|
3919
|
+
const { workDir } = context;
|
|
3920
|
+
const target = resolve(workDir, path);
|
|
3921
|
+
const inside = relative(workDir, target);
|
|
3922
|
+
if (isAbsolute(path) || inside.startsWith("..") || isAbsolute(inside)) throw new Error(refusal$1({
|
|
3923
|
+
fix: "name a relative path under the working directory",
|
|
3924
|
+
found: `${context.displayPath}: files: "${path}" leaves the working directory`,
|
|
3925
|
+
read: "docs/guides/spec-documents.md#files-what-the-run-left-behind",
|
|
3926
|
+
why: "A `files:` key is a relative path under the working directory."
|
|
3927
|
+
}));
|
|
3928
|
+
return target;
|
|
4174
3929
|
}
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
host: address.host,
|
|
4180
|
-
port: address.port,
|
|
4181
|
-
rejectUnauthorized: false,
|
|
4182
|
-
...route.ca === void 0 ? {} : { ca: [...getCACertificates("default"), route.ca] },
|
|
4183
|
-
...isIP(name) === 0 ? { servername: name } : {}
|
|
4184
|
-
});
|
|
4185
|
-
hold(socket);
|
|
3930
|
+
function preview(text) {
|
|
3931
|
+
return JSON.stringify(text.length > 160 ? `${text.slice(0, 160)}...` : text);
|
|
3932
|
+
}
|
|
3933
|
+
function readIfPresent(path) {
|
|
4186
3934
|
try {
|
|
4187
|
-
|
|
4188
|
-
} catch
|
|
4189
|
-
return
|
|
4190
|
-
tls: void 0,
|
|
4191
|
-
unreachable: unreachableReason(error, true)
|
|
4192
|
-
};
|
|
3935
|
+
return statSync(path).isFile() ? readFileSync(path, "utf8") : null;
|
|
3936
|
+
} catch {
|
|
3937
|
+
return null;
|
|
4193
3938
|
}
|
|
4194
|
-
const refused = socket.authorized ? void 0 : codeOf(socket.authorizationError);
|
|
4195
|
-
const tls = tlsFacts(socket.getPeerCertificate(), {
|
|
4196
|
-
authorizationError: refused,
|
|
4197
|
-
protocol: socket.getProtocol() ?? void 0
|
|
4198
|
-
}, Date.now());
|
|
4199
|
-
return refused === void 0 || route.insecure ? {
|
|
4200
|
-
socket,
|
|
4201
|
-
tls
|
|
4202
|
-
} : {
|
|
4203
|
-
tls,
|
|
4204
|
-
unreachable: refusedCertificate(refused)
|
|
4205
|
-
};
|
|
4206
3939
|
}
|
|
4207
|
-
|
|
4208
|
-
const
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
return {
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
3940
|
+
function checkFile(assertion, context) {
|
|
3941
|
+
const { scope } = context;
|
|
3942
|
+
const target = resolveUnderWorkDir(assertion.path, context);
|
|
3943
|
+
let exists;
|
|
3944
|
+
try {
|
|
3945
|
+
exists = statSync(target) !== void 0;
|
|
3946
|
+
} catch {
|
|
3947
|
+
exists = false;
|
|
3948
|
+
}
|
|
3949
|
+
if (assertion.kind === "absent") return exists ? `${assertion.path}: expected absent, got a file` : null;
|
|
3950
|
+
if (!exists) return `${assertion.path}: expected to exist, got nothing at that path`;
|
|
3951
|
+
if (assertion.kind === "exists") return null;
|
|
3952
|
+
const content = readIfPresent(target);
|
|
3953
|
+
if (content === null) return `${assertion.path}: expected a readable file, got a directory`;
|
|
3954
|
+
for (const needle of assertion.contains) if (!textContains(needle.text, content, scope)) return `${assertion.path}: expected to contain ${preview(needle.text)}, got ${preview(content)}`;
|
|
3955
|
+
if (assertion.equals !== null && !textEquals(assertion.equals.text, content, scope)) return `${assertion.path}: expected ${preview(assertion.equals.text)}, got ${preview(content)}`;
|
|
3956
|
+
return null;
|
|
4220
3957
|
}
|
|
4221
|
-
|
|
4222
|
-
return
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
text: Buffer.concat(chunks).toString("utf8")
|
|
4237
|
-
});
|
|
4238
|
-
});
|
|
4239
|
-
response.once("error", reject);
|
|
4240
|
-
});
|
|
4241
|
-
outgoing.once("error", reject);
|
|
4242
|
-
outgoing.end(sent.body);
|
|
4243
|
-
});
|
|
3958
|
+
function failure(run, context, details) {
|
|
3959
|
+
return atLine(new Error(refusal$1({
|
|
3960
|
+
fix: "correct the command, or run with TEST_UPDATE=1 to rewrite the runs: the exit code and the streams, and nothing else",
|
|
3961
|
+
found: [
|
|
3962
|
+
`spec mismatch: ${context.document.description}`,
|
|
3963
|
+
"",
|
|
3964
|
+
`$ ${run.command}`,
|
|
3965
|
+
"",
|
|
3966
|
+
details.join("\n\n"),
|
|
3967
|
+
"",
|
|
3968
|
+
`${context.displayPath}:${run.commandLine}`
|
|
3969
|
+
].join("\n"),
|
|
3970
|
+
read: "docs/guides/spec-documents.md#failure-and-update",
|
|
3971
|
+
why: "A spec document states what its runs produce: the exit code and the streams, whole."
|
|
3972
|
+
})), context.filePath, run.commandLine);
|
|
4244
3973
|
}
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
if (!("socket" in opening)) return opening;
|
|
4249
|
-
const { response, text } = await exchanged(sent, opening.socket);
|
|
4250
|
-
return {
|
|
4251
|
-
headers: flatHeaders(response.headers),
|
|
4252
|
-
status: response.statusCode ?? 0,
|
|
4253
|
-
text,
|
|
4254
|
-
tls: opening.tls
|
|
4255
|
-
};
|
|
3974
|
+
function atLine(error, filePath, line) {
|
|
3975
|
+
error.stack = `${error.name}: ${error.message}\n at ${filePath}:${line}:1`;
|
|
3976
|
+
return error;
|
|
4256
3977
|
}
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
3978
|
+
function assertRun(outcome, context) {
|
|
3979
|
+
const { actual, run } = outcome;
|
|
3980
|
+
const { scope } = context;
|
|
3981
|
+
if (actual.exitCode !== run.exitCode) throw failure(run, context, [[
|
|
3982
|
+
"exit code mismatch",
|
|
3983
|
+
` expected: ${run.exitCode}`,
|
|
3984
|
+
` received: ${actual.exitCode}`,
|
|
3985
|
+
...outcome.stderr.length > 0 ? [
|
|
3986
|
+
"",
|
|
3987
|
+
"stderr was:",
|
|
3988
|
+
outcome.stderr.replace(/\n$/u, "")
|
|
3989
|
+
] : []
|
|
3990
|
+
].join("\n")]);
|
|
3991
|
+
const equals = tokenAwareEquals(scope);
|
|
3992
|
+
const details = [];
|
|
3993
|
+
for (const [name, expected, received] of [[
|
|
3994
|
+
"stdout",
|
|
3995
|
+
run.stdout?.text ?? "",
|
|
3996
|
+
outcome.stdout
|
|
3997
|
+
], [
|
|
3998
|
+
"stderr",
|
|
3999
|
+
run.stderr?.text ?? "",
|
|
4000
|
+
outcome.stderr
|
|
4001
|
+
]]) if (!textEquals(expected, received, scope)) details.push(formatStdoutDiff(name, expected, received, { equals }));
|
|
4002
|
+
details.push(...filesDetail(outcome, context));
|
|
4003
|
+
if (details.length > 0) throw failure(run, context, details);
|
|
4004
|
+
}
|
|
4005
|
+
function filesDetail({ run }, context) {
|
|
4006
|
+
const fileFailures = run.files.map((assertion) => checkFile(assertion, context)).filter((line) => line !== null);
|
|
4007
|
+
return fileFailures.length === 0 ? [] : [[
|
|
4008
|
+
"files mismatch",
|
|
4009
|
+
"",
|
|
4010
|
+
...fileFailures.map((line) => ` ${line}`)
|
|
4011
|
+
].join("\n")];
|
|
4012
|
+
}
|
|
4013
|
+
/** The `files:` of a run, judged alone: update mode rewrites a run's exit code and streams, never what the run was asked to leave on disk. */
|
|
4014
|
+
function assertFiles(outcome, context) {
|
|
4015
|
+
const details = filesDetail(outcome, context);
|
|
4016
|
+
if (details.length > 0) throw failure(outcome.run, context, details);
|
|
4017
|
+
}
|
|
4018
|
+
/** Placeholders of the previous golden preserved by pattern (`no-volatile-literal`); an emptied stream loses its key. */
|
|
4019
|
+
function updates(outcomes, scope) {
|
|
4020
|
+
return outcomes.map(({ actual, run, stderr, stdout }) => ({
|
|
4021
|
+
exitCode: actual.exitCode,
|
|
4022
|
+
stderr: mergeTextPreservingPlaceholders(run.stderr?.text ?? null, stderr, scope),
|
|
4023
|
+
stdout: mergeTextPreservingPlaceholders(run.stdout?.text ?? null, stdout, scope)
|
|
4024
|
+
}));
|
|
4025
|
+
}
|
|
4026
|
+
//#endregion
|
|
4027
|
+
//#region src/specifications/cli/documents/literate.ts
|
|
4028
|
+
function readDocument(filePath, displayPath) {
|
|
4270
4029
|
try {
|
|
4271
|
-
return
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
tls = facts;
|
|
4275
|
-
}), deadline]);
|
|
4276
|
-
} catch (error) {
|
|
4277
|
-
return {
|
|
4278
|
-
tls,
|
|
4279
|
-
unreachable: unreachableReason(error)
|
|
4280
|
-
};
|
|
4281
|
-
} finally {
|
|
4282
|
-
clearTimeout(timer);
|
|
4283
|
-
socket?.destroy();
|
|
4030
|
+
return readSpecFile(readFileSync(filePath, "utf8"), displayPath);
|
|
4031
|
+
} catch (error) {
|
|
4032
|
+
throw error instanceof SpecSyntaxError ? atLine(grammarRefusal(error, displayPath), filePath, error.line) : error;
|
|
4284
4033
|
}
|
|
4285
4034
|
}
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
const
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
};
|
|
4296
|
-
const contentType = answer.headers["content-type"];
|
|
4035
|
+
async function execute(run, session) {
|
|
4036
|
+
const { command, env, transform, workDir } = session;
|
|
4037
|
+
const timeout = run.timeout ?? void 0;
|
|
4038
|
+
const actual = run.waitFor === null ? await command.exec(run.command, workDir, env, {
|
|
4039
|
+
stdin: run.stdin ?? void 0,
|
|
4040
|
+
timeout
|
|
4041
|
+
}) : await command.watch(run.command, workDir, {
|
|
4042
|
+
timeout,
|
|
4043
|
+
waitFor: run.waitFor
|
|
4044
|
+
}, env);
|
|
4297
4045
|
return {
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4046
|
+
actual,
|
|
4047
|
+
run,
|
|
4048
|
+
stderr: comparable(actual.stderr, transform),
|
|
4049
|
+
stdout: comparable(actual.stdout, transform)
|
|
4301
4050
|
};
|
|
4302
4051
|
}
|
|
4303
|
-
|
|
4304
|
-
const
|
|
4305
|
-
const
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
...written.headers,
|
|
4310
|
-
...read(headers)
|
|
4311
|
-
},
|
|
4312
|
-
method: written.method,
|
|
4313
|
-
url
|
|
4314
|
-
}, {
|
|
4315
|
-
ca: config.ca,
|
|
4316
|
-
connect: address === null ? void 0 : connectAddress(address, url),
|
|
4317
|
-
insecure: config.insecure,
|
|
4318
|
-
timeout: config.timeout
|
|
4319
|
-
});
|
|
4320
|
-
return new ExchangeResult({
|
|
4052
|
+
function lastResult(outcomes, options) {
|
|
4053
|
+
const { config, testDir, workDir } = options;
|
|
4054
|
+
const lastOutcome = outcomes.at(-1);
|
|
4055
|
+
if (lastOutcome === void 0) throw invariant("a document ran no command, though its runs: holds at least one");
|
|
4056
|
+
return new CliResult({
|
|
4057
|
+
commandOutput: lastOutcome.actual,
|
|
4321
4058
|
config,
|
|
4322
|
-
|
|
4323
|
-
response: responseOf(answer),
|
|
4059
|
+
dockerConfig: config.dockerConfig,
|
|
4324
4060
|
testDir,
|
|
4325
|
-
|
|
4061
|
+
testRunId: config.dockerTestRunId,
|
|
4062
|
+
transform: config.transform,
|
|
4063
|
+
workDir
|
|
4326
4064
|
});
|
|
4327
4065
|
}
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4066
|
+
/** Resolves with the last run's result. Under `TEST_UPDATE=1` every run executes and is rewritten; its `files:` are still judged. */
|
|
4067
|
+
async function runSpecDocument(options) {
|
|
4068
|
+
const { config, displayPath, filePath, workDir } = options;
|
|
4069
|
+
const { command } = config;
|
|
4070
|
+
if (!command) throw invariant(".run() reached a chain with no command adapter");
|
|
4071
|
+
const file = readDocument(filePath, displayPath);
|
|
4072
|
+
const { document } = file;
|
|
4073
|
+
const fileDir = filePath.replace(/[/\\][^/\\]*$/u, "");
|
|
4074
|
+
for (const fixture of document.fixtures) fsFixtures.layer(fixture.path, fileDir, workDir);
|
|
4075
|
+
const workdir = safeRealpath(workDir);
|
|
4076
|
+
const servers = await startServers(document, config, displayPath, workdir);
|
|
4077
|
+
const scope = new CaptureScope(workdir);
|
|
4078
|
+
const env = expandWorkdir({
|
|
4079
|
+
...options.baseEnv,
|
|
4080
|
+
...servers.env,
|
|
4081
|
+
...documentEnv(document, config, displayPath)
|
|
4082
|
+
}, workdir);
|
|
4083
|
+
const judgement = {
|
|
4084
|
+
displayPath,
|
|
4085
|
+
document,
|
|
4086
|
+
filePath,
|
|
4087
|
+
scope,
|
|
4088
|
+
workDir
|
|
4089
|
+
};
|
|
4090
|
+
const session = {
|
|
4091
|
+
command,
|
|
4092
|
+
env,
|
|
4093
|
+
transform: config.transform,
|
|
4094
|
+
workDir
|
|
4335
4095
|
};
|
|
4096
|
+
const rewriting = shouldUpdateSnapshots() && options.frozen !== true;
|
|
4097
|
+
const outcomes = [];
|
|
4098
|
+
try {
|
|
4099
|
+
for (const run of document.runs) {
|
|
4100
|
+
const outcome = await execute(run, session);
|
|
4101
|
+
outcomes.push(outcome);
|
|
4102
|
+
if (rewriting) assertFiles(outcome, judgement);
|
|
4103
|
+
else assertRun(outcome, judgement);
|
|
4104
|
+
}
|
|
4105
|
+
} finally {
|
|
4106
|
+
await servers.stop();
|
|
4107
|
+
}
|
|
4108
|
+
if (rewriting) writeFileSync(filePath, updateSpecFile(file, updates(outcomes, scope)));
|
|
4109
|
+
return lastResult(outcomes, options);
|
|
4336
4110
|
}
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4111
|
+
//#endregion
|
|
4112
|
+
//#region src/specifications/cli/documents/run.action.ts
|
|
4113
|
+
/** The chain's fixture is laid first, the document's `fixture:` over it; its `env:` wins over a chained `.env()`. */
|
|
4114
|
+
const run$1 = defineAction({
|
|
4115
|
+
accessors: CLI_RESULT_ROWS,
|
|
4116
|
+
name: "run",
|
|
4117
|
+
perform: async ([document, options], read, _adapters, { config, testDir, workDir }) => {
|
|
4118
|
+
const cwd = workDir;
|
|
4119
|
+
const filePath = resolveSpecPath("run", document.path, testDir);
|
|
4120
|
+
return await runSpecDocument({
|
|
4121
|
+
baseEnv: childEnv(config, read(env), safeRealpath(cwd)),
|
|
4122
|
+
config,
|
|
4123
|
+
displayPath: relative(process.cwd(), filePath) || document.path,
|
|
4124
|
+
filePath,
|
|
4125
|
+
frozen: options?.frozen,
|
|
4126
|
+
testDir,
|
|
4127
|
+
workDir: cwd
|
|
4128
|
+
});
|
|
4129
|
+
},
|
|
4130
|
+
prepare: ([document]) => {
|
|
4131
|
+
requireDiskFile("run", document, ".spec.yaml");
|
|
4132
|
+
return { workDir: scratchDirectory("spec-command-") };
|
|
4133
|
+
},
|
|
4346
4134
|
row: {
|
|
4347
4135
|
group: "Terminal action",
|
|
4348
|
-
name: ".
|
|
4349
|
-
probe: ".
|
|
4136
|
+
name: ".run()",
|
|
4137
|
+
probe: ".run("
|
|
4350
4138
|
}
|
|
4351
4139
|
});
|
|
4352
4140
|
//#endregion
|
|
4353
|
-
//#region src/specifications/
|
|
4141
|
+
//#region src/specifications/cli/process/exec.action.ts
|
|
4142
|
+
function isLongRunning(options) {
|
|
4143
|
+
return options?.waitFor !== void 0 || options?.timeout !== void 0;
|
|
4144
|
+
}
|
|
4145
|
+
function requireRelativeFolder(cwd) {
|
|
4146
|
+
if (cwd === void 0) return;
|
|
4147
|
+
const inside = normalize(cwd);
|
|
4148
|
+
if (isAbsolute(cwd) || inside === ".." || inside.startsWith(`..${sep}`)) throw new Error(refusal$1({
|
|
4149
|
+
fix: "name a folder below the working directory, relative to it: .exec('check', { cwd: 'packages/site' })",
|
|
4150
|
+
found: `.exec(..., { cwd: ${JSON.stringify(cwd)} }), a folder outside the working directory`,
|
|
4151
|
+
read: "docs/guides/cli-specs.md#cwd-a-folder-of-the-working-directory",
|
|
4152
|
+
why: "A command runs in the working directory the chain laid, or in one of its folders: anywhere else is the machine, not the fixture."
|
|
4153
|
+
}));
|
|
4154
|
+
}
|
|
4155
|
+
function runFolder(root, cwd) {
|
|
4156
|
+
if (cwd === void 0) return root;
|
|
4157
|
+
const folder = resolve(root, cwd);
|
|
4158
|
+
if (statSync(folder, { throwIfNoEntry: false })?.isDirectory() !== true) throw new Error(refusal$1({
|
|
4159
|
+
fix: "lay the folder with a fixture, or name one the fixtures laid",
|
|
4160
|
+
found: `.exec(..., { cwd: ${JSON.stringify(cwd)} }), a folder the working directory does not hold`,
|
|
4161
|
+
read: "docs/guides/cli-specs.md#cwd-a-folder-of-the-working-directory",
|
|
4162
|
+
why: "A command runs in a folder the chain laid: one that does not exist has no files of the fixture."
|
|
4163
|
+
}));
|
|
4164
|
+
return folder;
|
|
4165
|
+
}
|
|
4166
|
+
//#endregion
|
|
4167
|
+
//#region src/specifications/cli/cli.composition.ts
|
|
4354
4168
|
const composition$5 = {
|
|
4355
4169
|
actions: [defineAction({
|
|
4356
|
-
accessors:
|
|
4357
|
-
name: "
|
|
4358
|
-
perform: async ([
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4170
|
+
accessors: CLI_RESULT_ROWS,
|
|
4171
|
+
name: "exec",
|
|
4172
|
+
perform: async ([args = "", options], read, { command }, { config, testDir, workDir }) => {
|
|
4173
|
+
const root = workDir;
|
|
4174
|
+
const cwd = runFolder(root, options?.cwd);
|
|
4175
|
+
const env$1 = childEnv(config, read(env), safeRealpath(root));
|
|
4176
|
+
let output;
|
|
4177
|
+
if (isLongRunning(options)) output = await command.watch(args, cwd, options, env$1);
|
|
4178
|
+
else if (Array.isArray(args)) {
|
|
4179
|
+
output = {
|
|
4180
|
+
exitCode: 0,
|
|
4181
|
+
stderr: "",
|
|
4182
|
+
stdout: ""
|
|
4183
|
+
};
|
|
4184
|
+
for (const one of args) {
|
|
4185
|
+
output = await command.exec(one, cwd, env$1);
|
|
4186
|
+
if (output.exitCode !== 0) break;
|
|
4187
|
+
}
|
|
4188
|
+
} else output = await command.exec(args, cwd, env$1);
|
|
4189
|
+
return new CliResult({
|
|
4190
|
+
commandOutput: output,
|
|
4191
|
+
config,
|
|
4192
|
+
dockerConfig: config.dockerConfig ?? void 0,
|
|
4193
|
+
requests: config.backend?.requests(),
|
|
4194
|
+
testDir,
|
|
4195
|
+
testRunId: config.dockerTestRunId ?? void 0,
|
|
4196
|
+
transform: config.transform,
|
|
4197
|
+
workDir: root
|
|
4198
|
+
});
|
|
4199
|
+
},
|
|
4200
|
+
prepare: ([args = "", options]) => {
|
|
4201
|
+
if (Array.isArray(args) && args.length === 0) throw new Error(refusal$1({
|
|
4202
|
+
fix: "name at least one command: .exec(['build', 'test'])",
|
|
4203
|
+
found: ".exec([]), a sequence of no command",
|
|
4204
|
+
read: "docs/guides/cli-specs.md#sequences-array-form",
|
|
4205
|
+
why: "A sequence runs its commands in order, and runs at least one."
|
|
4206
|
+
}));
|
|
4207
|
+
if (isLongRunning(options) && Array.isArray(args)) throw new Error(refusal$1({
|
|
4208
|
+
fix: "wait on one long-running command: .exec(args, { waitFor, timeout }), or drop the options",
|
|
4209
|
+
found: ".exec([...], { waitFor/timeout }), options on a command sequence",
|
|
4210
|
+
read: "docs/guides/cli-specs.md#long-running-processes-waitfor--timeout",
|
|
4211
|
+
why: "`waitFor` and `timeout` wait on one long-running process."
|
|
4212
|
+
}));
|
|
4213
|
+
requireRelativeFolder(options?.cwd);
|
|
4214
|
+
return { workDir: scratchDirectory("spec-command-") };
|
|
4362
4215
|
},
|
|
4363
4216
|
row: {
|
|
4364
4217
|
group: "Terminal action",
|
|
4365
|
-
name: ".
|
|
4366
|
-
probe: ".
|
|
4218
|
+
name: ".exec()",
|
|
4219
|
+
probe: ".exec("
|
|
4367
4220
|
}
|
|
4368
|
-
}),
|
|
4221
|
+
}), run$1],
|
|
4369
4222
|
capabilities: [
|
|
4370
|
-
|
|
4371
|
-
|
|
4223
|
+
env,
|
|
4224
|
+
fixture,
|
|
4225
|
+
intercept,
|
|
4226
|
+
link$1,
|
|
4227
|
+
prepare,
|
|
4228
|
+
seed,
|
|
4372
4229
|
sharing
|
|
4373
4230
|
],
|
|
4374
4231
|
options: {
|
|
4375
|
-
|
|
4232
|
+
backend: {
|
|
4233
|
+
group: "Constructor option",
|
|
4234
|
+
name: "backend",
|
|
4235
|
+
probe: "backend:"
|
|
4236
|
+
},
|
|
4237
|
+
commands: {
|
|
4238
|
+
group: "Constructor option",
|
|
4239
|
+
name: "commands",
|
|
4240
|
+
probe: "commands:"
|
|
4241
|
+
},
|
|
4242
|
+
defaults: {
|
|
4243
|
+
group: "Constructor option",
|
|
4244
|
+
name: "defaults",
|
|
4245
|
+
probe: "defaults:"
|
|
4246
|
+
},
|
|
4247
|
+
docker: {
|
|
4248
|
+
group: "Constructor option",
|
|
4249
|
+
name: "docker",
|
|
4250
|
+
probe: "docker:"
|
|
4251
|
+
},
|
|
4252
|
+
env: {
|
|
4376
4253
|
group: "Constructor option",
|
|
4377
|
-
name: "
|
|
4378
|
-
probe: "
|
|
4254
|
+
name: "env",
|
|
4255
|
+
probe: "env:"
|
|
4379
4256
|
},
|
|
4380
|
-
|
|
4257
|
+
root: {
|
|
4381
4258
|
group: "Constructor option",
|
|
4382
|
-
name: "
|
|
4383
|
-
probe: "
|
|
4259
|
+
name: "root",
|
|
4260
|
+
probe: "root:"
|
|
4384
4261
|
},
|
|
4385
|
-
|
|
4262
|
+
serve: {
|
|
4386
4263
|
group: "Constructor option",
|
|
4387
|
-
name: "
|
|
4388
|
-
probe: "
|
|
4264
|
+
name: "serve",
|
|
4265
|
+
probe: "serve:"
|
|
4389
4266
|
},
|
|
4390
|
-
|
|
4267
|
+
services: {
|
|
4391
4268
|
group: "Constructor option",
|
|
4392
|
-
name: "
|
|
4393
|
-
probe: "
|
|
4269
|
+
name: "services",
|
|
4270
|
+
probe: "services:"
|
|
4394
4271
|
},
|
|
4395
|
-
|
|
4272
|
+
transform: {
|
|
4396
4273
|
group: "Constructor option",
|
|
4397
|
-
name: "
|
|
4398
|
-
probe: "
|
|
4274
|
+
name: "transform",
|
|
4275
|
+
probe: "transform:"
|
|
4399
4276
|
}
|
|
4400
4277
|
}
|
|
4401
4278
|
};
|
|
4402
|
-
const
|
|
4279
|
+
const cliDefinition = defineSpecification()({
|
|
4403
4280
|
...composition$5,
|
|
4404
|
-
adapters: (
|
|
4281
|
+
adapters: (config) => ({
|
|
4282
|
+
command: config.command,
|
|
4283
|
+
databases: databaseSet(config),
|
|
4284
|
+
links: fsLinks,
|
|
4285
|
+
network: stubBackend(config.backend, "cli"),
|
|
4286
|
+
steps: inProcessSteps,
|
|
4287
|
+
workdir: fsFixtures
|
|
4288
|
+
}),
|
|
4405
4289
|
locate: getCallerDir
|
|
4406
4290
|
});
|
|
4407
4291
|
//#endregion
|
|
4408
|
-
//#region src/specifications/
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4292
|
+
//#region src/specifications/cli/process/command.ts
|
|
4293
|
+
/** An absolute path as is; else the project's `node_modules/.bin`, then the working directory's; else the bare name. */
|
|
4294
|
+
function resolveCommand(command, root) {
|
|
4295
|
+
return [root, process.cwd()].map((directory) => resolve(directory, "node_modules/.bin", command)).find((path) => existsSync(path)) ?? command;
|
|
4296
|
+
}
|
|
4297
|
+
//#endregion
|
|
4298
|
+
//#region src/specifications/cli/process/exec.adapter.ts
|
|
4299
|
+
const DEFAULT_WATCH_TIMEOUT = 1e4;
|
|
4300
|
+
const TIMED_OUT = 124;
|
|
4301
|
+
const COVERAGE_REPORTER = /--require="[^"]*child-coverage\.cjs"/u;
|
|
4302
|
+
function withCoverageReporter(extra) {
|
|
4303
|
+
const stated = extra?.NODE_OPTIONS;
|
|
4304
|
+
const reporter = COVERAGE_REPORTER.exec(process.env.NODE_OPTIONS ?? "")?.[0];
|
|
4305
|
+
if (extra === void 0 || typeof stated !== "string" || reporter === void 0) return extra;
|
|
4306
|
+
return stated.includes(reporter) ? extra : {
|
|
4307
|
+
...extra,
|
|
4308
|
+
NODE_OPTIONS: `${stated} ${reporter}`.trim()
|
|
4309
|
+
};
|
|
4310
|
+
}
|
|
4311
|
+
/** `null` overrides delete keys (`INIT_CWD: null`); a NODE_OPTIONS a chain states keeps the mutation reporter the run asked for. */
|
|
4312
|
+
function buildEnv(stated) {
|
|
4313
|
+
const extra = withCoverageReporter(stated);
|
|
4314
|
+
const unset = new Set(Object.entries(extra ?? {}).filter(([, value]) => value === null).map(([key]) => key));
|
|
4315
|
+
return Object.fromEntries(Object.entries({
|
|
4316
|
+
...process.env,
|
|
4317
|
+
INIT_CWD: void 0,
|
|
4318
|
+
...extra
|
|
4319
|
+
}).filter(([key]) => !unset.has(key)));
|
|
4320
|
+
}
|
|
4321
|
+
function terminate(child) {
|
|
4322
|
+
child.kill("SIGTERM");
|
|
4323
|
+
return setTimeout(() => {
|
|
4324
|
+
child.kill("SIGKILL");
|
|
4325
|
+
}, KILL_GRACE_MS);
|
|
4326
|
+
}
|
|
4327
|
+
/** invariant: with a pattern expected, even a clean exit is a failure; without one a clean exit is a success. */
|
|
4328
|
+
function exitCodeOf(code, waitFor) {
|
|
4329
|
+
if (waitFor !== void 0 && code === 0) return 1;
|
|
4330
|
+
return code ?? 1;
|
|
4331
|
+
}
|
|
4332
|
+
/** Resolves 0 when `waitFor` appears, the exit code when the process ends first, 124 at the timeout; the timeout timer is unref'd. */
|
|
4333
|
+
async function observeProcess(child, options) {
|
|
4334
|
+
const { waitFor } = options;
|
|
4335
|
+
return await new Promise((resolve) => {
|
|
4336
|
+
const output = {
|
|
4337
|
+
stderr: "",
|
|
4338
|
+
stdout: ""
|
|
4339
|
+
};
|
|
4340
|
+
let resolved = false;
|
|
4341
|
+
let exited = false;
|
|
4342
|
+
let killTimer;
|
|
4343
|
+
const timeoutTimer = setTimeout(() => {
|
|
4344
|
+
finish(TIMED_OUT);
|
|
4345
|
+
}, options.timeout ?? DEFAULT_WATCH_TIMEOUT);
|
|
4346
|
+
timeoutTimer.unref();
|
|
4347
|
+
function finish(exitCode) {
|
|
4348
|
+
if (resolved) return;
|
|
4349
|
+
resolved = true;
|
|
4350
|
+
clearTimeout(timeoutTimer);
|
|
4351
|
+
if (!exited) killTimer = terminate(child);
|
|
4352
|
+
resolve({
|
|
4353
|
+
exitCode,
|
|
4354
|
+
...output
|
|
4355
|
+
});
|
|
4356
|
+
}
|
|
4357
|
+
const collect = (stream) => (data) => {
|
|
4358
|
+
output[stream] += data.toString();
|
|
4359
|
+
if (waitFor !== void 0 && (output.stdout.includes(waitFor) || output.stderr.includes(waitFor))) finish(0);
|
|
4360
|
+
};
|
|
4361
|
+
child.stdout?.on("data", collect("stdout"));
|
|
4362
|
+
child.stderr?.on("data", collect("stderr"));
|
|
4363
|
+
child.on("exit", (code) => {
|
|
4364
|
+
exited = true;
|
|
4365
|
+
clearTimeout(killTimer);
|
|
4366
|
+
finish(exitCodeOf(code, waitFor));
|
|
4367
|
+
});
|
|
4368
|
+
});
|
|
4369
|
+
}
|
|
4370
|
+
/** Spawned, never `spawnSync`: the test's event loop keeps running while the binary does, so a server the test started (a collector, a stub) answers it. stdout and stderr are captured whatever the exit code, and many CLIs write banners to stderr. */
|
|
4371
|
+
var ExecAdapter = class {
|
|
4372
|
+
command;
|
|
4373
|
+
constructor(command) {
|
|
4374
|
+
this.command = command;
|
|
4375
|
+
}
|
|
4376
|
+
async exec(args, cwd, extraEnv, input) {
|
|
4377
|
+
const env = buildEnv(extraEnv);
|
|
4378
|
+
const child = spawn(`${this.command} ${args}`, [], {
|
|
4379
|
+
cwd,
|
|
4380
|
+
detached: SIGNALS_GROUPS,
|
|
4381
|
+
env,
|
|
4382
|
+
shell: true
|
|
4383
|
+
});
|
|
4384
|
+
onTestFinished(async () => {
|
|
4385
|
+
await endGroup(child);
|
|
4386
|
+
});
|
|
4387
|
+
return await new Promise((resolve) => {
|
|
4388
|
+
const output = {
|
|
4389
|
+
stderr: "",
|
|
4390
|
+
stdout: ""
|
|
4391
|
+
};
|
|
4392
|
+
let timedOut = false;
|
|
4393
|
+
const timer = input?.timeout === void 0 ? void 0 : setTimeout(() => {
|
|
4394
|
+
timedOut = true;
|
|
4395
|
+
endGroup(child);
|
|
4396
|
+
}, input.timeout);
|
|
4397
|
+
child.stdout.setEncoding("utf8").on("data", (data) => {
|
|
4398
|
+
output.stdout += data;
|
|
4399
|
+
});
|
|
4400
|
+
child.stderr.setEncoding("utf8").on("data", (data) => {
|
|
4401
|
+
output.stderr += data;
|
|
4402
|
+
});
|
|
4403
|
+
child.on("close", (code) => {
|
|
4404
|
+
clearTimeout(timer);
|
|
4405
|
+
resolve({
|
|
4406
|
+
exitCode: timedOut ? TIMED_OUT : code ?? 1,
|
|
4407
|
+
...output
|
|
4408
|
+
});
|
|
4409
|
+
});
|
|
4410
|
+
child.stdin.on("error", () => void 0);
|
|
4411
|
+
child.stdin.end(input?.stdin);
|
|
4412
|
+
});
|
|
4413
|
+
}
|
|
4414
|
+
async watch(args, cwd, options, extraEnv) {
|
|
4415
|
+
const env = buildEnv(extraEnv);
|
|
4416
|
+
const child = spawn(`${this.command} ${args}`, [], {
|
|
4417
|
+
cwd,
|
|
4418
|
+
detached: SIGNALS_GROUPS,
|
|
4419
|
+
env,
|
|
4420
|
+
shell: true,
|
|
4421
|
+
stdio: [
|
|
4422
|
+
"pipe",
|
|
4423
|
+
"pipe",
|
|
4424
|
+
"pipe"
|
|
4425
|
+
]
|
|
4426
|
+
});
|
|
4427
|
+
const kill = (signal) => signalGroup(child, signal);
|
|
4428
|
+
return await observeProcess({
|
|
4429
|
+
kill,
|
|
4430
|
+
on: child.on.bind(child),
|
|
4431
|
+
stderr: child.stderr,
|
|
4432
|
+
stdout: child.stdout
|
|
4433
|
+
}, options);
|
|
4434
|
+
}
|
|
4435
|
+
};
|
|
4436
|
+
//#endregion
|
|
4437
|
+
//#region src/specifications/cli/cli.handle.ts
|
|
4438
|
+
function dockerTestRunId() {
|
|
4439
|
+
return `t-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
|
4440
|
+
}
|
|
4441
|
+
async function startedBackend$1(declared, defaults, started) {
|
|
4442
|
+
if (declared === void 0) return {
|
|
4443
|
+
backend: null,
|
|
4444
|
+
defaults
|
|
4445
|
+
};
|
|
4446
|
+
const backend = new StubBackend({ port: declared.port });
|
|
4447
|
+
try {
|
|
4448
|
+
return {
|
|
4449
|
+
backend,
|
|
4450
|
+
defaults: {
|
|
4451
|
+
[declared.env]: await backend.start(),
|
|
4452
|
+
...defaults
|
|
4453
|
+
}
|
|
4454
|
+
};
|
|
4455
|
+
} catch (error) {
|
|
4456
|
+
await started?.stop();
|
|
4457
|
+
throw error;
|
|
4458
|
+
}
|
|
4425
4459
|
}
|
|
4426
|
-
async function
|
|
4460
|
+
async function startCli(bin, options = {}) {
|
|
4461
|
+
const callerDir = getCallerDir();
|
|
4427
4462
|
await registerMatchers();
|
|
4428
|
-
|
|
4463
|
+
const root = resolveRoot(options.root, callerDir);
|
|
4464
|
+
const resolvedBin = resolveCommand(bin, root);
|
|
4465
|
+
const services = declaredServices(options.services);
|
|
4466
|
+
const databaseKeys = declaredDatabaseKeys(services);
|
|
4467
|
+
if (options.docker !== void 0) requireDockerDaemon();
|
|
4468
|
+
let started = null;
|
|
4469
|
+
if (Object.keys(services).length > 0) started = await startServices(services, root, { type: "none" });
|
|
4470
|
+
const { backend, defaults } = await startedBackend$1(options.backend, options.defaults, started);
|
|
4471
|
+
const dockerTestRunIdOf = options.docker === void 0 ? void 0 : dockerTestRunId();
|
|
4472
|
+
const declaredServicesOf = Object.keys(services).length > 0 ? services : void 0;
|
|
4429
4473
|
const config = {
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4474
|
+
backend: backend ?? void 0,
|
|
4475
|
+
command: new ExecAdapter(resolvedBin),
|
|
4476
|
+
database: started?.database,
|
|
4477
|
+
databaseKeys,
|
|
4478
|
+
databases: started?.databases,
|
|
4479
|
+
dockerConfig: options.docker,
|
|
4480
|
+
dockerTestRunId: dockerTestRunIdOf,
|
|
4481
|
+
defaultEnv: defaults,
|
|
4482
|
+
envSets: options.env,
|
|
4483
|
+
root,
|
|
4484
|
+
serveRegistry: options.serve,
|
|
4485
|
+
services: declaredServicesOf,
|
|
4486
|
+
transform: options.transform
|
|
4438
4487
|
};
|
|
4439
4488
|
return {
|
|
4440
|
-
cleanup: async () => {
|
|
4441
|
-
|
|
4489
|
+
cleanup: async () => {
|
|
4490
|
+
await started?.stop();
|
|
4491
|
+
await backend?.stop();
|
|
4492
|
+
},
|
|
4493
|
+
cli: cliDefinition.open(config),
|
|
4494
|
+
docker: createDockerReader(callerDir),
|
|
4495
|
+
orchestrator: started?.orchestrator ?? null
|
|
4442
4496
|
};
|
|
4443
4497
|
}
|
|
4444
4498
|
//#endregion
|
|
@@ -5952,8 +6006,6 @@ const specification = {
|
|
|
5952
6006
|
api: startApi,
|
|
5953
6007
|
/** Test a command binary, each spec in a fresh temp directory. */
|
|
5954
6008
|
cli: startCli,
|
|
5955
|
-
/** Probe a running deployment over the network: one complete HTTP exchange, no browser; `.get(url)` or `.request(...)` ends the chain. */
|
|
5956
|
-
deployment: startDeployment,
|
|
5957
6009
|
/** Specify a module against real services, or against a golden; `.call()` ends the chain. */
|
|
5958
6010
|
integration: startIntegration,
|
|
5959
6011
|
/** Test background jobs, in this process; `.trigger(name)` ends the chain. */
|