@jterrazz/test 17.0.0 → 18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/index.js +1 -2
- package/dist/browser/services.js +1 -2
- package/dist/browser/stubs.js +1 -2
- package/dist/{shared-index.browser.js → browser-shared-index.browser.js} +82 -40
- package/dist/checker.js +15 -824
- package/dist/in-place.global-setup.d.ts +1 -0
- package/dist/in-place.global-setup.js +1 -1
- package/dist/index.d.ts +2 -14
- package/dist/index.js +83 -204
- package/dist/mobile-session.global-setup.d.ts +1 -0
- package/dist/oxlint.cjs +2 -2
- package/dist/oxlint.js +67 -2321
- package/dist/route-coverage.global-setup.d.ts +1 -0
- package/dist/route-coverage.global-setup.js +1 -2
- package/dist/services.global-setup.d.ts +1 -1
- package/dist/services.global-setup.js +1 -2
- package/dist/services.js +1 -1
- package/dist/shared-appium.adapter.js +14 -14
- package/dist/shared-artifacts.js +7 -5
- package/dist/shared-derived-projects.js +23 -8
- package/dist/shared-in-place.js +1 -1
- package/dist/shared-ledger.js +1 -1
- package/dist/shared-listened-port.js +19 -0
- package/dist/shared-literate-plugin.js +2 -1
- package/dist/shared-pause.js +12 -1
- package/dist/shared-playwright.adapter.js +17 -19
- package/dist/shared-process-alive.js +13 -0
- package/dist/shared-record-key.js +13 -0
- package/dist/shared-serve.js +107 -65
- package/dist/shared-services.global-setup.js +4 -9
- package/dist/shared-session-ledger.js +10 -6
- package/dist/shared-spec-document.js +2 -64
- package/dist/shared-spec-grammar.js +66 -0
- package/dist/shared-specs-modules.js +1033 -0
- package/dist/{shared-stream-body.js → shared-stub-reply.js} +40 -3
- package/dist/shared-stub-server.js +30 -78
- package/dist/shared-temp-root.global-setup.js +3 -10
- package/dist/shared-testcontainers.adapter.js +111 -8
- package/dist/temp-root.global-setup.js +1 -2
- package/dist/vitest.d.ts +17 -22
- package/dist/vitest.js +37 -53
- package/docs/concepts/architecture.md +20 -20
- package/docs/concepts/conventions.md +2 -2
- package/docs/guides/cli-specs.md +2 -0
- package/docs/guides/develop.md +20 -17
- package/docs/guides/mobile-specs.md +3 -3
- package/docs/guides/release.md +4 -3
- package/docs/guides/website-specs.md +1 -1
- package/docs/reference/linting.md +2 -2
- package/docs/reference/services.md +9 -9
- package/docs/reference/stubs.md +4 -2
- package/docs/start/set-up-a-project.md +31 -17
- package/package.json +10 -10
|
@@ -39,4 +39,5 @@ export declare function routeCoverageSetup(): string;
|
|
|
39
39
|
export declare function setupRoutes(project: RouteProject): () => Promise<void>;
|
|
40
40
|
export default function setup(project: TestProject): () => Promise<void>;
|
|
41
41
|
//#endregion
|
|
42
|
+
export {};
|
|
42
43
|
//# sourceMappingURL=route-coverage.global-setup.d.ts.map
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { setup as default, routeCoverageSetup, setupRoutes };
|
|
1
|
+
export { n as default, t as routeCoverageSetup, r as setupRoutes } from "./shared-route-coverage.global-setup.js";
|
|
@@ -26,7 +26,6 @@ declare function serveContainers(containers: RunContainers): Promise<ServedConta
|
|
|
26
26
|
//#endregion
|
|
27
27
|
//#region src/services/containers/services.global-setup.d.ts
|
|
28
28
|
/** Every project's setup joins the one run's containers. Default images are pulled in the background, so the first file waits on a pull at most once; a project declaring no service needs no Docker. */
|
|
29
|
-
export declare const DOCKER_ABSENT = "no Docker daemon answered";
|
|
30
29
|
/** `connect` stands in for the runtime's own client lookup, so the refusal is reachable without a daemon to stop. */
|
|
31
30
|
export declare function prepareDocker(connect?: () => Promise<ContainerRuntimeClient>): Promise<void>;
|
|
32
31
|
export declare function servicesSetup(): string;
|
|
@@ -40,4 +39,5 @@ type RunSeams = {
|
|
|
40
39
|
};
|
|
41
40
|
export default function setup(project: RunProject, seams?: Partial<RunSeams>): Promise<() => Promise<void>>;
|
|
42
41
|
//#endregion
|
|
42
|
+
export {};
|
|
43
43
|
//# sourceMappingURL=services.global-setup.d.ts.map
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export { DOCKER_ABSENT, setup as default, prepareDocker, servicesSetup };
|
|
1
|
+
export { r as default, t as prepareDocker, n as servicesSetup } from "./shared-services.global-setup.js";
|
package/dist/services.js
CHANGED
|
@@ -3,7 +3,7 @@ import { n as refusal, t as invariant } from "./shared-refusal.js";
|
|
|
3
3
|
import { t as pause } from "./shared-pause.js";
|
|
4
4
|
import { n as processService, t as ProcessHandle } from "./shared-process.adapter.js";
|
|
5
5
|
import { n as requireBuiltPeer, t as loadPeer } from "./shared-peer.js";
|
|
6
|
-
import {
|
|
6
|
+
import { l as SQLITE_TEMPLATE_DIR } from "./shared-artifacts.js";
|
|
7
7
|
import { t as SERVICE_IMAGES } from "./shared-service-images.js";
|
|
8
8
|
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { n as refusal } from "./shared-refusal.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { n as untilDeadline } from "./shared-pause.js";
|
|
3
|
+
import { d as evidenceFile } from "./shared-artifacts.js";
|
|
4
4
|
import { n as SERVER_CALL_TIMEOUT_MS } from "./shared-session-ledger.js";
|
|
5
5
|
import { c as scopeChain } from "./shared-landmarks.js";
|
|
6
6
|
import { a as ambiguityError, c as asWrittenUnscoped, i as shortened, s as asWritten, t as scenarioFailure } from "./shared-scenario-failure.js";
|
|
7
|
-
import {
|
|
8
|
-
import { resolve } from "node:path";
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
9
8
|
//#region src/specifications/mobile/session/driver-session.ts
|
|
10
9
|
const IDLE_SESSION_BUDGET_S = 300;
|
|
11
10
|
const IOS_PREFIX = /^ios\s+/iu;
|
|
@@ -355,15 +354,17 @@ var AppiumAdapter = class {
|
|
|
355
354
|
async resolveOne(driver, target, verb) {
|
|
356
355
|
const chain = scopeChain(target);
|
|
357
356
|
const anyTarget = verb === "see";
|
|
358
|
-
const deadline = Date.now() + this.actionTimeoutMs;
|
|
359
357
|
let tryScroll = true;
|
|
360
|
-
|
|
361
|
-
const
|
|
358
|
+
const resolved = await untilDeadline(async () => {
|
|
359
|
+
const found = await this.resolveChain(driver, chain, anyTarget, tryScroll);
|
|
362
360
|
tryScroll = false;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
361
|
+
return found ?? void 0;
|
|
362
|
+
}, {
|
|
363
|
+
interval: POLL_INTERVAL_MS,
|
|
364
|
+
timeout: this.actionTimeoutMs
|
|
365
|
+
});
|
|
366
|
+
if (resolved === void 0) throw await this.timeoutError(driver, target, verb);
|
|
367
|
+
return resolved;
|
|
367
368
|
}
|
|
368
369
|
async resolveChain(driver, chain, anyTarget, tryScroll) {
|
|
369
370
|
let scope = driver;
|
|
@@ -385,11 +386,10 @@ var AppiumAdapter = class {
|
|
|
385
386
|
}
|
|
386
387
|
return element;
|
|
387
388
|
}
|
|
388
|
-
/** Screenshot the failing state into
|
|
389
|
+
/** Screenshot the failing state into the evidence folder; never masks the original error. */
|
|
389
390
|
async captureEvidence(driver) {
|
|
390
391
|
try {
|
|
391
|
-
const
|
|
392
|
-
const path = resolve(dir, "failure.png");
|
|
392
|
+
const path = evidenceFile("mobile");
|
|
393
393
|
writeFileSync(path, Buffer.from(await driver.takeScreenshot(), "base64"));
|
|
394
394
|
return path;
|
|
395
395
|
} catch {
|
package/dist/shared-artifacts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, mkdtempSync } from "node:fs";
|
|
2
2
|
import { basename, join, resolve } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
//#region lint/kit/project/artifacts.ts
|
|
@@ -11,14 +11,16 @@ const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
|
|
|
11
11
|
const ATTACHMENTS_DIR = `${VITEST_ARTIFACTS_DIR}/attachments`;
|
|
12
12
|
/** Under the project: the run's temp root is gone by the time the error naming the file is read. */
|
|
13
13
|
const EVIDENCE_DIR = `${VITEST_ARTIFACTS_DIR}/evidence`;
|
|
14
|
-
/**
|
|
15
|
-
function
|
|
14
|
+
/** Where a failing scenario's screenshot goes, in a folder of its own filed under the run's temp root name, `<pid>-...`: the next run's start sweeps every run that is no longer alive. */
|
|
15
|
+
function evidenceFile(kind) {
|
|
16
16
|
const parent = resolve(EVIDENCE_DIR, basename(tmpdir()));
|
|
17
17
|
mkdirSync(parent, { recursive: true });
|
|
18
|
-
return join(parent,
|
|
18
|
+
return join(mkdtempSync(join(parent, `${kind}-`)), "failure.png");
|
|
19
19
|
}
|
|
20
20
|
const SCREENSHOTS_DIR = `${VITEST_ARTIFACTS_DIR}/screenshots`;
|
|
21
21
|
const BLOB_DIR = `${VITEST_ARTIFACTS_DIR}/blob`;
|
|
22
|
+
/** Node's compile cache, which every Node process of a run reads and writes: keyed by content, so an edit is never served old code. */
|
|
23
|
+
const NODE_COMPILE_CACHE_DIR = `${ARTIFACTS_DIR}/node-compile-cache`;
|
|
22
24
|
/** Vitest 5's file-writing reporters, each pinned under the artifacts folder. */
|
|
23
25
|
const REPORTER_OUTPUT_FILES = {
|
|
24
26
|
blob: `${BLOB_DIR}/blob.json`,
|
|
@@ -27,6 +29,6 @@ const REPORTER_OUTPUT_FILES = {
|
|
|
27
29
|
junit: `${VITEST_ARTIFACTS_DIR}/junit/output.xml`
|
|
28
30
|
};
|
|
29
31
|
//#endregion
|
|
30
|
-
export { EVIDENCE_DIR as a,
|
|
32
|
+
export { EVIDENCE_DIR as a, SCREENSHOTS_DIR as c, evidenceFile as d, COVERAGE_DIR as i, SQLITE_TEMPLATE_DIR as l, ATTACHMENTS_DIR as n, NODE_COMPILE_CACHE_DIR as o, BLOB_DIR as r, REPORTER_OUTPUT_FILES as s, ARTIFACTS_DIR as t, VITEST_ARTIFACTS_DIR as u };
|
|
31
33
|
|
|
32
34
|
//# sourceMappingURL=shared-artifacts.js.map
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { t as SPECIFICATIONS } from "./shared-specifications.js";
|
|
2
2
|
import { TESTS_FOLDER } from "@jterrazz/typescript/rules";
|
|
3
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
5
|
//#region lint/kit/project/derived-projects.ts
|
|
6
6
|
const SPECS = "specs";
|
|
7
7
|
const UNIT = "unit";
|
|
8
8
|
const FOLDERED = /* @__PURE__ */ new Set([...SPECIFICATIONS, "component"]);
|
|
9
|
-
const
|
|
9
|
+
const RUNNER_SHARED = /* @__PURE__ */ new Set([
|
|
10
10
|
"api",
|
|
11
|
+
"cli",
|
|
11
12
|
"integration",
|
|
12
|
-
"job"
|
|
13
|
+
"job",
|
|
14
|
+
"website"
|
|
13
15
|
]);
|
|
14
16
|
const SPECIFICATION_MODULE = ".specification.ts";
|
|
15
17
|
const MODULE_TEST = /\.test\.ts$/u;
|
|
@@ -23,14 +25,27 @@ function holds(path, pattern) {
|
|
|
23
25
|
});
|
|
24
26
|
}
|
|
25
27
|
const foldersIn = (path) => existsSync(path) ? readdirSync(path, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !isUnwalked(entry.name)).map(({ name }) => name).toSorted() : [];
|
|
28
|
+
function inImportOrder(directory, modules) {
|
|
29
|
+
const ordered = [];
|
|
30
|
+
const seen = /* @__PURE__ */ new Set();
|
|
31
|
+
const visit = (module) => {
|
|
32
|
+
if (seen.has(module)) return;
|
|
33
|
+
seen.add(module);
|
|
34
|
+
const text = readFileSync(join(directory, module), "utf8");
|
|
35
|
+
for (const imported of modules.filter((other) => text.includes(`'./${other.slice(0, -2)}js'`))) visit(imported);
|
|
36
|
+
ordered.push(module);
|
|
37
|
+
};
|
|
38
|
+
for (const module of modules) visit(module);
|
|
39
|
+
return ordered;
|
|
40
|
+
}
|
|
26
41
|
function runnerOf(root, folder, specification) {
|
|
27
|
-
if (!
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
return modules.length ===
|
|
42
|
+
if (!RUNNER_SHARED.has(specification)) return {};
|
|
43
|
+
const directory = join(root, folder);
|
|
44
|
+
const modules = readdirSync(directory).filter((name) => name.endsWith(SPECIFICATION_MODULE)).toSorted();
|
|
45
|
+
return modules.length === 0 ? {} : { runner: inImportOrder(directory, modules).map((name) => `./${folder}/${name}`) };
|
|
31
46
|
}
|
|
32
47
|
const isSpecsMember = (root) => basename(root) === SPECS && existsSync(join(root, "package.json"));
|
|
33
|
-
/** The projects a tree names, each with the options it derives: one per `specs/<specification>/` folder (
|
|
48
|
+
/** The projects a tree names, each with the options it derives: one per `specs/<specification>/` folder (one whose spec files meet their subject through a runner handed as its `runner` every specification module of the folder, `*.specification.ts`, a module after those it imports), `unit` over each root holding a module test (`specs/unit/` is one root among them), `component` over each holding a rendered one. */
|
|
34
49
|
function derivedProjects(root) {
|
|
35
50
|
const member = isSpecsMember(root);
|
|
36
51
|
const prefix = member ? "" : `${SPECS}/`;
|
package/dist/shared-in-place.js
CHANGED
|
@@ -167,6 +167,6 @@ function writeInPlace(site, render, folder = pendingFolder?.()) {
|
|
|
167
167
|
save(site.file, source, folder);
|
|
168
168
|
}
|
|
169
169
|
//#endregion
|
|
170
|
-
export {
|
|
170
|
+
export { getCallerDir as a, writePendingRewrites as i, deferRewritesTo as n, writeInPlace as r, callSiteOf as t };
|
|
171
171
|
|
|
172
172
|
//# sourceMappingURL=shared-in-place.js.map
|
package/dist/shared-ledger.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as isRecord } from "./shared-json-shape.js";
|
|
2
|
-
import {
|
|
2
|
+
import { u as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
|
|
3
3
|
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { t as invariant } from "./shared-refusal.js";
|
|
2
|
+
//#region src/chain/listened-port.ts
|
|
3
|
+
/** Listens, on `host` when one is named, and answers the port it got; a port another server holds rejects with its error. */
|
|
4
|
+
async function listenedPort(server, { host, port = 0 } = {}) {
|
|
5
|
+
await new Promise((resolve, reject) => {
|
|
6
|
+
server.once("error", reject);
|
|
7
|
+
server.listen(port, host, () => {
|
|
8
|
+
server.off("error", reject);
|
|
9
|
+
resolve();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
const address = server.address();
|
|
13
|
+
if (address === null || typeof address === "string") throw invariant("a server listening on a TCP port answered no port");
|
|
14
|
+
return address.port;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { listenedPort as t };
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=shared-listened-port.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { n as refusal } from "./shared-refusal.js";
|
|
2
|
-
import {
|
|
2
|
+
import { c as isRelativePath, s as SpecSyntaxError } from "./shared-spec-grammar.js";
|
|
3
|
+
import { i as readSpecFile, o as dedent } from "./shared-spec-document.js";
|
|
3
4
|
import { readFileSync } from "node:fs";
|
|
4
5
|
import { basename, isAbsolute, resolve } from "node:path";
|
|
5
6
|
//#region src/disk/spec-path.ts
|
package/dist/shared-pause.js
CHANGED
|
@@ -5,7 +5,18 @@ async function pause(ms) {
|
|
|
5
5
|
setTimeout(resolve, ms);
|
|
6
6
|
});
|
|
7
7
|
}
|
|
8
|
+
/** Asks `probe` until it answers something other than `undefined`, which it hands back, or answers `undefined` once `timeout` has passed. Measured on `performance.now()`, so `clock.at()`, which fakes `Date`, cannot freeze it. */
|
|
9
|
+
async function untilDeadline(probe, { interval = 0, timeout, wait }) {
|
|
10
|
+
const deadline = performance.now() + timeout;
|
|
11
|
+
for (;;) {
|
|
12
|
+
const answer = await probe();
|
|
13
|
+
if (answer !== void 0) return answer;
|
|
14
|
+
const remaining = deadline - performance.now();
|
|
15
|
+
if (remaining <= 0) return;
|
|
16
|
+
await (wait === void 0 ? pause(interval) : wait(remaining));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
8
19
|
//#endregion
|
|
9
|
-
export { pause as t };
|
|
20
|
+
export { untilDeadline as n, pause as t };
|
|
10
21
|
|
|
11
22
|
//# sourceMappingURL=shared-pause.js.map
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { n as refusal } from "./shared-refusal.js";
|
|
2
|
-
import {
|
|
2
|
+
import { n as untilDeadline } from "./shared-pause.js";
|
|
3
|
+
import { d as evidenceFile } from "./shared-artifacts.js";
|
|
3
4
|
import { c as scopeChain } from "./shared-landmarks.js";
|
|
4
5
|
import { n as CANDIDATE_LIMITS, o as ambiguousTargetError, r as candidatesOf, s as asWritten, t as scenarioFailure } from "./shared-scenario-failure.js";
|
|
5
|
-
import { mkdtempSync } from "node:fs";
|
|
6
|
-
import { resolve } from "node:path";
|
|
7
|
-
import { setTimeout } from "node:timers/promises";
|
|
8
6
|
//#region src/specifications/website/browser/page-scripts.ts
|
|
9
7
|
function extractPage() {
|
|
10
8
|
const links = [...document.querySelectorAll("link")].map((link) => ({
|
|
@@ -135,20 +133,21 @@ async function currentValue(locator) {
|
|
|
135
133
|
return null;
|
|
136
134
|
}
|
|
137
135
|
}
|
|
138
|
-
async function waitForValue(locator, target, wanted) {
|
|
136
|
+
async function waitForValue(locator, target, wanted, timeout = VALUE_TIMEOUT_MS) {
|
|
139
137
|
const value = target.value ?? "";
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}))
|
|
150
|
-
|
|
151
|
-
|
|
138
|
+
let actual = null;
|
|
139
|
+
if (await untilDeadline(async () => {
|
|
140
|
+
actual = await currentValue(locator);
|
|
141
|
+
return actual === value === wanted || void 0;
|
|
142
|
+
}, {
|
|
143
|
+
interval: VALUE_POLL_MS,
|
|
144
|
+
timeout
|
|
145
|
+
}) === void 0) throw new Error(refusal({
|
|
146
|
+
fix: "correct the value the scenario states, or what the page puts in the field",
|
|
147
|
+
found: `${asWritten(target)} ${wanted ? "never held" : "still holds"} ${JSON.stringify(value)}${actual === null ? " (it is not on the page)" : `, it holds ${JSON.stringify(actual)}`}`,
|
|
148
|
+
read: "docs/reference/visitor.md#see-and-gone-are-the-synchronization",
|
|
149
|
+
why: "A verb waits for what it states to hold, within its budget, and never sleeps."
|
|
150
|
+
}));
|
|
152
151
|
}
|
|
153
152
|
async function settle(page, locator, target, present) {
|
|
154
153
|
if (target.value !== void 0) {
|
|
@@ -312,8 +311,7 @@ var PlaywrightAdapter = class {
|
|
|
312
311
|
/** Screenshot the failing state into the evidence folder; never masks the original error. */
|
|
313
312
|
async captureEvidence(page) {
|
|
314
313
|
try {
|
|
315
|
-
const
|
|
316
|
-
const path = resolve(dir, "failure.png");
|
|
314
|
+
const path = evidenceFile("website");
|
|
317
315
|
await page.screenshot({
|
|
318
316
|
fullPage: true,
|
|
319
317
|
path
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/chain/process-alive.ts
|
|
2
|
+
function processAlive(pid, probe = (target) => process.kill(target, 0)) {
|
|
3
|
+
try {
|
|
4
|
+
probe(pid);
|
|
5
|
+
return true;
|
|
6
|
+
} catch (error) {
|
|
7
|
+
return Reflect.get(new Object(error), "code") !== "ESRCH";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { processAlive as t };
|
|
12
|
+
|
|
13
|
+
//# sourceMappingURL=shared-process-alive.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region lint/kit/project/record-key.ts
|
|
2
|
+
/** `analyticsDb` gives `analytics-db`. */
|
|
3
|
+
function toKebabCase(key) {
|
|
4
|
+
return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>-$<upper>").toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
/** `analyticsDb` gives `ANALYTICS_DB`, `db-main` gives `DB_MAIN`. */
|
|
7
|
+
function toConstantCase(key) {
|
|
8
|
+
return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>_$<upper>").replaceAll(/[^A-Za-z0-9]/gu, "_").toUpperCase();
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { toKebabCase as n, toConstantCase as t };
|
|
12
|
+
|
|
13
|
+
//# sourceMappingURL=shared-record-key.js.map
|
package/dist/shared-serve.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { n as refusal
|
|
2
|
-
import {
|
|
1
|
+
import { n as refusal } from "./shared-refusal.js";
|
|
2
|
+
import { n as untilDeadline } from "./shared-pause.js";
|
|
3
|
+
import { t as listenedPort } from "./shared-listened-port.js";
|
|
3
4
|
import "node:module";
|
|
4
5
|
import { resolve } from "node:path";
|
|
5
6
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
6
|
-
import { Socket, createServer } from "node:net";
|
|
7
|
+
import { Socket, connect, createServer } from "node:net";
|
|
7
8
|
import { once } from "node:events";
|
|
8
9
|
//#region \0rolldown/runtime.js
|
|
9
10
|
var __defProp = Object.defineProperty;
|
|
@@ -19,22 +20,11 @@ var __exportAll = (all, no_symbols) => {
|
|
|
19
20
|
//#endregion
|
|
20
21
|
//#region src/services/process/listening.ts
|
|
21
22
|
async function findFreePort() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
server.close();
|
|
28
|
-
reject(invariant("a server listening on port 0 answered no port"));
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
const { port } = address;
|
|
32
|
-
server.close(() => {
|
|
33
|
-
resolve(port);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
server.on("error", reject);
|
|
37
|
-
});
|
|
23
|
+
const server = createServer();
|
|
24
|
+
const port = await listenedPort(server);
|
|
25
|
+
server.close();
|
|
26
|
+
await once(server, "close");
|
|
27
|
+
return port;
|
|
38
28
|
}
|
|
39
29
|
async function firstAnswering(urls) {
|
|
40
30
|
return (await Promise.all(urls.map(async (url) => await answers(url) ? url : void 0))).find((url) => url !== void 0);
|
|
@@ -47,6 +37,44 @@ async function answers(url) {
|
|
|
47
37
|
return false;
|
|
48
38
|
}
|
|
49
39
|
}
|
|
40
|
+
const LOOPBACK = ["127.0.0.1", "::1"];
|
|
41
|
+
async function holdingHost(port) {
|
|
42
|
+
return (await Promise.all(LOOPBACK.map(async (host) => await accepts(host, port) ? host : void 0))).find((host) => host !== void 0);
|
|
43
|
+
}
|
|
44
|
+
async function accepts(host, port) {
|
|
45
|
+
return await new Promise((resolve) => {
|
|
46
|
+
const socket = connect({
|
|
47
|
+
host,
|
|
48
|
+
port
|
|
49
|
+
});
|
|
50
|
+
socket.once("connect", () => {
|
|
51
|
+
socket.destroy();
|
|
52
|
+
resolve(true);
|
|
53
|
+
});
|
|
54
|
+
socket.once("error", () => {
|
|
55
|
+
resolve(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/services/process/output-change.ts
|
|
61
|
+
/** Wakes one waiter when the output of a process grows or the process closes. */
|
|
62
|
+
var OutputChange = class {
|
|
63
|
+
waiting;
|
|
64
|
+
notify() {
|
|
65
|
+
this.waiting?.();
|
|
66
|
+
}
|
|
67
|
+
/** Resolves on the next change, or after `withinMs` when none comes. */
|
|
68
|
+
async next(withinMs) {
|
|
69
|
+
await new Promise((resolve) => {
|
|
70
|
+
const late = setTimeout(resolve, withinMs);
|
|
71
|
+
this.waiting = () => {
|
|
72
|
+
clearTimeout(late);
|
|
73
|
+
resolve();
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
50
78
|
//#endregion
|
|
51
79
|
//#region src/services/process/port-in-use.ts
|
|
52
80
|
const PORT_IN_USE = /EADDRINUSE|address already in use|port \d+ is already in use/iu;
|
|
@@ -58,6 +86,17 @@ function lessonOf(reason, output, port) {
|
|
|
58
86
|
why: "A process is handed a free port unless `port` pins one, and a port another process holds is not free."
|
|
59
87
|
};
|
|
60
88
|
}
|
|
89
|
+
async function refuseHeldPort(port, specification, command) {
|
|
90
|
+
if (port === void 0) return;
|
|
91
|
+
const host = await holdingHost(port);
|
|
92
|
+
if (host === void 0) return;
|
|
93
|
+
throw new Error(refusal({
|
|
94
|
+
fix: `stop the process holding port ${String(port)}, or drop the \`port\` option so a free one is picked`,
|
|
95
|
+
found: `specification.${specification}(): port ${String(port)}, which \`port\` pins, is already held: something answers on ${host} before the server is started.\nCommand: ${command}`,
|
|
96
|
+
read: "docs/reference/services.md#process-the-one-shape-an-external-process-takes",
|
|
97
|
+
why: "A process pinned to a port is ready once that port answers, and a port another process holds would answer in its place."
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
61
100
|
//#endregion
|
|
62
101
|
//#region src/services/process/process-group.ts
|
|
63
102
|
const KILL_GRACE_MS = 2e3;
|
|
@@ -127,14 +166,11 @@ function groupRunning(group) {
|
|
|
127
166
|
return false;
|
|
128
167
|
}
|
|
129
168
|
}
|
|
130
|
-
async function endedBy(group, deadline) {
|
|
131
|
-
if (!groupRunning(group)) return true;
|
|
132
|
-
if (Date.now() >= deadline) return false;
|
|
133
|
-
await pause(GROUP_SETTLE_MS);
|
|
134
|
-
return await endedBy(group, deadline);
|
|
135
|
-
}
|
|
136
169
|
async function groupEnded(group, withinMs) {
|
|
137
|
-
return await
|
|
170
|
+
return await untilDeadline(() => !groupRunning(group) || void 0, {
|
|
171
|
+
interval: GROUP_SETTLE_MS,
|
|
172
|
+
timeout: withinMs
|
|
173
|
+
}) === true;
|
|
138
174
|
}
|
|
139
175
|
/** Resolves once the child is reaped, at once for one reaped already. */
|
|
140
176
|
async function reaped(child) {
|
|
@@ -150,6 +186,13 @@ function signalGroup(child, signal = "SIGTERM") {
|
|
|
150
186
|
} catch {}
|
|
151
187
|
return child.kill(signal);
|
|
152
188
|
}
|
|
189
|
+
async function endGroup(child) {
|
|
190
|
+
if (child.pid === void 0) return;
|
|
191
|
+
const leaderReaped = reaped(child);
|
|
192
|
+
signalGroup(child, "SIGTERM");
|
|
193
|
+
if (!await groupEnded(-child.pid, 2e3)) signalGroup(child, "SIGKILL");
|
|
194
|
+
await leaderReaped;
|
|
195
|
+
}
|
|
153
196
|
//#endregion
|
|
154
197
|
//#region src/services/process/serve.ts
|
|
155
198
|
var serve_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -157,7 +200,7 @@ var serve_exports = /* @__PURE__ */ __exportAll({
|
|
|
157
200
|
runToCompletion: () => runToCompletion
|
|
158
201
|
});
|
|
159
202
|
const DEFAULT_READY_TIMEOUT = 3e4;
|
|
160
|
-
const READY_POLL_INTERVAL_MS =
|
|
203
|
+
const READY_POLL_INTERVAL_MS = 25;
|
|
161
204
|
function watchGroup(groupId, strays) {
|
|
162
205
|
const args = [
|
|
163
206
|
"-e",
|
|
@@ -202,6 +245,7 @@ function runToCompletion(command, env) {
|
|
|
202
245
|
var ServedProcess = class {
|
|
203
246
|
port = null;
|
|
204
247
|
child = null;
|
|
248
|
+
change = new OutputChange();
|
|
205
249
|
extraEnv;
|
|
206
250
|
specification;
|
|
207
251
|
options;
|
|
@@ -218,12 +262,12 @@ var ServedProcess = class {
|
|
|
218
262
|
this.root = root;
|
|
219
263
|
}
|
|
220
264
|
async start() {
|
|
265
|
+
await refuseHeldPort(this.options.port, this.specification, this.options.command);
|
|
221
266
|
this.runBefore();
|
|
222
267
|
const banner = this.options.ready instanceof RegExp ? this.options.ready : null;
|
|
223
268
|
const port = banner ? null : this.options.port ?? await findFreePort();
|
|
224
269
|
this.spawnServer(port);
|
|
225
|
-
|
|
226
|
-
return (banner ? await this.awaitBanner(banner, deadline) : await this.awaitAnswer(port ?? 0, deadline)) ?? await this.refuse({
|
|
270
|
+
return (banner ? await this.awaitBanner(banner) : await this.awaitAnswer(port ?? 0)) ?? await this.refuse({
|
|
227
271
|
fix: "run the command by hand and correct what its output names",
|
|
228
272
|
found: "the server command exited before it was ready",
|
|
229
273
|
why: "A process is handed to the chain once it is ready, and one that exits never is."
|
|
@@ -256,57 +300,62 @@ var ServedProcess = class {
|
|
|
256
300
|
if (child.pid !== void 0 && SIGNALS_GROUPS) this.watchdog = watchGroup(child.pid, this.strays);
|
|
257
301
|
const collect = (data) => {
|
|
258
302
|
this.output += String(data);
|
|
303
|
+
this.change.notify();
|
|
259
304
|
};
|
|
260
305
|
child.stdout?.on("data", collect);
|
|
261
306
|
child.stderr?.on("data", collect);
|
|
262
307
|
child.on("close", () => {
|
|
263
308
|
this.exited = true;
|
|
309
|
+
this.change.notify();
|
|
264
310
|
});
|
|
265
311
|
child.on("error", (error) => {
|
|
266
312
|
collect(`${String(error)}\n`);
|
|
267
313
|
});
|
|
268
314
|
}
|
|
269
|
-
async awaitBanner(banner
|
|
270
|
-
|
|
315
|
+
async awaitBanner(banner) {
|
|
316
|
+
const seen = await untilDeadline(() => {
|
|
317
|
+
if (this.exited) return "exited";
|
|
271
318
|
banner.lastIndex = 0;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
319
|
+
return banner.exec(this.output) ?? void 0;
|
|
320
|
+
}, {
|
|
321
|
+
timeout: this.timeout(),
|
|
322
|
+
wait: async (remaining) => {
|
|
323
|
+
await this.change.next(remaining);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
if (seen === "exited") return null;
|
|
327
|
+
if (seen === void 0) return await this.refuse({
|
|
328
|
+
fix: "correct the pattern to what the server prints, or raise `timeout`",
|
|
329
|
+
found: `the server did not print ${String(banner)} within ${this.timeout()}ms`,
|
|
330
|
+
why: "A process is ready once its output matches `ready`, within `timeout`."
|
|
331
|
+
});
|
|
332
|
+
return await this.bannerPort(banner, seen[1]);
|
|
282
333
|
}
|
|
283
334
|
async bannerPort(banner, group) {
|
|
284
335
|
const captured = Number(group);
|
|
285
336
|
if (!Number.isInteger(captured)) return await this.refuse({
|
|
286
|
-
fix:
|
|
337
|
+
fix: String.raw`capture the port alone in the pattern's one group, e.g. /listening on (\d+)/`,
|
|
287
338
|
found: `the ready pattern ${String(banner)} matched, and its capture group is not a port ("${group}")`,
|
|
288
339
|
why: "A RegExp `ready` is read over the output, and its one capture group is the port."
|
|
289
340
|
});
|
|
290
341
|
this.port = captured;
|
|
291
342
|
return `http://127.0.0.1:${captured}`;
|
|
292
343
|
}
|
|
293
|
-
async awaitAnswer(port
|
|
344
|
+
async awaitAnswer(port) {
|
|
294
345
|
const baseUrls = [`http://127.0.0.1:${port}`, `http://[::1]:${port}`];
|
|
295
346
|
const path = typeof this.options.ready === "string" ? this.options.ready : "/";
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
return null;
|
|
347
|
+
const seen = await untilDeadline(async () => this.exited ? "exited" : await firstAnswering(baseUrls.map((baseUrl) => `${baseUrl}${path}`)), {
|
|
348
|
+
interval: READY_POLL_INTERVAL_MS,
|
|
349
|
+
timeout: this.timeout()
|
|
350
|
+
});
|
|
351
|
+
if (seen === "exited") return null;
|
|
352
|
+
if (seen === void 0) return await this.refuse({
|
|
353
|
+
fix: "correct `ready` to a path the server answers, have it listen on `PORT`, or raise `timeout`",
|
|
354
|
+
found: `the server did not answer on ${baseUrls.map((baseUrl) => `${baseUrl}${path}`).join(" or ")} within ${this.timeout()}ms, whatever address it printed above`,
|
|
355
|
+
why: "A process is ready once its `ready` path answers, within `timeout`."
|
|
356
|
+
});
|
|
357
|
+
this.port = port;
|
|
358
|
+
return seen.slice(0, seen.length - path.length);
|
|
310
359
|
}
|
|
311
360
|
timeout() {
|
|
312
361
|
return this.options.timeout ?? DEFAULT_READY_TIMEOUT;
|
|
@@ -352,14 +401,7 @@ var ServedProcess = class {
|
|
|
352
401
|
this.watchdog = null;
|
|
353
402
|
}
|
|
354
403
|
};
|
|
355
|
-
async function endGroup(child) {
|
|
356
|
-
if (child.pid === void 0) return;
|
|
357
|
-
const leaderReaped = reaped(child);
|
|
358
|
-
signalGroup(child, "SIGTERM");
|
|
359
|
-
if (!await groupEnded(-child.pid, 2e3)) signalGroup(child, "SIGKILL");
|
|
360
|
-
await leaderReaped;
|
|
361
|
-
}
|
|
362
404
|
//#endregion
|
|
363
|
-
export {
|
|
405
|
+
export { endGroup as a, SIGNALS_GROUPS as i, serve_exports as n, signalGroup as o, KILL_GRACE_MS as r, ServedProcess as t };
|
|
364
406
|
|
|
365
407
|
//# sourceMappingURL=shared-serve.js.map
|