@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/README.md +1 -1
- package/dist/checker.js +5 -5
- package/dist/config.js +1 -1
- package/dist/index.d.ts +205 -64
- package/dist/index.js +615 -51
- package/dist/oxlint.cjs +1 -1
- package/dist/shared-exchange.js +1 -1
- package/dist/shared-in-place.js +1 -1
- package/dist/shared-manifest.js +1 -1
- package/dist/shared-route-coverage.global-setup.js +1 -1
- package/dist/shared-session-ledger.js +1 -1
- package/dist/shared-stub-server.js +13 -0
- package/dist/shared-testcontainers.adapter.js +1 -1
- package/dist/stubs.js +1 -1
- package/dist/vitest.js +1 -1
- package/docs/README.md +1 -1
- package/docs/concepts/architecture.md +12 -12
- package/docs/guides/api-specs.md +172 -3
- package/docs/guides/website-specs.md +1 -1
- package/docs/reference/assertions.md +8 -0
- package/docs/reference/glossary.md +1 -0
- package/docs/reference/linting.md +1 -1
- package/docs/reference/tokens.md +1 -0
- package/docs/start/getting-started.md +1 -1
- package/docs/start/set-up-a-project.md +1 -1
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1235
|
-
function
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
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/
|
|
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$
|
|
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$
|
|
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);
|