@jterrazz/test 18.1.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/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";
@@ -24,9 +24,11 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep
24
24
  import { execFileSync, spawn, spawnSync } from "node:child_process";
25
25
  import { readdir } from "node:fs/promises";
26
26
  import { randomUUID } from "node:crypto";
27
- import { createServer } from "node:http";
27
+ import { connect, isIP } from "node:net";
28
+ import { createServer, request } from "node:http";
28
29
  import { tmpdir } from "node:os";
29
30
  import { TestRunner, describe, it, onTestFinished, vi } from "vitest";
31
+ import { checkServerIdentity, connect as connect$1, getCACertificates } from "node:tls";
30
32
  import { mock, mockDeep } from "vitest-mock-extended";
31
33
  //#region src/expectations/disk/directory.ts
32
34
  const DEFAULT_IGNORES = [
@@ -656,6 +658,11 @@ function createDockerReader(testDir, inspect = inspectContainer) {
656
658
  }
657
659
  //#endregion
658
660
  //#region src/expectations/http/response.ts
661
+ /** A fresh golden records the content-type alone, and a golden with no body is given the body the response carried. */
662
+ const DEFAULT_RECORDING = {
663
+ bodyKeptAbsent: false,
664
+ headers: ["content-type"]
665
+ };
659
666
  /** Read-only accessor for an HTTP response, compared by `expect(...).toMatch(...)`. */
660
667
  var ResponseAccessor = class {
661
668
  /** The JSON value the body holds, or its text when it is not JSON; null when the body is empty. */
@@ -664,15 +671,21 @@ var ResponseAccessor = class {
664
671
  captures;
665
672
  /** Response headers as a flat, lower-cased key-value map. */
666
673
  headers;
674
+ /** @internal */
675
+ recording;
667
676
  status;
668
677
  /** @internal */
669
678
  testDir;
670
- constructor(response, testDir, captures) {
679
+ /** Why the exchange never answered, a golden's `unreachable: <reason>` line; undefined when it answered. */
680
+ unreachable;
681
+ constructor(response, testDir, captures, recording = DEFAULT_RECORDING) {
671
682
  this.body = response.body;
672
683
  this.headers = response.headers;
673
684
  this.status = response.status;
685
+ this.unreachable = response.unreachable;
674
686
  this.testDir = testDir;
675
687
  this.captures = captures ?? new CaptureScope();
688
+ this.recording = recording;
676
689
  }
677
690
  };
678
691
  //#endregion
@@ -1121,6 +1134,9 @@ function parseHeaders(headerLines, fileName) {
1121
1134
  }
1122
1135
  const PARAMETERS = /;.*$/su;
1123
1136
  const START_LINE = /^HTTP\/1\.1\s+(?<status>\S+)(?:\s+.*)?$/u;
1137
+ const UNREACHABLE_LINE = /^unreachable:\s*(?<reason>\S.*)$/u;
1138
+ /** The one line an exchange that never answered is written as. */
1139
+ const unreachableLine = (reason) => `unreachable: ${reason}`;
1124
1140
  const TEXT_MEDIA_TYPES = /* @__PURE__ */ new Set([
1125
1141
  "application/javascript",
1126
1142
  "application/x-ndjson",
@@ -1170,12 +1186,14 @@ function statedContentType(headers) {
1170
1186
  }
1171
1187
  function parseResponseFile(content, fileName, contentType) {
1172
1188
  const { body, headerLines, startLine } = splitSections(content);
1189
+ const reason = UNREACHABLE_LINE.exec(startLine.trim())?.groups?.reason;
1190
+ if (reason !== void 0) return unreachableFile(reason, headerLines.length > 0 || body.length > 0, fileName);
1173
1191
  const status = START_LINE.exec(startLine.trim())?.[1];
1174
1192
  if (status === void 0) throw new Error(refusal$1({
1175
- fix: "start the response file with its status line: \"HTTP/1.1 200 OK\"",
1193
+ fix: "start the response file with its status line: \"HTTP/1.1 200 OK\", or \"unreachable: <reason>\" for an exchange that never answers",
1176
1194
  found: `${fileName}: the first line is "${startLine}"`,
1177
1195
  read: "docs/guides/api-specs.md#http-request-files-full-format",
1178
- why: "A response file opens on `HTTP/1.1 <status>`."
1196
+ why: "A response file opens on `HTTP/1.1 <status>`, or is the one line `unreachable: <reason>`."
1179
1197
  }));
1180
1198
  const headers = parseHeaders(headerLines, fileName);
1181
1199
  return {
@@ -1185,6 +1203,25 @@ function parseResponseFile(content, fileName, contentType) {
1185
1203
  status
1186
1204
  };
1187
1205
  }
1206
+ function unreachableFile(reason, more, fileName) {
1207
+ if (more) throw new Error(refusal$1({
1208
+ fix: "keep the one line `unreachable: <reason>`, or state the status the exchange answers",
1209
+ found: `${fileName}: "unreachable: ${reason}" is followed by headers or a body`,
1210
+ read: "docs/guides/api-specs.md#unreachable-is-a-result",
1211
+ why: "An exchange that never answered carries no header and no body, so its golden is one line."
1212
+ }));
1213
+ return unreachableResponse(reason);
1214
+ }
1215
+ /** The golden of an exchange that never answered: its reason, and no status, header or body. */
1216
+ function unreachableResponse(reason) {
1217
+ return {
1218
+ body: void 0,
1219
+ hasBody: false,
1220
+ headers: {},
1221
+ status: "",
1222
+ unreachable: reason
1223
+ };
1224
+ }
1188
1225
  const STATUS_TEXT = {
1189
1226
  200: "OK",
1190
1227
  201: "Created",
@@ -1193,6 +1230,8 @@ const STATUS_TEXT = {
1193
1230
  301: "Moved Permanently",
1194
1231
  302: "Found",
1195
1232
  304: "Not Modified",
1233
+ 307: "Temporary Redirect",
1234
+ 308: "Permanent Redirect",
1196
1235
  400: "Bad Request",
1197
1236
  401: "Unauthorized",
1198
1237
  403: "Forbidden",
@@ -1206,6 +1245,7 @@ const STATUS_TEXT = {
1206
1245
  };
1207
1246
  /** A body is written as text or JSON by its `content-type`, the actual response's when the fixture states none. */
1208
1247
  function serializeResponseFile(response, contentType) {
1248
+ if (response.unreachable !== void 0) return `${unreachableLine(response.unreachable)}\n`;
1209
1249
  const statusNumber = Number(response.status);
1210
1250
  const statusText = STATUS_TEXT[statusNumber];
1211
1251
  const lines = [`HTTP/1.1 ${response.status}${statusText === void 0 ? "" : ` ${statusText}`}`];
@@ -1231,26 +1271,49 @@ function isJsonLike(text) {
1231
1271
  //#endregion
1232
1272
  //#region src/expectations/goldens/response-goldens.ts
1233
1273
  const STATUS_CODE = /^\d+$/u;
1234
- /** Headers are the intersection with the actual response, so a freshly updated fixture passes the next run. */
1235
- function buildUpdatedResponse(previous, actual, workdir) {
1236
- const scope = new CaptureScope();
1237
- const status = previous && structuralEquals(previous.status, String(actual.status), scope) ? previous.status : String(actual.status);
1274
+ const outcomeOf = (response) => response.unreachable === void 0 ? `HTTP/1.1 ${response.status}` : unreachableLine(response.unreachable);
1275
+ function updatedUnreachable(previous, reason, scope) {
1276
+ return unreachableResponse(previous?.unreachable !== void 0 && structuralEquals(previous.unreachable, reason, scope) ? previous.unreachable : reason);
1277
+ }
1278
+ function updatedHeaders(previous, actual, scope) {
1238
1279
  const headers = {};
1239
- if (previous) for (const [key, value] of Object.entries(previous.headers)) {
1280
+ if (previous === null) {
1281
+ for (const name of (actual.recording ?? DEFAULT_RECORDING).headers) {
1282
+ const actualValue = actual.headers[name.toLowerCase()];
1283
+ if (actualValue !== void 0) headers[name.toLowerCase()] = actualValue;
1284
+ }
1285
+ return headers;
1286
+ }
1287
+ for (const [key, value] of Object.entries(previous.headers)) {
1240
1288
  const actualValue = actual.headers[key.toLowerCase()];
1241
- if (actualValue === void 0) continue;
1242
- headers[key] = structuralEquals(value, actualValue, scope) ? value : actualValue;
1289
+ if (actualValue !== void 0) headers[key] = structuralEquals(value, actualValue, scope) ? value : actualValue;
1243
1290
  }
1244
- else if (actual.headers["content-type"] !== void 0) headers["content-type"] = actual.headers["content-type"];
1245
- const hasBody = actual.body !== null && actual.body !== void 0;
1291
+ return headers;
1292
+ }
1293
+ function updatedBody(previous, actual, workdir) {
1294
+ const hasBody = !((actual.recording ?? DEFAULT_RECORDING).bodyKeptAbsent && previous?.hasBody === false) && actual.body !== null && actual.body !== void 0;
1246
1295
  return {
1247
1296
  body: previous?.hasBody === true && hasBody ? mergePreservingPlaceholders(previous.body, actual.body, workdir) : mergePreservingPlaceholders(null, actual.body, workdir),
1248
- hasBody,
1249
- headers,
1297
+ hasBody
1298
+ };
1299
+ }
1300
+ /** Headers are the intersection with the actual response, so a freshly updated fixture passes the next run. */
1301
+ function buildUpdatedResponse(stated, actual, workdir) {
1302
+ const scope = new CaptureScope();
1303
+ if (actual.unreachable !== void 0) return updatedUnreachable(stated, actual.unreachable, scope);
1304
+ const previous = stated?.unreachable === void 0 ? stated : null;
1305
+ const status = previous && structuralEquals(previous.status, String(actual.status), scope) ? previous.status : String(actual.status);
1306
+ return {
1307
+ ...updatedBody(previous, actual, workdir),
1308
+ headers: updatedHeaders(previous, actual, scope),
1250
1309
  status
1251
1310
  };
1252
1311
  }
1312
+ function compareOutcome(name, expected, actual, scope) {
1313
+ return structuralEquals(expected.unreachable, actual.unreachable, scope) ? null : `Response outcome mismatch (${name})\n expected: ${outcomeOf(expected)}\n received: ${outcomeOf(actual)}`;
1314
+ }
1253
1315
  function compareResponse(name, expected, actual, scope) {
1316
+ if (expected.unreachable !== void 0 || actual.unreachable !== void 0) return compareOutcome(name, expected, actual, scope);
1254
1317
  if (!(STATUS_CODE.test(expected.status) ? Number(expected.status) === actual.status : structuralEquals(expected.status, String(actual.status), scope))) return `Response status mismatch (${name})\n expected: ${expected.status}\n received: ${actual.status}`;
1255
1318
  for (const [key, value] of Object.entries(expected.headers)) {
1256
1319
  const actualValue = actual.headers[key.toLowerCase()];
@@ -1603,7 +1666,7 @@ function declaredDatabaseKeys(services) {
1603
1666
  }
1604
1667
  //#endregion
1605
1668
  //#region src/services/containers/run-setup-container.adapter.ts
1606
- function answered(value) {
1669
+ function answered$1(value) {
1607
1670
  const error = field$1(value, "error");
1608
1671
  if (typeof error === "string") return { error };
1609
1672
  const host = field$1(value, "host");
@@ -1634,7 +1697,7 @@ var RunSetupContainer = class {
1634
1697
  this.request = request;
1635
1698
  }
1636
1699
  async start() {
1637
- const answer = answered(await (await fetch(`${this.url}/containers`, {
1700
+ const answer = answered$1(await (await fetch(`${this.url}/containers`, {
1638
1701
  body: JSON.stringify(this.request),
1639
1702
  method: "POST"
1640
1703
  })).json());
@@ -2238,24 +2301,7 @@ function defineAction(definition) {
2238
2301
  };
2239
2302
  }
2240
2303
  //#endregion
2241
- //#region src/specifications/api/request/http.result.ts
2242
- /** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
2243
- var HttpResult = class extends BaseResult {
2244
- responseData;
2245
- constructor(options) {
2246
- super(options);
2247
- this.responseData = options.response;
2248
- }
2249
- /** Access the HTTP response (status, headers, body) for assertions. */
2250
- get response() {
2251
- return new ResponseAccessor(this.responseData, this.testDir, this.captures);
2252
- }
2253
- get status() {
2254
- return this.responseData.status;
2255
- }
2256
- };
2257
- //#endregion
2258
- //#region src/specifications/api/request/request-file.ts
2304
+ //#region src/expectations/http/request-file.ts
2259
2305
  function parseRequestFile(content, fileName) {
2260
2306
  const { body, headerLines, startLine } = splitSections(content);
2261
2307
  const start = /^(?<method>[A-Z]+)\s+(?<path>\S+)$/u.exec(startLine.trim());
@@ -2274,6 +2320,23 @@ function parseRequestFile(content, fileName) {
2274
2320
  };
2275
2321
  }
2276
2322
  //#endregion
2323
+ //#region src/specifications/api/request/http.result.ts
2324
+ /** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
2325
+ var HttpResult = class extends BaseResult {
2326
+ responseData;
2327
+ constructor(options) {
2328
+ super(options);
2329
+ this.responseData = options.response;
2330
+ }
2331
+ /** Access the HTTP response (status, headers, body) for assertions. */
2332
+ get response() {
2333
+ return new ResponseAccessor(this.responseData, this.testDir, this.captures);
2334
+ }
2335
+ get status() {
2336
+ return this.responseData.status;
2337
+ }
2338
+ };
2339
+ //#endregion
2277
2340
  //#region src/specifications/api/request/route.ts
2278
2341
  /** A path with no `:name` segment is sent as written; `{ params: {} }` states that it is the route. */
2279
2342
  const params = {
@@ -2356,15 +2419,15 @@ const ACCESSORS = [
2356
2419
  probe: ".directory("
2357
2420
  }
2358
2421
  ];
2359
- const NO_WORKDIR = { workDir: null };
2422
+ const NO_WORKDIR$1 = { workDir: null };
2360
2423
  function bodyOf(body, testDir) {
2361
2424
  const content = readFileSync(resolve(testDir, body.path), "utf8");
2362
2425
  return body.path.endsWith(".json") ? JSON.parse(content) : content;
2363
2426
  }
2364
- function writtenRequest(source, testDir) {
2427
+ function writtenRequest$1(source, testDir) {
2365
2428
  return typeof source === "string" ? parseRequestFile(dedent(source), "the request in place") : parseRequestFile(readFileSync(resolve(testDir, source.path), "utf8"), source.path);
2366
2429
  }
2367
- async function send({ body, headers: written, method, path, template }, read, { server }, { config, testDir }) {
2430
+ async function send$1({ body, headers: written, method, path, template }, read, { server }, { config, testDir }) {
2368
2431
  if (isDiskDirectory(body)) throw new TypeError(refusal$1("disk-named-by-relative-path-at-runtime", {
2369
2432
  fix: `name one file: .${method.toLowerCase()}(${JSON.stringify(path)}, file('./__requests__/body.json'))`,
2370
2433
  found: `.${method.toLowerCase()}(${JSON.stringify(path)}, directory(${JSON.stringify(body.path)})), a directory where the body is one file`
@@ -2384,15 +2447,15 @@ async function send({ body, headers: written, method, path, template }, read, {
2384
2447
  }
2385
2448
  //#endregion
2386
2449
  //#region src/specifications/api/api.composition.ts
2387
- const composition$6 = {
2450
+ const composition$7 = {
2388
2451
  actions: [
2389
2452
  defineAction({
2390
2453
  accessors: ACCESSORS,
2391
2454
  name: "request",
2392
- perform: async ([source], read, adapters, run) => await send(writtenRequest(source, run.testDir), read, adapters, run),
2455
+ perform: async ([source], read, adapters, run) => await send$1(writtenRequest$1(source, run.testDir), read, adapters, run),
2393
2456
  prepare: ([source]) => {
2394
2457
  if (typeof source !== "string") requireDiskFile("request", source, ".http");
2395
- return NO_WORKDIR;
2458
+ return NO_WORKDIR$1;
2396
2459
  },
2397
2460
  row: {
2398
2461
  group: "Terminal action",
@@ -2404,13 +2467,13 @@ const composition$6 = {
2404
2467
  accessors: ACCESSORS,
2405
2468
  name: "get",
2406
2469
  options: [params],
2407
- perform: async ([path, options], read, adapters, run) => await send({
2470
+ perform: async ([path, options], read, adapters, run) => await send$1({
2408
2471
  method: "GET",
2409
2472
  ...routeRequest("GET", path, options)
2410
2473
  }, read, adapters, run),
2411
2474
  prepare: ([path, options]) => {
2412
2475
  routeRequest("GET", path, options);
2413
- return NO_WORKDIR;
2476
+ return NO_WORKDIR$1;
2414
2477
  },
2415
2478
  row: {
2416
2479
  group: "Terminal action",
@@ -2422,14 +2485,14 @@ const composition$6 = {
2422
2485
  accessors: ACCESSORS,
2423
2486
  name: "post",
2424
2487
  options: [params],
2425
- perform: async ([path, body, options], read, adapters, run) => await send({
2488
+ perform: async ([path, body, options], read, adapters, run) => await send$1({
2426
2489
  body,
2427
2490
  method: "POST",
2428
2491
  ...routeRequest("POST", path, options)
2429
2492
  }, read, adapters, run),
2430
2493
  prepare: ([path, _body, options]) => {
2431
2494
  routeRequest("POST", path, options);
2432
- return NO_WORKDIR;
2495
+ return NO_WORKDIR$1;
2433
2496
  },
2434
2497
  row: {
2435
2498
  group: "Terminal action",
@@ -2441,14 +2504,14 @@ const composition$6 = {
2441
2504
  accessors: ACCESSORS,
2442
2505
  name: "put",
2443
2506
  options: [params],
2444
- perform: async ([path, body, options], read, adapters, run) => await send({
2507
+ perform: async ([path, body, options], read, adapters, run) => await send$1({
2445
2508
  body,
2446
2509
  method: "PUT",
2447
2510
  ...routeRequest("PUT", path, options)
2448
2511
  }, read, adapters, run),
2449
2512
  prepare: ([path, _body, options]) => {
2450
2513
  routeRequest("PUT", path, options);
2451
- return NO_WORKDIR;
2514
+ return NO_WORKDIR$1;
2452
2515
  },
2453
2516
  row: {
2454
2517
  group: "Terminal action",
@@ -2460,13 +2523,13 @@ const composition$6 = {
2460
2523
  accessors: ACCESSORS,
2461
2524
  name: "delete",
2462
2525
  options: [params],
2463
- perform: async ([path, options], read, adapters, run) => await send({
2526
+ perform: async ([path, options], read, adapters, run) => await send$1({
2464
2527
  method: "DELETE",
2465
2528
  ...routeRequest("DELETE", path, options)
2466
2529
  }, read, adapters, run),
2467
2530
  prepare: ([path, options]) => {
2468
2531
  routeRequest("DELETE", path, options);
2469
- return NO_WORKDIR;
2532
+ return NO_WORKDIR$1;
2470
2533
  },
2471
2534
  row: {
2472
2535
  group: "Terminal action",
@@ -2501,7 +2564,7 @@ const composition$6 = {
2501
2564
  }
2502
2565
  };
2503
2566
  const apiDefinition = defineSpecification()({
2504
- ...composition$6,
2567
+ ...composition$7,
2505
2568
  adapters: (config) => ({
2506
2569
  clock: processClock,
2507
2570
  databases: databaseSet(config),
@@ -2511,6 +2574,448 @@ const apiDefinition = defineSpecification()({
2511
2574
  locate: getCallerDir
2512
2575
  });
2513
2576
  //#endregion
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("
2588
+ }
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;
2614
+ }
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);
2618
+ }
2619
+ };
2620
+ //#endregion
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)
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);
2681
+ return {
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
2692
+ };
2693
+ }
2694
+ //#endregion
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();
2751
+ });
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)
2772
+ };
2773
+ }
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
+ };
2786
+ }
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);
2820
+ });
2821
+ outgoing.once("error", reject);
2822
+ outgoing.end(sent.body);
2823
+ });
2824
+ }
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);
2830
+ return {
2831
+ headers: flatHeaders(response.headers),
2832
+ status: response.statusCode ?? 0,
2833
+ text,
2834
+ tls: opening.tls
2835
+ };
2836
+ }
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();
2864
+ }
2865
+ }
2866
+ //#endregion
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
+ };
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
+ });
2931
+ //#endregion
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."
3002
+ }));
3003
+ }
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
+ } });
3017
+ }
3018
+ //#endregion
2514
3019
  //#region src/specifications/api/server/hono.adapter.ts
2515
3020
  const COMPOSED_HANDLER = "__COMPOSED_HANDLER";
2516
3021
  function targetOf(handler) {
@@ -2563,9 +3068,68 @@ var HonoAdapter = class {
2563
3068
  };
2564
3069
  //#endregion
2565
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
+ }));
3086
+ }
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");
3093
+ }
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'`");
3097
+ }
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");
3100
+ }
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);
3107
+ }
3108
+ }
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
+ };
3120
+ return {
3121
+ api: refusingInProcessSetups(apiDeployedDefinition.open(config)),
3122
+ cleanup: async () => {}
3123
+ };
3124
+ }
2566
3125
  async function startApi(options) {
2567
3126
  const callerDir = getCallerDir();
2568
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) {
2569
3133
  const root = resolveRoot(options.root, callerDir);
2570
3134
  const services = declaredServices(options.services);
2571
3135
  const databaseKeys = declaredDatabaseKeys(services);