@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.
Files changed (53) hide show
  1. package/dist/browser/index.js +1 -2
  2. package/dist/browser/services.js +1 -2
  3. package/dist/browser/stubs.js +1 -2
  4. package/dist/{shared-index.browser.js → browser-shared-index.browser.js} +82 -40
  5. package/dist/checker.js +15 -824
  6. package/dist/in-place.global-setup.d.ts +1 -0
  7. package/dist/in-place.global-setup.js +1 -1
  8. package/dist/index.d.ts +2 -14
  9. package/dist/index.js +83 -204
  10. package/dist/mobile-session.global-setup.d.ts +1 -0
  11. package/dist/oxlint.cjs +2 -2
  12. package/dist/oxlint.js +67 -2321
  13. package/dist/route-coverage.global-setup.d.ts +1 -0
  14. package/dist/route-coverage.global-setup.js +1 -2
  15. package/dist/services.global-setup.d.ts +1 -1
  16. package/dist/services.global-setup.js +1 -2
  17. package/dist/services.js +1 -1
  18. package/dist/shared-appium.adapter.js +14 -14
  19. package/dist/shared-artifacts.js +7 -5
  20. package/dist/shared-derived-projects.js +23 -8
  21. package/dist/shared-in-place.js +1 -1
  22. package/dist/shared-ledger.js +1 -1
  23. package/dist/shared-listened-port.js +19 -0
  24. package/dist/shared-literate-plugin.js +2 -1
  25. package/dist/shared-pause.js +12 -1
  26. package/dist/shared-playwright.adapter.js +17 -19
  27. package/dist/shared-process-alive.js +13 -0
  28. package/dist/shared-record-key.js +13 -0
  29. package/dist/shared-serve.js +107 -65
  30. package/dist/shared-services.global-setup.js +4 -9
  31. package/dist/shared-session-ledger.js +10 -6
  32. package/dist/shared-spec-document.js +2 -64
  33. package/dist/shared-spec-grammar.js +66 -0
  34. package/dist/shared-specs-modules.js +1033 -0
  35. package/dist/{shared-stream-body.js → shared-stub-reply.js} +40 -3
  36. package/dist/shared-stub-server.js +30 -78
  37. package/dist/shared-temp-root.global-setup.js +3 -10
  38. package/dist/shared-testcontainers.adapter.js +111 -8
  39. package/dist/temp-root.global-setup.js +1 -2
  40. package/dist/vitest.d.ts +17 -22
  41. package/dist/vitest.js +37 -53
  42. package/docs/concepts/architecture.md +20 -20
  43. package/docs/concepts/conventions.md +2 -2
  44. package/docs/guides/cli-specs.md +2 -0
  45. package/docs/guides/develop.md +20 -17
  46. package/docs/guides/mobile-specs.md +3 -3
  47. package/docs/guides/release.md +4 -3
  48. package/docs/guides/website-specs.md +1 -1
  49. package/docs/reference/linting.md +2 -2
  50. package/docs/reference/services.md +9 -9
  51. package/docs/reference/stubs.md +4 -2
  52. package/docs/start/set-up-a-project.md +31 -17
  53. 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
