@jterrazz/test 15.2.0 → 15.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 +38 -29
- package/dist/artifacts.js +41 -4
- package/dist/browser/index.js +1364 -1045
- package/dist/checker.js +461 -26
- package/dist/index.d.ts +648 -133
- package/dist/index.js +726 -39
- package/dist/intercept.js +9 -1
- package/dist/oxlint.cjs +47 -14
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +47 -14
- package/dist/playwright.adapter.js +1 -0
- package/dist/{queue.js → stream.js} +60 -1
- package/dist/vitest.d.ts +38 -8
- package/dist/vitest.js +59 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { a as copyPlan, c as GROUND_EXPECTED, d as GROUND_SEEDS, i as match, n as Matcher, t as CaptureScope, u as GROUND_REQUESTS } from "./match.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { a as isStreamBody, c as defineContracts, i as STREAM_BODY, l as isContract, n as ContractQueue, o as contractsOf, r as pause, s as defineContract, t as toReadableStream, u as isContracts } from "./stream.js";
|
|
3
|
+
import { A as mergePreservingPlaceholders, C as DirectoryAccessor, D as inspectContainer, E as findContainersByLabel, F as structuralSubset, I as textEquals, L as TableAccessor, N as renderExpected, O as removeContainers, P as structuralEquals, R as JsonAccessor, S as FilesystemAccessor, T as walkDirectory, _ as validateDatabaseOption, b as resolveCommand, c as shouldUpdateSnapshots, d as formatStartupReport, f as formatStdoutDiff, g as BaseResult, h as CliResult, i as runSpecDocument, j as mergeTextPreservingPlaceholders, l as formatDirectoryDiff, m as ContainerAccessor, n as SQLITE_TEMPLATE_DIR, o as ServeAdapter, p as formatTableDiff, s as missingHint, u as formatResponseDiff, v as discoverRoot, w as diffDirectories, x as resolveRoot, y as expandWorkdir, z as TextAccessor } from "./artifacts.js";
|
|
4
4
|
import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
import { execFile, execSync, spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { Client } from "pg";
|
|
9
|
-
import { mockDeep } from "vitest-mock-extended";
|
|
9
|
+
import { mock, mockDeep } from "vitest-mock-extended";
|
|
10
10
|
import MockDatePackage from "mockdate";
|
|
11
|
+
import { vi } from "vitest";
|
|
11
12
|
import { tmpdir } from "node:os";
|
|
12
13
|
import "node:url";
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
15
|
import { createServer } from "node:http";
|
|
14
16
|
import { promisify } from "node:util";
|
|
15
17
|
import Database from "better-sqlite3";
|
|
16
|
-
import { createHash } from "node:crypto";
|
|
17
18
|
//#region src/integrations/compose/compose-parser.ts
|
|
18
19
|
/**
|
|
19
20
|
* Parse a docker-compose file and extract service definitions.
|
|
@@ -110,6 +111,60 @@ var ComposeStackAdapter = class {
|
|
|
110
111
|
}
|
|
111
112
|
};
|
|
112
113
|
//#endregion
|
|
114
|
+
//#region src/integrations/msw/scope.ts
|
|
115
|
+
/**
|
|
116
|
+
* The network double in MODULE scope — the same contracts, the same queue and
|
|
117
|
+
* the same strictness as a chain's `.intercept()`, for the one kind of test
|
|
118
|
+
* that has no chain to hang them on.
|
|
119
|
+
*
|
|
120
|
+
* A module test whose subject reaches the network had exactly two ways out
|
|
121
|
+
* before this: `vi.stubGlobal('fetch')`, which replaces the platform with a
|
|
122
|
+
* hand-written stand-in, or `msw/node` imported directly, which F6 refuses a
|
|
123
|
+
* test and F8 refuses a consumer to declare. Both answer "what did the outside
|
|
124
|
+
* world reply" in a dialect nothing else in the repository speaks.
|
|
125
|
+
*
|
|
126
|
+
* The scope is an `AsyncDisposable`, so the handlers come off at the end of
|
|
127
|
+
* the block however the block ends — and the strict violation (an outgoing
|
|
128
|
+
* request no contract accepted) is raised THERE, which is what makes the
|
|
129
|
+
* double a guard rather than a convenience.
|
|
130
|
+
*/
|
|
131
|
+
/**
|
|
132
|
+
* The contracts a call declared, whichever of the two forms it used. Exported
|
|
133
|
+
* for its own module test: the bare-request refusal is a runtime guard the
|
|
134
|
+
* compiler already states, so no typed call site can reach it.
|
|
135
|
+
*
|
|
136
|
+
* @internal
|
|
137
|
+
*/
|
|
138
|
+
function declared(requestOrContracts, maybeResponse) {
|
|
139
|
+
if (Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts)) return contractsOf(requestOrContracts);
|
|
140
|
+
if (maybeResponse === void 0) throw new Error("intercept(): a bare request needs its response — pass a contract (defineContract({ request, response })) or the inline pair intercept(request, response).");
|
|
141
|
+
return [{
|
|
142
|
+
request: requestOrContracts,
|
|
143
|
+
response: maybeResponse
|
|
144
|
+
}];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Build the module-scope double on one engine. The node entry hands it msw's
|
|
148
|
+
* server, the page's entry hands it msw's worker: one shape, two transports,
|
|
149
|
+
* exactly as a chain's `.intercept()` already has it.
|
|
150
|
+
*
|
|
151
|
+
* @internal
|
|
152
|
+
*/
|
|
153
|
+
function interceptThrough(register) {
|
|
154
|
+
async function intercept(requestOrContracts, maybeResponse) {
|
|
155
|
+
const contracts = declared(requestOrContracts, maybeResponse);
|
|
156
|
+
if (contracts.length === 0) throw new Error("intercept(): declare at least one contract — an empty list guards nothing, and a subject with no network needs no intercept at all.");
|
|
157
|
+
const registration = await register(contracts);
|
|
158
|
+
return { [Symbol.asyncDispose]: async () => {
|
|
159
|
+
const violation = registration.violation();
|
|
160
|
+
registration.cleanup();
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
if (violation) throw violation;
|
|
163
|
+
} };
|
|
164
|
+
}
|
|
165
|
+
return intercept;
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
113
168
|
//#region src/integrations/postgres/postgres.ts
|
|
114
169
|
var PostgresHandle = class {
|
|
115
170
|
type = "postgres";
|
|
@@ -641,6 +696,18 @@ function wrapJson(data) {
|
|
|
641
696
|
};
|
|
642
697
|
}
|
|
643
698
|
const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
699
|
+
const STREAM_CONTENT_TYPE = "application/octet-stream";
|
|
700
|
+
const SSE_CONTENT_TYPE = "text/event-stream";
|
|
701
|
+
/** The wire form of one event — the frame an `EventSource` parses. */
|
|
702
|
+
function frameEvent(event) {
|
|
703
|
+
const lines = [];
|
|
704
|
+
if (event.event !== void 0) lines.push(`event: ${event.event}`);
|
|
705
|
+
if (event.id !== void 0) lines.push(`id: ${event.id}`);
|
|
706
|
+
if (event.retry !== void 0) lines.push(`retry: ${event.retry}`);
|
|
707
|
+
const payload = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
|
|
708
|
+
for (const line of payload.split("\n")) lines.push(`data: ${line}`);
|
|
709
|
+
return `${lines.join("\n")}\n\n`;
|
|
710
|
+
}
|
|
644
711
|
function matchesBody(body, expected) {
|
|
645
712
|
if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
|
|
646
713
|
if (expected instanceof RegExp) {
|
|
@@ -750,6 +817,52 @@ const http = {
|
|
|
750
817
|
transport: "network-error"
|
|
751
818
|
};
|
|
752
819
|
},
|
|
820
|
+
/**
|
|
821
|
+
* Response: a body that arrives in PIECES, written in order.
|
|
822
|
+
*
|
|
823
|
+
* A subject that reads a response as it lands — a token-by-token render, a
|
|
824
|
+
* progress bar, a reconnect on a half-read body — is specified by the
|
|
825
|
+
* chunks and their order, which a single serialised body cannot state.
|
|
826
|
+
* Honoured by all three engines: msw's server, msw's worker, and the
|
|
827
|
+
* `node:http` stub the website and mobile facets serve from.
|
|
828
|
+
*
|
|
829
|
+
* @example
|
|
830
|
+
* http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 })
|
|
831
|
+
*/
|
|
832
|
+
stream(chunks, init) {
|
|
833
|
+
return {
|
|
834
|
+
status: init?.status ?? 200,
|
|
835
|
+
body: {
|
|
836
|
+
chunks: [...chunks],
|
|
837
|
+
contentType: init?.contentType ?? STREAM_CONTENT_TYPE,
|
|
838
|
+
delayBetweenChunks: init?.delay ?? 0,
|
|
839
|
+
kind: STREAM_BODY
|
|
840
|
+
},
|
|
841
|
+
headers: init?.headers
|
|
842
|
+
};
|
|
843
|
+
},
|
|
844
|
+
/**
|
|
845
|
+
* Response: a server-sent event stream — one chunk per event, framed the
|
|
846
|
+
* way an `EventSource` reads them, served as `text/event-stream`.
|
|
847
|
+
*
|
|
848
|
+
* @example
|
|
849
|
+
* http.sse([{ data: { token: 'Hel' } }, { data: { token: 'lo' } }, { event: 'done', data: '' }])
|
|
850
|
+
*/
|
|
851
|
+
sse(events, init) {
|
|
852
|
+
return {
|
|
853
|
+
status: init?.status ?? 200,
|
|
854
|
+
body: {
|
|
855
|
+
chunks: events.map(frameEvent),
|
|
856
|
+
contentType: SSE_CONTENT_TYPE,
|
|
857
|
+
delayBetweenChunks: init?.delay ?? 0,
|
|
858
|
+
kind: STREAM_BODY
|
|
859
|
+
},
|
|
860
|
+
headers: {
|
|
861
|
+
"cache-control": "no-cache",
|
|
862
|
+
...init?.headers
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
},
|
|
753
866
|
/** Response: a text body, served as `text/plain` (200 by default). */
|
|
754
867
|
text(body, init) {
|
|
755
868
|
return {
|
|
@@ -922,11 +1035,9 @@ const openai = {
|
|
|
922
1035
|
};
|
|
923
1036
|
//#endregion
|
|
924
1037
|
//#region src/vitest/mock-of.ts
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
*/
|
|
929
|
-
const mockOf = mockDeep;
|
|
1038
|
+
function mockOf(options) {
|
|
1039
|
+
return options?.deep === false ? mock() : mockDeep();
|
|
1040
|
+
}
|
|
930
1041
|
//#endregion
|
|
931
1042
|
//#region src/vitest/mock-of-date.ts
|
|
932
1043
|
/**
|
|
@@ -935,6 +1046,132 @@ const mockOf = mockDeep;
|
|
|
935
1046
|
*/
|
|
936
1047
|
const mockOfDate = MockDatePackage;
|
|
937
1048
|
//#endregion
|
|
1049
|
+
//#region src/vitest/clock.ts
|
|
1050
|
+
/** The instant `iso` names, or a refusal that says what an instant looks like. */
|
|
1051
|
+
function instantOf(method, iso) {
|
|
1052
|
+
const at = new Date(iso);
|
|
1053
|
+
if (Number.isNaN(at.getTime())) throw new TypeError(`clock.${method}(): "${iso}" is not an instant — pass an ISO-8601 string (e.g. '2026-03-04T09:30:00Z').`);
|
|
1054
|
+
return at;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* One clock per test — a second one taken inside the first is refused rather
|
|
1058
|
+
* than nested.
|
|
1059
|
+
*
|
|
1060
|
+
* Disposing gives the REAL clock back, which is the only thing vitest's fake
|
|
1061
|
+
* timers can restore: the instant, the `toFake` set and the queue a previous
|
|
1062
|
+
* scope held are not readable, so an inner scope that ended would silently end
|
|
1063
|
+
* the outer one too. A refusal says which of the two the test meant.
|
|
1064
|
+
*/
|
|
1065
|
+
function take(method, options) {
|
|
1066
|
+
if (vi.isFakeTimers()) throw new Error(`clock.${method}(): a clock is already pinned — one scope per test. End the first scope before taking another, or state the instant on the chain (\`.clock(iso)\`) rather than around it.`);
|
|
1067
|
+
vi.useFakeTimers(options);
|
|
1068
|
+
return { [Symbol.dispose]: () => {
|
|
1069
|
+
vi.useRealTimers();
|
|
1070
|
+
} };
|
|
1071
|
+
}
|
|
1072
|
+
const clock = {
|
|
1073
|
+
/**
|
|
1074
|
+
* Move the pinned clock forward by `ms` — and, under {@link clock.run},
|
|
1075
|
+
* fire every callback that came due, awaiting what each of them started.
|
|
1076
|
+
*
|
|
1077
|
+
* Only a pinned clock moves: called with no clock taken, it says so rather
|
|
1078
|
+
* than silently doing nothing to the real one.
|
|
1079
|
+
*/
|
|
1080
|
+
async advance(ms) {
|
|
1081
|
+
if (!vi.isFakeTimers()) throw new Error("clock.advance(): no clock is pinned — take one first with `using _ = clock.at('<iso>')` (the calendar) or `using _ = clock.run()` (the scheduler).");
|
|
1082
|
+
await vi.advanceTimersByTimeAsync(ms);
|
|
1083
|
+
},
|
|
1084
|
+
/**
|
|
1085
|
+
* Pin `Date` at `iso` for the current scope. `Date` ONLY: the scheduler
|
|
1086
|
+
* stays real, so a subject that awaits, renders or polls keeps running.
|
|
1087
|
+
*
|
|
1088
|
+
* @example
|
|
1089
|
+
* using _ = clock.at('2026-03-04T09:30:00Z');
|
|
1090
|
+
* expect(stampedAt()).toBe('2026-03-04T09:30:00.000Z');
|
|
1091
|
+
*/
|
|
1092
|
+
at(iso) {
|
|
1093
|
+
return take("at", {
|
|
1094
|
+
now: instantOf("at", iso),
|
|
1095
|
+
toFake: ["Date"]
|
|
1096
|
+
});
|
|
1097
|
+
},
|
|
1098
|
+
/**
|
|
1099
|
+
* Take the scheduler as well as the calendar: `setTimeout`, `setInterval`
|
|
1100
|
+
* and their friends queue instead of firing, and {@link clock.advance} is
|
|
1101
|
+
* what makes them due. For a subject whose behaviour IS elapsed time — a
|
|
1102
|
+
* debounce, a retry backoff, a poll — where waiting for real would be the
|
|
1103
|
+
* arbitrary sleep J2 forbids.
|
|
1104
|
+
*
|
|
1105
|
+
* @example
|
|
1106
|
+
* using _ = clock.run('2026-03-04T09:30:00Z');
|
|
1107
|
+
* const pending = retrying(failsTwice);
|
|
1108
|
+
* await clock.advance(5_000);
|
|
1109
|
+
* await expect(pending).resolves.toBe('ok');
|
|
1110
|
+
*/
|
|
1111
|
+
run(iso) {
|
|
1112
|
+
return take("run", iso === void 0 ? {} : { now: instantOf("run", iso) });
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
//#endregion
|
|
1116
|
+
//#region src/specification/assertions/required.ts
|
|
1117
|
+
/**
|
|
1118
|
+
* The value, or a failure that says what was missing and why it mattered.
|
|
1119
|
+
*
|
|
1120
|
+
* A test reaching into a captured structure — a row, a header, the third
|
|
1121
|
+
* element of a list — meets `T | undefined` and has to do something about it.
|
|
1122
|
+
* The two things it usually does are both wrong: `!` erases the question, and
|
|
1123
|
+
* an `if (!x) throw new Error('missing')` re-states the same three lines in
|
|
1124
|
+
* every repository (the estate had two hand-written copies of exactly this).
|
|
1125
|
+
* `required` is that gesture with the reason attached, so the failure reads as
|
|
1126
|
+
* a sentence rather than as a `TypeError` two frames further down.
|
|
1127
|
+
*
|
|
1128
|
+
* @example
|
|
1129
|
+
* const id = required(result.response.body.id, 'the creation reply carries the new id');
|
|
1130
|
+
*/
|
|
1131
|
+
function required(value, why) {
|
|
1132
|
+
if (value === null || value === void 0) throw new Error(`required(): ${why} — got ${value === null ? "null" : "undefined"}. State it in the Given, or assert on what the subject actually produced.`);
|
|
1133
|
+
return value;
|
|
1134
|
+
}
|
|
1135
|
+
//#endregion
|
|
1136
|
+
//#region src/specification/assertions/wait-until.ts
|
|
1137
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
1138
|
+
const DEFAULT_INTERVAL_MS = 50;
|
|
1139
|
+
/**
|
|
1140
|
+
* Wait until a condition holds — the sanctioned answer to "the thing I am
|
|
1141
|
+
* specifying is not ready yet".
|
|
1142
|
+
*
|
|
1143
|
+
* A spec that sleeps for a guessed duration is a spec that is slow on a fast
|
|
1144
|
+
* machine and flaky on a slow one, which is why J2 refuses `setTimeout` under
|
|
1145
|
+
* `specs/`. Every facet that drives something already waits on a CONDITION —
|
|
1146
|
+
* a served page, a banner on stdout, an element the visitor can see — and this
|
|
1147
|
+
* is that primitive where no facet owns the waiting: a background write to
|
|
1148
|
+
* land, a queue to drain, a file to appear.
|
|
1149
|
+
*
|
|
1150
|
+
* The failure names the condition and how long it was given, so a timeout is a
|
|
1151
|
+
* sentence rather than "expected true, got false".
|
|
1152
|
+
*
|
|
1153
|
+
* The budget is measured on `performance.now()`, not on `Date` — `clock.at()`
|
|
1154
|
+
* freezes the calendar, and a deadline read off a frozen calendar never
|
|
1155
|
+
* arrives: the poll would run until vitest killed the test with a sentence
|
|
1156
|
+
* about the file rather than about the condition.
|
|
1157
|
+
*
|
|
1158
|
+
* Not for a subject under `clock.run()`: there the scheduler is the test's, and
|
|
1159
|
+
* `clock.advance(ms)` is what makes time pass.
|
|
1160
|
+
*
|
|
1161
|
+
* @example
|
|
1162
|
+
* await waitUntil(() => outbox.length === 1, { why: 'the job wrote its one message' });
|
|
1163
|
+
*/
|
|
1164
|
+
async function waitUntil(predicate, options = {}) {
|
|
1165
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
1166
|
+
const interval = options.interval ?? DEFAULT_INTERVAL_MS;
|
|
1167
|
+
const deadline = performance.now() + timeout;
|
|
1168
|
+
for (;;) {
|
|
1169
|
+
if (await predicate()) return;
|
|
1170
|
+
if (performance.now() >= deadline) throw new Error(`waitUntil(): ${options.why ?? "the condition"} did not hold within ${timeout}ms. Raise the budget if the wait is legitimate, or wait on what actually changes.`);
|
|
1171
|
+
await pause(interval);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
//#endregion
|
|
938
1175
|
//#region src/integrations/hono/hono.adapter.ts
|
|
939
1176
|
/**
|
|
940
1177
|
* Server adapter that dispatches requests in-process through a Hono app instance.
|
|
@@ -1210,7 +1447,7 @@ function matchStreamFile(accessor, name, frozen) {
|
|
|
1210
1447
|
writeFileSync(filePath, merged);
|
|
1211
1448
|
return PASS(`updated ${GROUND_EXPECTED}/${name}`);
|
|
1212
1449
|
}
|
|
1213
|
-
if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${
|
|
1450
|
+
if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
|
|
1214
1451
|
return compareStreamText(accessor, name, readFileSync(filePath, "utf8"));
|
|
1215
1452
|
}
|
|
1216
1453
|
function matchJsonFile(accessor, name, frozen) {
|
|
@@ -1223,7 +1460,7 @@ function matchJsonFile(accessor, name, frozen) {
|
|
|
1223
1460
|
writeFileSync(filePath, formatJson(merged));
|
|
1224
1461
|
return PASS(`updated ${GROUND_EXPECTED}/${name}`);
|
|
1225
1462
|
}
|
|
1226
|
-
if (!existsSync(filePath)) return FAIL(`JSON fixture "${name}" does not exist at ${filePath}.\n${
|
|
1463
|
+
if (!existsSync(filePath)) return FAIL(`JSON fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
|
|
1227
1464
|
const expected = JSON.parse(readFileSync(filePath, "utf8"));
|
|
1228
1465
|
if (structuralEquals(expected, actual, accessor.captures)) return PASS(`expected JSON not to match ${GROUND_EXPECTED}/${name}`);
|
|
1229
1466
|
return FAIL(formatResponseDiff(name, renderExpected(expected), actual));
|
|
@@ -1279,7 +1516,7 @@ function matchResponseFile(accessor, name, frozen) {
|
|
|
1279
1516
|
writeFileSync(filePath, serializeResponseFile(updated));
|
|
1280
1517
|
return PASS(`updated ${GROUND_EXPECTED}/${name}`);
|
|
1281
1518
|
}
|
|
1282
|
-
if (!existsSync(filePath)) return FAIL(`Response fixture "${name}" does not exist at ${filePath}.\n${
|
|
1519
|
+
if (!existsSync(filePath)) return FAIL(`Response fixture "${name}" does not exist at ${filePath}.\n${missingHint(frozen)}`);
|
|
1283
1520
|
const failure = compareResponse(name, parseResponseFile(readFileSync(filePath, "utf8"), `${GROUND_EXPECTED}/${name}`), accessor, accessor.captures);
|
|
1284
1521
|
return failure === null ? PASS(`expected response not to match ${GROUND_EXPECTED}/${name}`) : FAIL(failure);
|
|
1285
1522
|
}
|
|
@@ -1302,7 +1539,7 @@ async function matchTreeFile(actualRoot, testDir, name, scope, frozen) {
|
|
|
1302
1539
|
}
|
|
1303
1540
|
return PASS(`updated ${GROUND_EXPECTED}/${name}/`);
|
|
1304
1541
|
}
|
|
1305
|
-
if (!existsSync(fixtureDir)) return FAIL(`Directory fixture "${name}" does not exist at ${fixtureDir}.\n${
|
|
1542
|
+
if (!existsSync(fixtureDir)) return FAIL(`Directory fixture "${name}" does not exist at ${fixtureDir}.\n${missingHint(frozen)}`);
|
|
1306
1543
|
const diff = await diffDirectories(fixtureDir, actualRoot, { scope });
|
|
1307
1544
|
if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return PASS(`expected directory not to match ${GROUND_EXPECTED}/${name}/`);
|
|
1308
1545
|
return FAIL(formatDirectoryDiff(name, diff, "Run with TEST_UPDATE=1 to update the fixture."));
|
|
@@ -1347,8 +1584,15 @@ function toContain(received, expected) {
|
|
|
1347
1584
|
if (received instanceof TextAccessor) return textContains(received, String(expected));
|
|
1348
1585
|
return nativeContain(received, expected);
|
|
1349
1586
|
}
|
|
1587
|
+
/** Is this the shape, before the adapter is asked to build SQL from it? */
|
|
1588
|
+
function isRowsExpectation(expected) {
|
|
1589
|
+
if (expected === null || typeof expected !== "object") return false;
|
|
1590
|
+
const { columns, rows } = expected;
|
|
1591
|
+
return Array.isArray(columns) && Array.isArray(rows);
|
|
1592
|
+
}
|
|
1350
1593
|
async function toMatchRows(received, expected) {
|
|
1351
1594
|
if (!(received instanceof TableAccessor)) throw new TypeError("toMatchRows: unsupported subject — expected result.table(...).");
|
|
1595
|
+
if (!isRowsExpectation(expected)) throw new TypeError(`toMatchRows takes { columns, rows } — a column list and one array of cells per row (received ${Array.isArray(expected) ? "an array" : `a ${typeof expected}`}).`);
|
|
1352
1596
|
const actual = await received.query(expected.columns);
|
|
1353
1597
|
const pass = actual.length === expected.rows.length && expected.rows.every((row, i) => {
|
|
1354
1598
|
const actualRow = actual[i];
|
|
@@ -1423,6 +1667,61 @@ var HttpResult = class extends BaseResult {
|
|
|
1423
1667
|
}
|
|
1424
1668
|
};
|
|
1425
1669
|
//#endregion
|
|
1670
|
+
//#region src/specification/facets/integration/result.ts
|
|
1671
|
+
/**
|
|
1672
|
+
* The result of an in-process call — the integration facet's whole surface.
|
|
1673
|
+
*
|
|
1674
|
+
* Two readings, never both: `value` is what the call RETURNED, `error` is
|
|
1675
|
+
* what it THREW. A module that refuses is specified the same way a module
|
|
1676
|
+
* that answers is, and neither needs a `try`/`catch` in the test — which is
|
|
1677
|
+
* the shape that used to make a refusal spec three times the size of the
|
|
1678
|
+
* happy one, and the shape that quietly passes when nothing throws at all.
|
|
1679
|
+
*
|
|
1680
|
+
* Both are the package's ordinary accessors, so the golden mechanism reaches
|
|
1681
|
+
* them, and `Returned` is what `.call<T>()` returned — `result.value.value` is
|
|
1682
|
+
* that type, not `unknown`, so a one-field reading needs no golden file: `toMatch('<name>.json'|'<name>.txt')`, the `{{token}}` grammar,
|
|
1683
|
+
* `{ frozen }` and `TEST_UPDATE=1` all work here exactly as on an HTTP
|
|
1684
|
+
* response or a command's stdout.
|
|
1685
|
+
*/
|
|
1686
|
+
var CallResult = class extends BaseResult {
|
|
1687
|
+
outcome;
|
|
1688
|
+
constructor(options) {
|
|
1689
|
+
super(options);
|
|
1690
|
+
this.outcome = options.outcome;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* What the call THREW, as text — the message of an `Error`, the value
|
|
1694
|
+
* itself when something else was thrown, and empty when it returned.
|
|
1695
|
+
*
|
|
1696
|
+
* Empty rather than absent on purpose: `await expect(result.error).toBeEmpty()`
|
|
1697
|
+
* is how a spec says "and it did not refuse" — awaited, because the
|
|
1698
|
+
* matcher answers a promise on every subject it takes, and a dropped one
|
|
1699
|
+
* passes the test while the failure surfaces as an unhandled rejection
|
|
1700
|
+
* (D2 refuses the bare form).
|
|
1701
|
+
*/
|
|
1702
|
+
get error() {
|
|
1703
|
+
return new TextAccessor(describeError(this.outcome), "error", this.testDir, { captures: this.captures });
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* What the call RETURNED. A string reads as a stream (`<name>.txt`),
|
|
1707
|
+
* anything else as JSON (`<name>.json`) — the same two subjects every
|
|
1708
|
+
* other facet's results are built from.
|
|
1709
|
+
*
|
|
1710
|
+
* A call that threw returns the empty value: what it produced is
|
|
1711
|
+
* {@link CallResult.error}.
|
|
1712
|
+
*/
|
|
1713
|
+
get value() {
|
|
1714
|
+
const produced = this.outcome.threw ? null : this.outcome.value;
|
|
1715
|
+
return typeof produced === "string" ? new TextAccessor(produced, "value", this.testDir, { captures: this.captures }) : new JsonAccessor(JSON.stringify(produced ?? null), this.testDir, void 0, this.captures);
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
/** The thrown thing, as the text a golden compares. */
|
|
1719
|
+
function describeError(outcome) {
|
|
1720
|
+
if (!outcome.threw) return "";
|
|
1721
|
+
const { error } = outcome;
|
|
1722
|
+
return error instanceof Error ? error.message : String(error);
|
|
1723
|
+
}
|
|
1724
|
+
//#endregion
|
|
1426
1725
|
//#region src/specification/facets/mobile/result.ts
|
|
1427
1726
|
/** Result from a `.open()` action — the screen as the device saw it, final state. */
|
|
1428
1727
|
var ScreenResult = class extends BaseResult {
|
|
@@ -1713,6 +2012,7 @@ function getCallerDir() {
|
|
|
1713
2012
|
* only surfaces the methods that make sense for it.
|
|
1714
2013
|
*/
|
|
1715
2014
|
var SpecificationBuilder = class {
|
|
2015
|
+
pinnedClock = null;
|
|
1716
2016
|
commandEnv = {};
|
|
1717
2017
|
config;
|
|
1718
2018
|
contracts = [];
|
|
@@ -1725,6 +2025,22 @@ var SpecificationBuilder = class {
|
|
|
1725
2025
|
this.testDir = testDir;
|
|
1726
2026
|
}
|
|
1727
2027
|
/**
|
|
2028
|
+
* Pin the clock at `iso` for this chain.
|
|
2029
|
+
*
|
|
2030
|
+
* In-process facets (api, jobs, integration) pin the runner's own `Date`,
|
|
2031
|
+
* which is the app's; a website visit pins the PAGE's, through the browser.
|
|
2032
|
+
* Either way the clock is released when the action resolves, so a chain
|
|
2033
|
+
* never leaks its instant into the next one (CONVENTIONS D16).
|
|
2034
|
+
*
|
|
2035
|
+
* @example
|
|
2036
|
+
* const result = await api.clock('2026-03-04T09:30:00Z').get('/now');
|
|
2037
|
+
*/
|
|
2038
|
+
clock(iso) {
|
|
2039
|
+
if (this.config.clockDisabledReason !== void 0) throw new Error(`.clock(): ${this.config.clockDisabledReason}`);
|
|
2040
|
+
this.pinnedClock = iso;
|
|
2041
|
+
return this;
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
1728
2044
|
* Queue a SQL seed file to run before the action.
|
|
1729
2045
|
*
|
|
1730
2046
|
* With two or more declared databases the `database` option is mandatory;
|
|
@@ -1925,6 +2241,7 @@ var SpecificationBuilder = class {
|
|
|
1925
2241
|
* expect(result.body).toMatch('robots.txt');
|
|
1926
2242
|
*/
|
|
1927
2243
|
async fetch(path) {
|
|
2244
|
+
if (this.pinnedClock !== null) throw new Error(".clock() pins the clock of a PAGE, and .fetch() opens none — it is one raw HTTP exchange. Assert the moment with a token ({{iso8601}}) in the golden, or visit the page.");
|
|
1928
2245
|
return await this.executeSetup(null, async () => await this.runFetchAction(path));
|
|
1929
2246
|
}
|
|
1930
2247
|
/**
|
|
@@ -1964,6 +2281,22 @@ var SpecificationBuilder = class {
|
|
|
1964
2281
|
return await this.executeSetup(null, async () => await this.runOpenAction(deepLink, scenario));
|
|
1965
2282
|
}
|
|
1966
2283
|
/**
|
|
2284
|
+
* Call the module under test and resolve with what it produced — the
|
|
2285
|
+
* value it returned, or the error it threw.
|
|
2286
|
+
*
|
|
2287
|
+
* A refusal is a reading, never a `try`/`catch`: `result.error` carries
|
|
2288
|
+
* what was thrown, and `result.value` what was returned. That is what
|
|
2289
|
+
* keeps a spec of a refusal the same size as a spec of a success, and
|
|
2290
|
+
* what stops a "it should throw" spec from passing when nothing does.
|
|
2291
|
+
*
|
|
2292
|
+
* @example
|
|
2293
|
+
* const result = await integration.seed('rows.sql').call(({ db }) => find(db.connectionString, id));
|
|
2294
|
+
* expect(result.value).toMatch('found.json');
|
|
2295
|
+
*/
|
|
2296
|
+
async call(subject) {
|
|
2297
|
+
return await this.executeSetup(null, async () => await this.runCallAction(subject));
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
1967
2300
|
* Execute the named job registered via the `jobs` option of
|
|
1968
2301
|
* `specification.jobs()` and resolve with the result.
|
|
1969
2302
|
*
|
|
@@ -2007,6 +2340,8 @@ var SpecificationBuilder = class {
|
|
|
2007
2340
|
const { registerContracts } = await import("./intercept.js");
|
|
2008
2341
|
registration = await registerContracts(this.contracts);
|
|
2009
2342
|
}
|
|
2343
|
+
const instant = this.pinnedClock;
|
|
2344
|
+
const pinned = instant !== null && this.config.baseUrl === void 0 ? clock.at(instant) : null;
|
|
2010
2345
|
try {
|
|
2011
2346
|
const value = await action();
|
|
2012
2347
|
const violation = registration?.violation() ?? this.config.backend?.violation();
|
|
@@ -2015,6 +2350,7 @@ var SpecificationBuilder = class {
|
|
|
2015
2350
|
} catch (error) {
|
|
2016
2351
|
throw registration?.violation() ?? this.config.backend?.violation() ?? error;
|
|
2017
2352
|
} finally {
|
|
2353
|
+
pinned?.[Symbol.dispose]();
|
|
2018
2354
|
registration?.cleanup();
|
|
2019
2355
|
}
|
|
2020
2356
|
}
|
|
@@ -2025,6 +2361,28 @@ var SpecificationBuilder = class {
|
|
|
2025
2361
|
prepareWorkDir() {
|
|
2026
2362
|
return mkdtempSync(resolve(tmpdir(), "spec-command-"));
|
|
2027
2363
|
}
|
|
2364
|
+
async runCallAction(subject) {
|
|
2365
|
+
const services = this.config.services ?? {};
|
|
2366
|
+
let outcome;
|
|
2367
|
+
try {
|
|
2368
|
+
outcome = {
|
|
2369
|
+
error: null,
|
|
2370
|
+
threw: false,
|
|
2371
|
+
value: await subject(services)
|
|
2372
|
+
};
|
|
2373
|
+
} catch (error) {
|
|
2374
|
+
outcome = {
|
|
2375
|
+
error,
|
|
2376
|
+
threw: true,
|
|
2377
|
+
value: null
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
return new CallResult({
|
|
2381
|
+
config: this.config,
|
|
2382
|
+
outcome,
|
|
2383
|
+
testDir: this.testDir
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2028
2386
|
async runHttpAction(request) {
|
|
2029
2387
|
if (!this.config.server) throw new Error("HTTP actions require a server adapter (use specification.api())");
|
|
2030
2388
|
let { body, method, path } = request;
|
|
@@ -2076,6 +2434,7 @@ var SpecificationBuilder = class {
|
|
|
2076
2434
|
const page = await browser.open(`${baseUrl}${path}`, {
|
|
2077
2435
|
allowedOrigins: this.config.backendUrl ? [new URL(this.config.backendUrl).origin] : void 0,
|
|
2078
2436
|
baseUrl,
|
|
2437
|
+
...this.pinnedClock === null ? {} : { clock: this.pinnedClock },
|
|
2079
2438
|
external: this.config.external ?? "allow",
|
|
2080
2439
|
headers,
|
|
2081
2440
|
scenario
|
|
@@ -2145,7 +2504,11 @@ var SpecificationBuilder = class {
|
|
|
2145
2504
|
* the chain's own `.env()` — which always wins, `null` unsetting.
|
|
2146
2505
|
*/
|
|
2147
2506
|
childEnv(workDir) {
|
|
2148
|
-
|
|
2507
|
+
const declared = this.config.defaultEnv;
|
|
2508
|
+
let env = declared ? {
|
|
2509
|
+
...expandWorkdir(declared, workDir),
|
|
2510
|
+
...this.serviceEnv()
|
|
2511
|
+
} : this.serviceEnv();
|
|
2149
2512
|
const { dockerConfig } = this.config;
|
|
2150
2513
|
if (dockerConfig && this.config.dockerTestRunId) env = {
|
|
2151
2514
|
...env,
|
|
@@ -2221,6 +2584,7 @@ function withDockerTestRunId(config) {
|
|
|
2221
2584
|
function createApiFacet(config) {
|
|
2222
2585
|
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2223
2586
|
return {
|
|
2587
|
+
clock: (iso) => start().clock(iso),
|
|
2224
2588
|
delete: async (path) => await start().delete(path),
|
|
2225
2589
|
get: async (path) => await start().get(path),
|
|
2226
2590
|
headers: (headers) => start().headers(headers),
|
|
@@ -2237,17 +2601,31 @@ function createApiFacet(config) {
|
|
|
2237
2601
|
function createJobsFacet(config) {
|
|
2238
2602
|
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2239
2603
|
return {
|
|
2604
|
+
clock: (iso) => start().clock(iso),
|
|
2240
2605
|
intercept: interceptOn(start),
|
|
2241
2606
|
seed: (file, options) => start().seed(file, options),
|
|
2242
2607
|
trigger: async (name) => await start().trigger(name)
|
|
2243
2608
|
};
|
|
2244
2609
|
}
|
|
2245
2610
|
/**
|
|
2611
|
+
* Create the `integration` facet bound to the given adapter configuration.
|
|
2612
|
+
*/
|
|
2613
|
+
function createIntegrationFacet(config) {
|
|
2614
|
+
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2615
|
+
return {
|
|
2616
|
+
call: async (subject) => await start().call(subject),
|
|
2617
|
+
clock: (iso) => start().clock(iso),
|
|
2618
|
+
intercept: interceptOn(start),
|
|
2619
|
+
seed: (file, options) => start().seed(file, options)
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2246
2623
|
* Create the `website` facet bound to the given adapter configuration.
|
|
2247
2624
|
*/
|
|
2248
2625
|
function createWebsiteFacet(config) {
|
|
2249
2626
|
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2250
2627
|
return {
|
|
2628
|
+
clock: (iso) => start().clock(iso),
|
|
2251
2629
|
fetch: async (path) => await start().fetch(path),
|
|
2252
2630
|
headers: (headers) => start().headers(headers),
|
|
2253
2631
|
intercept: interceptOn(start),
|
|
@@ -2561,6 +2939,124 @@ var Orchestrator = class {
|
|
|
2561
2939
|
}
|
|
2562
2940
|
};
|
|
2563
2941
|
//#endregion
|
|
2942
|
+
//#region src/specification/facets/_common/process.ts
|
|
2943
|
+
/**
|
|
2944
|
+
* An external process, declared like any other service.
|
|
2945
|
+
*
|
|
2946
|
+
* Before this there were three shapes for the same thing: `website({ server })`
|
|
2947
|
+
* took a `ServeOptions`, a literate document took a `serve:` entry, and a
|
|
2948
|
+
* project that needed a second process — an API behind the site, Metro behind
|
|
2949
|
+
* the simulator — started one by hand in a `beforeAll` and killed it in an
|
|
2950
|
+
* `afterAll` it sometimes forgot. `process()` is the one shape, and the
|
|
2951
|
+
* framework owns the lifetime.
|
|
2952
|
+
*
|
|
2953
|
+
* It sits in a `services` record beside `postgres()`, `redis()` and
|
|
2954
|
+
* `sqlite()`, so it starts after them and can be handed their connection
|
|
2955
|
+
* strings through `env`, and it is stopped with the specification.
|
|
2956
|
+
*/
|
|
2957
|
+
var ProcessHandle = class {
|
|
2958
|
+
/** Embedded: nothing to pull, nothing to run in a container. */
|
|
2959
|
+
defaultImage = "";
|
|
2960
|
+
defaultPort = 0;
|
|
2961
|
+
environment = {};
|
|
2962
|
+
type = "process";
|
|
2963
|
+
composeName = null;
|
|
2964
|
+
connectionString = "";
|
|
2965
|
+
started = false;
|
|
2966
|
+
adapter = null;
|
|
2967
|
+
options;
|
|
2968
|
+
constructor(options) {
|
|
2969
|
+
this.options = options;
|
|
2970
|
+
}
|
|
2971
|
+
buildConnectionString(host, port) {
|
|
2972
|
+
return `http://${host}:${port}`;
|
|
2973
|
+
}
|
|
2974
|
+
createDatabaseAdapter() {
|
|
2975
|
+
return null;
|
|
2976
|
+
}
|
|
2977
|
+
async healthcheck() {
|
|
2978
|
+
await Promise.resolve();
|
|
2979
|
+
}
|
|
2980
|
+
/**
|
|
2981
|
+
* A process is not a container, so the orchestrator never starts it: the
|
|
2982
|
+
* facet does, once its siblings are up and their connection strings are
|
|
2983
|
+
* readable ({@link startProcessServices}).
|
|
2984
|
+
*/
|
|
2985
|
+
async initialize() {
|
|
2986
|
+
await Promise.resolve();
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Isolation is the RUN's, not the worker's — and a run here is one
|
|
2990
|
+
* EVALUATION of the specification module. Under vitest's default
|
|
2991
|
+
* isolation that is once per test file, so two files that import the same
|
|
2992
|
+
* specification get a child each, with the id the facet minted for it.
|
|
2993
|
+
* Nothing is acquired or reset between tests: the id is what tells two
|
|
2994
|
+
* runs apart, never a label a spec samples.
|
|
2995
|
+
*/
|
|
2996
|
+
isolation() {
|
|
2997
|
+
return {
|
|
2998
|
+
acquire: async () => {
|
|
2999
|
+
await Promise.resolve();
|
|
3000
|
+
},
|
|
3001
|
+
release: async () => {
|
|
3002
|
+
await Promise.resolve();
|
|
3003
|
+
},
|
|
3004
|
+
reset: async () => {
|
|
3005
|
+
await Promise.resolve();
|
|
3006
|
+
}
|
|
3007
|
+
};
|
|
3008
|
+
}
|
|
3009
|
+
async reset() {
|
|
3010
|
+
await Promise.resolve();
|
|
3011
|
+
}
|
|
3012
|
+
/**
|
|
3013
|
+
* Spawn it and wait until it answers. `services` is every handle declared
|
|
3014
|
+
* beside this one, already started — what an `env` function reads.
|
|
3015
|
+
*
|
|
3016
|
+
* @internal
|
|
3017
|
+
*/
|
|
3018
|
+
async spawnWith(root, services, runId) {
|
|
3019
|
+
const stated = typeof this.options.env === "function" ? this.options.env(services) : this.options.env;
|
|
3020
|
+
this.adapter = new ServeAdapter(this.options, root, "process", {
|
|
3021
|
+
...stated,
|
|
3022
|
+
TEST_RUN_ID: runId
|
|
3023
|
+
});
|
|
3024
|
+
this.connectionString = await this.adapter.start();
|
|
3025
|
+
this.started = true;
|
|
3026
|
+
}
|
|
3027
|
+
/** Terminate the process group. Idempotent. */
|
|
3028
|
+
async shutdown() {
|
|
3029
|
+
await this.adapter?.stop();
|
|
3030
|
+
this.adapter = null;
|
|
3031
|
+
this.started = false;
|
|
3032
|
+
}
|
|
3033
|
+
/** What it was declared as — what a facet reads to start it in a slot of its own. */
|
|
3034
|
+
get spec() {
|
|
3035
|
+
return this.options;
|
|
3036
|
+
}
|
|
3037
|
+
/** The base URL it came up on — known only once it is started. */
|
|
3038
|
+
get url() {
|
|
3039
|
+
if (!this.started) throw new Error("process(): the URL is known once the process is ready — read it from the started services record, never at declaration time.");
|
|
3040
|
+
return this.connectionString;
|
|
3041
|
+
}
|
|
3042
|
+
};
|
|
3043
|
+
/**
|
|
3044
|
+
* Declare an external process the framework owns for the life of the
|
|
3045
|
+
* specification.
|
|
3046
|
+
*
|
|
3047
|
+
* @example
|
|
3048
|
+
* const { website, cleanup } = await specification.website({
|
|
3049
|
+
* services: { api: process({ command: 'bin/server web', ready: '/health' }) },
|
|
3050
|
+
* server: ({ api }) => process({
|
|
3051
|
+
* command: 'next dev',
|
|
3052
|
+
* env: { NEXT_PUBLIC_API_URL: api.connectionString },
|
|
3053
|
+
* }),
|
|
3054
|
+
* });
|
|
3055
|
+
*/
|
|
3056
|
+
function processService(options) {
|
|
3057
|
+
return new ProcessHandle(options);
|
|
3058
|
+
}
|
|
3059
|
+
//#endregion
|
|
2564
3060
|
//#region src/specification/facets/_common/services.ts
|
|
2565
3061
|
function getWorkerId() {
|
|
2566
3062
|
return process.env.VITEST_POOL_ID ?? "0";
|
|
@@ -2575,20 +3071,64 @@ async function releaseIsolation(services) {
|
|
|
2575
3071
|
function declaredDatabaseKeys(services) {
|
|
2576
3072
|
return Object.entries(services).filter(([, service]) => service.createDatabaseAdapter() !== null).map(([key]) => key);
|
|
2577
3073
|
}
|
|
3074
|
+
/**
|
|
3075
|
+
* A process is not a container: the orchestrator never sees one, and the
|
|
3076
|
+
* facet starts it AFTER the rest, so an `env` function reads connection
|
|
3077
|
+
* strings that are already there.
|
|
3078
|
+
*/
|
|
3079
|
+
function splitProcesses(services) {
|
|
3080
|
+
const infrastructure = {};
|
|
3081
|
+
const processes = [];
|
|
3082
|
+
for (const [key, handle] of Object.entries(services)) if (handle instanceof ProcessHandle) processes.push([key, handle]);
|
|
3083
|
+
else infrastructure[key] = handle;
|
|
3084
|
+
return {
|
|
3085
|
+
infrastructure,
|
|
3086
|
+
processes
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Start every declared process, in declaration order, once the infrastructure
|
|
3091
|
+
* is up — so `env: ({ db }) => …` reads a connection string that exists. Each
|
|
3092
|
+
* carries the run's id, minted here: a label a spec would otherwise sample
|
|
3093
|
+
* from `Date.now()` and put under an oracle (CONVENTIONS D16, P7).
|
|
3094
|
+
*
|
|
3095
|
+
* @internal
|
|
3096
|
+
*/
|
|
3097
|
+
async function startProcessServices(services, root, runId) {
|
|
3098
|
+
const { processes } = splitProcesses(services);
|
|
3099
|
+
const started = [];
|
|
3100
|
+
const stop = async () => {
|
|
3101
|
+
for (const handle of started.toReversed()) await handle.shutdown();
|
|
3102
|
+
started.length = 0;
|
|
3103
|
+
};
|
|
3104
|
+
for (const [, handle] of processes) try {
|
|
3105
|
+
await handle.spawnWith(root, services, runId);
|
|
3106
|
+
started.push(handle);
|
|
3107
|
+
} catch (error) {
|
|
3108
|
+
await stop();
|
|
3109
|
+
throw error;
|
|
3110
|
+
}
|
|
3111
|
+
return stop;
|
|
3112
|
+
}
|
|
2578
3113
|
/** Start a services record via testcontainers and acquire worker isolation. */
|
|
2579
3114
|
async function startServices(services, root) {
|
|
3115
|
+
const { infrastructure } = splitProcesses(services);
|
|
2580
3116
|
const orchestrator = new Orchestrator({
|
|
2581
3117
|
mode: "integration",
|
|
2582
3118
|
root,
|
|
2583
|
-
services
|
|
3119
|
+
services: infrastructure
|
|
2584
3120
|
});
|
|
2585
3121
|
await orchestrator.start();
|
|
2586
|
-
await acquireIsolation(
|
|
3122
|
+
await acquireIsolation(infrastructure);
|
|
3123
|
+
const runId = randomUUID();
|
|
3124
|
+
const stopProcesses = await startProcessServices(services, root, runId);
|
|
2587
3125
|
const databases = orchestrator.getDatabases();
|
|
2588
3126
|
return {
|
|
2589
3127
|
database: orchestrator.getDatabase() ?? void 0,
|
|
2590
3128
|
databases: databases.size > 0 ? databases : void 0,
|
|
2591
|
-
orchestrator
|
|
3129
|
+
orchestrator,
|
|
3130
|
+
runId,
|
|
3131
|
+
stopProcesses
|
|
2592
3132
|
};
|
|
2593
3133
|
}
|
|
2594
3134
|
//#endregion
|
|
@@ -2678,7 +3218,7 @@ async function startApi(options) {
|
|
|
2678
3218
|
const databaseKeys = declaredDatabaseKeys(services);
|
|
2679
3219
|
if (mode === "node") {
|
|
2680
3220
|
if (!options.server) throw new Error("specification.api(): 'server' is required in node mode — provide server: (services) => app, or run in compose mode (TEST_MODE=compose).");
|
|
2681
|
-
const { database, databases, orchestrator } = await startServices(services, root);
|
|
3221
|
+
const { database, databases, orchestrator, stopProcesses } = await startServices(services, root);
|
|
2682
3222
|
return {
|
|
2683
3223
|
api: createApiFacet({
|
|
2684
3224
|
database,
|
|
@@ -2687,6 +3227,7 @@ async function startApi(options) {
|
|
|
2687
3227
|
server: new HonoAdapter(options.server(services))
|
|
2688
3228
|
}),
|
|
2689
3229
|
cleanup: async () => {
|
|
3230
|
+
await stopProcesses();
|
|
2690
3231
|
await releaseIsolation(services);
|
|
2691
3232
|
await orchestrator.stop();
|
|
2692
3233
|
},
|
|
@@ -2709,6 +3250,7 @@ async function startApi(options) {
|
|
|
2709
3250
|
database: orchestrator.getDatabase() ?? void 0,
|
|
2710
3251
|
databaseKeys,
|
|
2711
3252
|
databases: databases.size > 0 ? databases : void 0,
|
|
3253
|
+
clockDisabledReason: "the calendar it pins is this runner's, and compose mode runs the app in a container — assert the stamp with a `{{iso8601}}` token instead.",
|
|
2712
3254
|
interceptDisabledReason: "intercepts are in-process (MSW) and not available in compose mode — keep intercept specs in node-only vitest projects.",
|
|
2713
3255
|
server: new FetchAdapter(appUrl)
|
|
2714
3256
|
}),
|
|
@@ -2887,6 +3429,7 @@ async function startCli(bin, options = {}) {
|
|
|
2887
3429
|
if (Object.keys(services).length > 0) started = await startServices(services, root);
|
|
2888
3430
|
return {
|
|
2889
3431
|
cleanup: async () => {
|
|
3432
|
+
await started?.stopProcesses();
|
|
2890
3433
|
await releaseIsolation(services);
|
|
2891
3434
|
if (started) await started.orchestrator.stop();
|
|
2892
3435
|
},
|
|
@@ -2896,6 +3439,7 @@ async function startCli(bin, options = {}) {
|
|
|
2896
3439
|
databaseKeys,
|
|
2897
3440
|
databases: started?.databases,
|
|
2898
3441
|
dockerConfig: options.docker,
|
|
3442
|
+
defaultEnv: options.defaults,
|
|
2899
3443
|
envSets: options.env,
|
|
2900
3444
|
root,
|
|
2901
3445
|
serveRegistry: options.serve,
|
|
@@ -2907,6 +3451,42 @@ async function startCli(bin, options = {}) {
|
|
|
2907
3451
|
};
|
|
2908
3452
|
}
|
|
2909
3453
|
//#endregion
|
|
3454
|
+
//#region src/specification/facets/integration/start-integration.ts
|
|
3455
|
+
/**
|
|
3456
|
+
* Specify a module the way the assembled product is specified: against real
|
|
3457
|
+
* services and declared contracts, with the golden mechanism on what it
|
|
3458
|
+
* produces.
|
|
3459
|
+
*
|
|
3460
|
+
* The fork is the SUBJECT. A module alone is a module test, beside its code,
|
|
3461
|
+
* with no runner at all. A module that needs a real database, or whose oracle
|
|
3462
|
+
* is a golden file, is this — and it used to be neither: a `specs/` folder
|
|
3463
|
+
* with plain vitest, a hand-built sqlite template, a `beforeAll` that started
|
|
3464
|
+
* a container, and a `toMatchSnapshot` where the package has a golden engine.
|
|
3465
|
+
*/
|
|
3466
|
+
async function startIntegration(options = {}) {
|
|
3467
|
+
const callerDir = getCallerDir();
|
|
3468
|
+
await registerMatchers();
|
|
3469
|
+
const root = resolveRoot(options.root, callerDir);
|
|
3470
|
+
const services = options.services ?? {};
|
|
3471
|
+
const databaseKeys = declaredDatabaseKeys(services);
|
|
3472
|
+
let started = null;
|
|
3473
|
+
if (Object.keys(services).length > 0) started = await startServices(services, root);
|
|
3474
|
+
return {
|
|
3475
|
+
cleanup: async () => {
|
|
3476
|
+
await started?.stopProcesses();
|
|
3477
|
+
await releaseIsolation(services);
|
|
3478
|
+
if (started) await started.orchestrator.stop();
|
|
3479
|
+
},
|
|
3480
|
+
integration: createIntegrationFacet({
|
|
3481
|
+
database: started?.database,
|
|
3482
|
+
databaseKeys,
|
|
3483
|
+
databases: started?.databases,
|
|
3484
|
+
root,
|
|
3485
|
+
services
|
|
3486
|
+
})
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
//#endregion
|
|
2910
3490
|
//#region src/specification/facets/jobs/start-jobs.ts
|
|
2911
3491
|
async function startJobs(options) {
|
|
2912
3492
|
const callerDir = getCallerDir();
|
|
@@ -2919,6 +3499,7 @@ async function startJobs(options) {
|
|
|
2919
3499
|
const jobHandles = typeof options.jobs === "function" ? options.jobs(services) : options.jobs;
|
|
2920
3500
|
return {
|
|
2921
3501
|
cleanup: async () => {
|
|
3502
|
+
await started?.stopProcesses();
|
|
2922
3503
|
await releaseIsolation(services);
|
|
2923
3504
|
if (started) await started.orchestrator.stop();
|
|
2924
3505
|
},
|
|
@@ -3067,6 +3648,22 @@ var StubBackend = class {
|
|
|
3067
3648
|
if (reply.delay) await new Promise((resolve) => setTimeout(resolve, reply.delay));
|
|
3068
3649
|
const status = reply.status ?? 200;
|
|
3069
3650
|
const { body } = reply;
|
|
3651
|
+
if (isStreamBody(body)) {
|
|
3652
|
+
response.writeHead(status, {
|
|
3653
|
+
...cors,
|
|
3654
|
+
"content-type": body.contentType,
|
|
3655
|
+
...reply.headers
|
|
3656
|
+
});
|
|
3657
|
+
await toReadableStream(body).pipeTo(new WritableStream({
|
|
3658
|
+
close() {
|
|
3659
|
+
response.end();
|
|
3660
|
+
},
|
|
3661
|
+
write(chunk) {
|
|
3662
|
+
response.write(chunk);
|
|
3663
|
+
}
|
|
3664
|
+
}));
|
|
3665
|
+
return;
|
|
3666
|
+
}
|
|
3070
3667
|
if (body === null || body === void 0) {
|
|
3071
3668
|
response.writeHead(status, {
|
|
3072
3669
|
...cors,
|
|
@@ -3231,6 +3828,14 @@ async function startMobile(options) {
|
|
|
3231
3828
|
const udid = options.device.udid ?? await resolveSimulatorUdid(options.device);
|
|
3232
3829
|
await ensureBooted(udid);
|
|
3233
3830
|
const appium = await startAppiumServer(root);
|
|
3831
|
+
const services = options.services ?? {};
|
|
3832
|
+
let started = null;
|
|
3833
|
+
if (Object.keys(services).length > 0) try {
|
|
3834
|
+
started = await startServices(services, root);
|
|
3835
|
+
} catch (error) {
|
|
3836
|
+
await appium.stop();
|
|
3837
|
+
throw error;
|
|
3838
|
+
}
|
|
3234
3839
|
let backend = null;
|
|
3235
3840
|
let backendUrl;
|
|
3236
3841
|
if (options.backend) {
|
|
@@ -3262,6 +3867,11 @@ async function startMobile(options) {
|
|
|
3262
3867
|
await device.close();
|
|
3263
3868
|
device = null;
|
|
3264
3869
|
}
|
|
3870
|
+
if (started) {
|
|
3871
|
+
await started.stopProcesses();
|
|
3872
|
+
await releaseIsolation(services);
|
|
3873
|
+
await started.orchestrator.stop();
|
|
3874
|
+
}
|
|
3265
3875
|
await appium.stop();
|
|
3266
3876
|
if (backend) await backend.stop();
|
|
3267
3877
|
},
|
|
@@ -3271,6 +3881,66 @@ async function startMobile(options) {
|
|
|
3271
3881
|
}
|
|
3272
3882
|
//#endregion
|
|
3273
3883
|
//#region src/specification/facets/website/start-website.ts
|
|
3884
|
+
/**
|
|
3885
|
+
* The declared shape, whichever of the three forms stated it — with an `env`
|
|
3886
|
+
* function resolved against the services that are already up, so the site is
|
|
3887
|
+
* handed the URL of the API it was started beside.
|
|
3888
|
+
*/
|
|
3889
|
+
function resolveServer(server, services) {
|
|
3890
|
+
const stated = typeof server === "function" ? server(services) : server;
|
|
3891
|
+
const spec = stated instanceof ProcessHandle ? stated.spec : stated;
|
|
3892
|
+
if (typeof spec.env !== "function") return spec;
|
|
3893
|
+
return {
|
|
3894
|
+
...spec,
|
|
3895
|
+
env: spec.env(services)
|
|
3896
|
+
};
|
|
3897
|
+
}
|
|
3898
|
+
/**
|
|
3899
|
+
* Start the declared record, if there is one. A record that fails to come up
|
|
3900
|
+
* must not orphan the stub that was started before it.
|
|
3901
|
+
*/
|
|
3902
|
+
async function startDeclared(services, root, backend) {
|
|
3903
|
+
if (Object.keys(services).length === 0) return null;
|
|
3904
|
+
try {
|
|
3905
|
+
return await startServices(services, root);
|
|
3906
|
+
} catch (error) {
|
|
3907
|
+
await backend?.stop();
|
|
3908
|
+
throw error;
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
/**
|
|
3912
|
+
* Start the site, or take the URL of a deployed one. A server that never came
|
|
3913
|
+
* up must not orphan the stub, nor the services it was started beside.
|
|
3914
|
+
*/
|
|
3915
|
+
async function startSite(options, context) {
|
|
3916
|
+
if (!options.server) return {
|
|
3917
|
+
baseUrl: (options.url ?? "").replace(/\/$/u, ""),
|
|
3918
|
+
serve: null
|
|
3919
|
+
};
|
|
3920
|
+
const serve = new ServeAdapter(resolveServer(options.server, context.services), context.root, "website", {
|
|
3921
|
+
TEST_RUN_ID: context.started?.runId ?? randomUUID(),
|
|
3922
|
+
...serverBackendEnv(options.backend, context.backendUrl)
|
|
3923
|
+
});
|
|
3924
|
+
try {
|
|
3925
|
+
return {
|
|
3926
|
+
baseUrl: await serve.start(),
|
|
3927
|
+
serve
|
|
3928
|
+
};
|
|
3929
|
+
} catch (error) {
|
|
3930
|
+
await context.started?.stopProcesses();
|
|
3931
|
+
await context.started?.orchestrator.stop();
|
|
3932
|
+
await context.backend?.stop();
|
|
3933
|
+
throw error;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
/** The stub's URL in the child's environment, under the name the caller chose. */
|
|
3937
|
+
function serverBackendEnv(backend, backendUrl) {
|
|
3938
|
+
return backend && backendUrl !== void 0 ? { [backend.env]: backendUrl } : {};
|
|
3939
|
+
}
|
|
3940
|
+
/** The declared record, or the empty one — a website may have no services. */
|
|
3941
|
+
function servicesOf(options) {
|
|
3942
|
+
return options.services ?? {};
|
|
3943
|
+
}
|
|
3274
3944
|
async function startWebsite(options) {
|
|
3275
3945
|
const callerDir = getCallerDir();
|
|
3276
3946
|
await registerMatchers();
|
|
@@ -3281,18 +3951,16 @@ async function startWebsite(options) {
|
|
|
3281
3951
|
backend = new StubBackend({ port: options.backend.port });
|
|
3282
3952
|
backendUrl = await backend.start();
|
|
3283
3953
|
}
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
}
|
|
3295
|
-
} else baseUrl = options.url.replace(/\/$/u, "");
|
|
3954
|
+
const services = servicesOf(options);
|
|
3955
|
+
const root = resolveRoot(options.root, callerDir);
|
|
3956
|
+
const started = await startDeclared(services, root, backend);
|
|
3957
|
+
const { baseUrl, serve } = await startSite(options, {
|
|
3958
|
+
backend,
|
|
3959
|
+
backendUrl,
|
|
3960
|
+
root,
|
|
3961
|
+
services,
|
|
3962
|
+
started
|
|
3963
|
+
});
|
|
3296
3964
|
let browser = null;
|
|
3297
3965
|
const getBrowser = async () => {
|
|
3298
3966
|
if (!browser) {
|
|
@@ -3301,13 +3969,6 @@ async function startWebsite(options) {
|
|
|
3301
3969
|
}
|
|
3302
3970
|
return browser;
|
|
3303
3971
|
};
|
|
3304
|
-
const config = {
|
|
3305
|
-
backend: backend ?? void 0,
|
|
3306
|
-
backendUrl,
|
|
3307
|
-
baseUrl,
|
|
3308
|
-
browser: getBrowser,
|
|
3309
|
-
external: options.external ?? (options.server ? "block" : "allow")
|
|
3310
|
-
};
|
|
3311
3972
|
return {
|
|
3312
3973
|
cleanup: async () => {
|
|
3313
3974
|
if (browser) {
|
|
@@ -3315,10 +3976,21 @@ async function startWebsite(options) {
|
|
|
3315
3976
|
browser = null;
|
|
3316
3977
|
}
|
|
3317
3978
|
if (serve) await serve.stop();
|
|
3979
|
+
if (started) {
|
|
3980
|
+
await started.stopProcesses();
|
|
3981
|
+
await releaseIsolation(services);
|
|
3982
|
+
await started.orchestrator.stop();
|
|
3983
|
+
}
|
|
3318
3984
|
if (backend) await backend.stop();
|
|
3319
3985
|
},
|
|
3320
3986
|
url: baseUrl,
|
|
3321
|
-
website: createWebsiteFacet(
|
|
3987
|
+
website: createWebsiteFacet({
|
|
3988
|
+
backend: backend ?? void 0,
|
|
3989
|
+
backendUrl,
|
|
3990
|
+
baseUrl,
|
|
3991
|
+
browser: getBrowser,
|
|
3992
|
+
external: options.external ?? (options.server ? "block" : "allow")
|
|
3993
|
+
})
|
|
3322
3994
|
};
|
|
3323
3995
|
}
|
|
3324
3996
|
//#endregion
|
|
@@ -3339,6 +4011,13 @@ const specification = {
|
|
|
3339
4011
|
*/
|
|
3340
4012
|
cli: startCli,
|
|
3341
4013
|
/**
|
|
4014
|
+
* Specify a module against the real thing: a database, a cache, a
|
|
4015
|
+
* `process()` it talks to — or nothing at all, when its oracle is a
|
|
4016
|
+
* golden. `.call((services) => …)` is the terminal action, and what it
|
|
4017
|
+
* produced is read as `result.value` or, when it refused, `result.error`.
|
|
4018
|
+
*/
|
|
4019
|
+
integration: startIntegration,
|
|
4020
|
+
/**
|
|
3342
4021
|
* Test background jobs. Jobs run in-process by definition — no HTTP
|
|
3343
4022
|
* server, no mode. `.trigger(name)` is the terminal action.
|
|
3344
4023
|
*/
|
|
@@ -3734,6 +4413,14 @@ function sqlite(options = {}) {
|
|
|
3734
4413
|
}
|
|
3735
4414
|
//#endregion
|
|
3736
4415
|
//#region src/index.ts
|
|
4416
|
+
/**
|
|
4417
|
+
* The module-scope network double, on msw's node server — what a test with no
|
|
4418
|
+
* chain to hang contracts on reaches for (chapter 10).
|
|
4419
|
+
*/
|
|
4420
|
+
const intercept = interceptThrough(async (contracts) => {
|
|
4421
|
+
const { registerContracts } = await import("./intercept.js");
|
|
4422
|
+
return await registerContracts(contracts);
|
|
4423
|
+
});
|
|
3737
4424
|
registerContainerIntegrations({
|
|
3738
4425
|
createComposeStack: (composeFile, projectName) => new ComposeStackAdapter(composeFile, projectName),
|
|
3739
4426
|
createContainer: (options) => new TestcontainersAdapter(options),
|
|
@@ -3745,4 +4432,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
|
|
|
3745
4432
|
}));
|
|
3746
4433
|
registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
|
|
3747
4434
|
//#endregion
|
|
3748
|
-
export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, findContainersByLabel, focused, form, heading, http, inspectContainer, link, listitem, main, match, mockOf, mockOfDate, navigation, openai, option, postgres, redis, region, removeContainers, row, search, selected, specification, sqlite, status, table, testId, text, valued, within };
|
|
4435
|
+
export { BaseResult, CallResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ProcessHandle, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, clock, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, findContainersByLabel, focused, form, heading, http, inspectContainer, intercept, link, listitem, main, match, mockOf, mockOfDate, navigation, openai, option, postgres, processService as process, redis, region, removeContainers, required, row, search, selected, specification, sqlite, status, table, testId, text, valued, waitUntil, within };
|