- import { n as setup, r as setupRoutes, t as routeCoverageSetup } from "./shared-route-coverage.global-setup.js";
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
- import { i as setup, n as prepareDocker, r as servicesSetup, t as DOCKER_ABSENT } from "./shared-services.global-setup.js";
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 { c as SQLITE_TEMPLATE_DIR } from "./shared-artifacts.js";
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 { t as pause } from "./shared-pause.js";
3
- import { u as evidenceDirectory } from "./shared-artifacts.js";
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 { mkdtempSync, writeFileSync } from "node:fs";
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
- for (;;) {
361
- const resolved = await this.resolveChain(driver, chain, anyTarget, tryScroll);
358
+ const resolved = await untilDeadline(async () => {
359
+ const found = await this.resolveChain(driver, chain, anyTarget, tryScroll);
362
360
  tryScroll = false;
363
- if (resolved) return resolved;
364
- if (Date.now() > deadline) throw await this.timeoutError(driver, target, verb);
365
- await pause(POLL_INTERVAL_MS);
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 a temp file; never masks the original error. */
389
+ /** Screenshot the failing state into the evidence folder; never masks the original error. */
389
390
  async captureEvidence(driver) {
390
391
  try {
391
- const dir = mkdtempSync(evidenceDirectory("mobile-"));
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 {
@@ -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
- /** Filed under the run's temp root name, `<pid>-...`: the next run's start sweeps every run that is no longer alive. */
15
- function evidenceDirectory(prefix) {
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, prefix);
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, SQLITE_TEMPLATE_DIR as c, COVERAGE_DIR as i, VITEST_ARTIFACTS_DIR as l, ATTACHMENTS_DIR as n, REPORTER_OUTPUT_FILES as o, BLOB_DIR as r, SCREENSHOTS_DIR as s, ARTIFACTS_DIR as t, evidenceDirectory as u };
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 IN_PROCESS = /* @__PURE__ */ new Set([
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 (!IN_PROCESS.has(specification)) return {};
28
- const modules = readdirSync(join(root, folder)).filter((name) => name.endsWith(SPECIFICATION_MODULE));
29
- const own = `${specification}${SPECIFICATION_MODULE}`;
30
- return modules.length === 1 && modules[0] === own ? { runner: `./${folder}/${own}` } : {};
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 (an in-process one handed as its `runner` the folder's one specification module, `<specification>.specification.ts`), `unit` over each root holding a module test (`specs/unit/` is one root among them), `component` over each holding a rendered one. */
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}/`;
@@ -167,6 +167,6 @@ function writeInPlace(site, render, folder = pendingFolder?.()) {
167
167
  save(site.file, source, folder);
168
168
  }
169
169
  //#endregion
170
- export { callerFrames as a, writePendingRewrites as i, deferRewritesTo as n, getCallerDir as o, writeInPlace as r, callSiteOf as t };
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
@@ -1,5 +1,5 @@
1
1
  import { t as isRecord } from "./shared-json-shape.js";
2
- import { l as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
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 { d as isRelativePath, i as readSpecFile, l as SpecSyntaxError, u as dedent } from "./shared-spec-document.js";
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
@@ -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 { u as evidenceDirectory } from "./shared-artifacts.js";
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
- const deadline = Date.now() + VALUE_TIMEOUT_MS;
141
- for (;;) {
142
- const actual = await currentValue(locator);
143
- if (actual === null ? !wanted : actual === value === wanted) return;
144
- if (Date.now() >= deadline) throw new Error(refusal({
145
- fix: "correct the value the scenario states, or what the page puts in the field",
146
- found: `${asWritten(target)} ${wanted ? "never held" : "still holds"} ${JSON.stringify(value)}${actual === null ? " (it is not on the page)" : `, it holds ${JSON.stringify(actual)}`}`,
147
- read: "docs/reference/visitor.md#see-and-gone-are-the-synchronization",
148
- why: "A verb waits for what it states to hold, within its budget, and never sleeps."
149
- }));
150
- await setTimeout(VALUE_POLL_MS);
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 dir = mkdtempSync(evidenceDirectory("website-"));
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
@@ -1,9 +1,10 @@
1
- import { n as refusal, t as invariant } from "./shared-refusal.js";
2
- import { t as pause } from "./shared-pause.js";
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
- return await new Promise((resolve, reject) => {
23
- const server = createServer();
24
- server.listen(0, () => {
25
- const address = server.address();
26
- if (address === null || typeof address === "string") {
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 endedBy(group, Date.now() + withinMs);
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 = 250;
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
- const deadline = Date.now() + this.timeout();
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, deadline) {
270
- while (!this.exited) {
315
+ async awaitBanner(banner) {
316
+ const seen = await untilDeadline(() => {
317
+ if (this.exited) return "exited";
271
318
  banner.lastIndex = 0;
272
- const found = banner.exec(this.output);
273
- if (found) return await this.bannerPort(banner, found[1]);
274
- if (Date.now() >= deadline) return await this.refuse({
275
- fix: "correct the pattern to what the server prints, or raise `timeout`",
276
- found: `the server did not print ${String(banner)} within ${this.timeout()}ms`,
277
- why: "A process is ready once its output matches `ready`, within `timeout`."
278
- });
279
- await pause(READY_POLL_INTERVAL_MS);
280
- }
281
- return null;
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: "capture the port alone in the pattern's one group, e.g. /listening on (\\d+)/",
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, deadline) {
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
- while (!this.exited) {
297
- const answering = await firstAnswering(baseUrls.map((baseUrl) => `${baseUrl}${path}`));
298
- if (answering !== void 0) {
299
- this.port = port;
300
- return answering.slice(0, answering.length - path.length);
301
- }
302
- if (Date.now() >= deadline) return await this.refuse({
303
- fix: "correct `ready` to a path the server answers, have it listen on `PORT`, or raise `timeout`",
304
- found: `the server did not answer on ${baseUrls.map((baseUrl) => `${baseUrl}${path}`).join(" or ")} within ${this.timeout()}ms, whatever address it printed above`,
305
- why: "A process is ready once its `ready` path answers, within `timeout`."
306
- });
307
- await pause(READY_POLL_INTERVAL_MS);
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 { signalGroup as a, SIGNALS_GROUPS as i, serve_exports as n, KILL_GRACE_MS as r, ServedProcess as t };
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