@teamscala/service-boot 0.23.536 → 0.23.537
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/package.json +306 -304
- package/src/apply-logger-config.ts +2 -1
- package/src/build-client-assets/hydrate-bundle.ts +3 -2
- package/src/build-client-assets/manifest.ts +0 -38
- package/src/build-client-assets/sourcemaps-upload.test.ts +2 -4
- package/src/build-client-assets/sourcemaps-upload.ts +244 -138
- package/src/build-client-assets.ts +9 -46
- package/src/load-service-install-row.ts +1 -1
- package/src/mount-server-packages.ts +6 -12
- package/src/orpc-contract-rows.json +34 -17
- package/src/orpc-contract-rows.test.ts +1450 -0
- package/src/pg-boss-mount.ts +124 -15
- package/src/read-html-template.ts +1 -25
- package/src/register-page-server-client.ts +2 -2
- package/src/register-registry-page-route/live-loader-config.test.ts +0 -12
- package/src/register-registry-page-route/live-loader-config.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -1
- package/src/register-registry-page-route.ts +3 -3
- package/src/two-phase-full-app.ts +3 -3
- package/src/ui-service.ts +11 -36
- package/src/orpc-openapi-gate.test.ts +0 -751
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts_function_contract_runner template (scala-test-suites
|
|
3
|
+
* templates/ts_function_contract_runner.pkl @1.15.0) over @teamscala/service-boot's
|
|
4
|
+
* test_vector rows: one `it` per function_call row (args -> value or
|
|
5
|
+
* expect_error) and one per fs_flow row (ordered steps in a temp
|
|
6
|
+
* workspace). This runner carries NO facts: every case is the registry
|
|
7
|
+
* row projected into ./orpc-contract-rows.json. Regenerate through the consumer's
|
|
8
|
+
* test-vector-rows projection row (scala-tools codegen run --repo
|
|
9
|
+
* <consumer>); hand edits are overwritten.
|
|
10
|
+
*/
|
|
11
|
+
function arithmetic(source: string, value: unknown, slug: string): number {
|
|
12
|
+
let pos = 0;
|
|
13
|
+
const text = source;
|
|
14
|
+
const fail: (why: string) => never = (why) => { throw new Error("row " + slug + " is malformed: member_equals_expression " + JSON.stringify(source) + " — " + why); };
|
|
15
|
+
const skip = (): void => { while (pos < text.length && text[pos] === " ") pos++; };
|
|
16
|
+
const eat = (ch: string): void => { skip(); if (text[pos] !== ch) fail("expected " + JSON.stringify(ch) + " at offset " + pos); pos++; };
|
|
17
|
+
const number = (): number => {
|
|
18
|
+
skip();
|
|
19
|
+
const start = pos;
|
|
20
|
+
while (pos < text.length && /[0-9.]/.test(text[pos])) pos++;
|
|
21
|
+
if (start === pos) fail("expected a number at offset " + start);
|
|
22
|
+
const got = Number(text.slice(start, pos));
|
|
23
|
+
if (Number.isNaN(got)) fail(JSON.stringify(text.slice(start, pos)) + " is not a number");
|
|
24
|
+
return got;
|
|
25
|
+
};
|
|
26
|
+
const identifier = (): string => {
|
|
27
|
+
skip();
|
|
28
|
+
const start = pos;
|
|
29
|
+
while (pos < text.length && /[A-Za-z0-9_@.]/.test(text[pos])) pos++;
|
|
30
|
+
if (start === pos) fail("expected a name at offset " + start);
|
|
31
|
+
return text.slice(start, pos);
|
|
32
|
+
};
|
|
33
|
+
const primary = (): number => {
|
|
34
|
+
skip();
|
|
35
|
+
if (text[pos] === "(") { pos++; const inner = expression(); eat(")"); return inner; }
|
|
36
|
+
if (text[pos] === "-") { pos++; return -primary(); }
|
|
37
|
+
if (text[pos] === "+") { pos++; return primary(); }
|
|
38
|
+
if (/[0-9.]/.test(text[pos] ?? "")) return number();
|
|
39
|
+
const name = identifier();
|
|
40
|
+
if (name === "round" || name === "abs") {
|
|
41
|
+
eat("(");
|
|
42
|
+
const first = expression();
|
|
43
|
+
let digits = 0;
|
|
44
|
+
skip();
|
|
45
|
+
if (text[pos] === ",") { pos++; digits = number(); }
|
|
46
|
+
eat(")");
|
|
47
|
+
if (name === "abs") return Math.abs(first);
|
|
48
|
+
const factor = Math.pow(10, digits);
|
|
49
|
+
return Math.round(first * factor) / factor;
|
|
50
|
+
}
|
|
51
|
+
if (name.startsWith("@")) {
|
|
52
|
+
const got = memberAt(value, name.slice(1));
|
|
53
|
+
if (typeof got !== "number") fail("@" + name.slice(1) + " is " + (got === undefined ? "absent from what the call returned" : typeof got) + ", and the formula reads it as a number");
|
|
54
|
+
return got;
|
|
55
|
+
}
|
|
56
|
+
return fail(name + " is not @member, a number, round or abs — the formula grammar is arithmetic over the returned members, nothing else");
|
|
57
|
+
};
|
|
58
|
+
const factor = (): number => {
|
|
59
|
+
let left = primary();
|
|
60
|
+
for (;;) {
|
|
61
|
+
skip();
|
|
62
|
+
const op = text[pos];
|
|
63
|
+
if (op !== "*" && op !== "/") return left;
|
|
64
|
+
pos++;
|
|
65
|
+
const right = primary();
|
|
66
|
+
if (op === "/" && right === 0) fail("divides by zero — a formula that cannot be evaluated is not a relation");
|
|
67
|
+
left = op === "*" ? left * right : left / right;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const term = (): number => {
|
|
71
|
+
let left = factor();
|
|
72
|
+
for (;;) {
|
|
73
|
+
skip();
|
|
74
|
+
const op = text[pos];
|
|
75
|
+
if (op !== "+" && op !== "-") return left;
|
|
76
|
+
pos++;
|
|
77
|
+
left = op === "+" ? left + factor() : left - factor();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
function expression(): number { return term(); }
|
|
81
|
+
const answer = expression();
|
|
82
|
+
skip();
|
|
83
|
+
if (pos !== text.length) fail("trailing input at offset " + pos);
|
|
84
|
+
return answer;
|
|
85
|
+
}
|
|
86
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
87
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
88
|
+
import { tmpdir } from "node:os";
|
|
89
|
+
import { existsSync } from "node:fs";
|
|
90
|
+
import { dirname, join, resolve } from "node:path";
|
|
91
|
+
import projected from "./orpc-contract-rows.json";
|
|
92
|
+
|
|
93
|
+
const ROWS = (projected.rows ?? projected) as Vector[];
|
|
94
|
+
|
|
95
|
+
const SELF = import.meta.path;
|
|
96
|
+
const SELECTED = process.env.SCALA_CONTRACT_ROW;
|
|
97
|
+
|
|
98
|
+
if (!Array.isArray(ROWS)) {
|
|
99
|
+
throw new Error("./orpc-contract-rows.json must project an array of rows — the runner executes registry rows, never a hand-written case list");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const PKG: string = "@teamscala/service-boot";
|
|
103
|
+
|
|
104
|
+
const ROOT = (() => {
|
|
105
|
+
let dir = import.meta.dir;
|
|
106
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
107
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
108
|
+
dir = join(dir, "..");
|
|
109
|
+
}
|
|
110
|
+
throw new Error("no package.json above " + import.meta.dir + " — a contract suite renders inside a package");
|
|
111
|
+
})();
|
|
112
|
+
|
|
113
|
+
type Vector = Record<string, unknown> & { suite_type: string; slug: string };
|
|
114
|
+
|
|
115
|
+
let ACTIVE_RECORDERS: Record<string, { args: unknown[] }[]> = {};
|
|
116
|
+
function declareRecorder(name: string): void {
|
|
117
|
+
(ACTIVE_RECORDERS[name] ??= []);
|
|
118
|
+
}
|
|
119
|
+
function recordCall(name: string, args: unknown[]): void {
|
|
120
|
+
(ACTIVE_RECORDERS[name] ??= []).push({ args });
|
|
121
|
+
}
|
|
122
|
+
let ACTIVE_HOOK: { value: unknown } | null = null;
|
|
123
|
+
|
|
124
|
+
async function decodeValue(value: unknown): Promise<unknown> {
|
|
125
|
+
if (value === null) return undefined;
|
|
126
|
+
if (typeof value === "object" && value !== null && "$null" in (value as object)) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (value !== null && typeof value === "object" && "$error" in (value as object)) {
|
|
130
|
+
const spec = (value as { $error: { class: string; module?: string; args?: unknown[] } }).$error;
|
|
131
|
+
const ctor = spec.module
|
|
132
|
+
? ((await import(spec.module)) as Record<string, unknown>)[spec.class]
|
|
133
|
+
: (globalThis as Record<string, unknown>)[spec.class];
|
|
134
|
+
if (typeof ctor !== "function") throw new Error("unconstructible error class: " + spec.class);
|
|
135
|
+
return new ctor(...(spec.args ?? []));
|
|
136
|
+
}
|
|
137
|
+
if (value !== null && typeof value === "object" && "$abort" in (value as object)) {
|
|
138
|
+
const spec = (value as { $abort: { afterMs?: unknown } }).$abort;
|
|
139
|
+
if (spec === null || typeof spec !== "object" || typeof spec.afterMs !== "number" || !(spec.afterMs >= 0)) throw new Error("$abort needs {afterMs} — a non-negative number of milliseconds before the signal fires");
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
setTimeout(() => controller.abort(), spec.afterMs);
|
|
142
|
+
return controller.signal;
|
|
143
|
+
}
|
|
144
|
+
if (typeof value === "object" && "$date" in (value as object)) {
|
|
145
|
+
const instant = new Date(String((value as { $date: unknown }).$date));
|
|
146
|
+
if (Number.isNaN(instant.getTime())) throw new Error("$date " + JSON.stringify((value as { $date: unknown }).$date) + " is not an instant this runtime can read");
|
|
147
|
+
return instant;
|
|
148
|
+
}
|
|
149
|
+
if (typeof value === "object" && value !== null && "$import" in (value as object)) {
|
|
150
|
+
const spec = (value as { $import: { module?: string; export?: string } }).$import;
|
|
151
|
+
if (spec === null || typeof spec !== "object" || typeof spec.module !== "string" || typeof spec.export !== "string") throw new Error("$import needs {module, export} — a value a row can only NAME is named by the module it lives in and the export it is called");
|
|
152
|
+
const mod = (await import(spec.module.includes("/") ? join(ROOT, spec.module) : spec.module)) as Record<string, unknown>;
|
|
153
|
+
if (!(spec.export in mod)) throw new Error("no export " + spec.export + " in " + spec.module + " — $import named a value that module does not carry");
|
|
154
|
+
return mod[spec.export];
|
|
155
|
+
}
|
|
156
|
+
if (typeof value === "object" && value !== null && "$map" in (value as object)) {
|
|
157
|
+
const spec = (value as { $map: { entries?: unknown[] } }).$map;
|
|
158
|
+
if (spec === null || typeof spec !== "object" || (spec.entries !== undefined && !Array.isArray(spec.entries))) throw new Error("$map needs {entries?} — an array of [key, value] pairs, or nothing for the empty map a subject is handed when it has nothing to say");
|
|
159
|
+
const pairs: unknown[][] = [];
|
|
160
|
+
for (const [i, entry] of ((spec.entries ?? []) as unknown[]).entries()) {
|
|
161
|
+
if (!Array.isArray(entry) || entry.length !== 2) throw new Error("$map entry " + i + " is " + JSON.stringify(entry) + " — a map entry is a [key, value] pair, and a pair of another length is a typo, not a value");
|
|
162
|
+
pairs.push([await decodeValue(entry[0]), await decodeValue(entry[1])]);
|
|
163
|
+
}
|
|
164
|
+
return new Map(pairs);
|
|
165
|
+
}
|
|
166
|
+
if (typeof value === "object" && value !== null && "$set" in (value as object)) {
|
|
167
|
+
const spec = (value as { $set: { members?: unknown[] } }).$set;
|
|
168
|
+
if (spec === null || typeof spec !== "object" || (spec.members !== undefined && !Array.isArray(spec.members))) throw new Error("$set needs {members?} — the values a subject's Set already holds, or nothing for the Set it is handed when it has seen nothing");
|
|
169
|
+
return new Set(await Promise.all(((spec.members ?? []) as unknown[]).map(decodeValue)));
|
|
170
|
+
}
|
|
171
|
+
if (typeof value === "object" && value !== null && "$call" in (value as object)) {
|
|
172
|
+
const spec = (value as { $call: { module?: string; export?: string; args?: unknown[] } }).$call;
|
|
173
|
+
if (spec === null || typeof spec !== "object" || typeof spec.module !== "string" || typeof spec.export !== "string" || (spec.args !== undefined && !Array.isArray(spec.args))) throw new Error("$call needs {module, export, args?} — the value an export returns when the row calls it with the row's own args");
|
|
174
|
+
const mod = (await import(spec.module.includes("/") ? join(ROOT, spec.module) : spec.module)) as Record<string, unknown>;
|
|
175
|
+
const callee = mod[spec.export];
|
|
176
|
+
if (typeof callee !== "function") throw new Error("$call names " + spec.export + " in " + spec.module + ", which that module does not export as a callable — a derived value is what calling it RETURNS");
|
|
177
|
+
const callArgs = await Promise.all(((spec.args ?? []) as unknown[]).map(decodeValue));
|
|
178
|
+
return await (callee as (...a: unknown[]) => unknown)(...callArgs);
|
|
179
|
+
}
|
|
180
|
+
if (typeof value === "object" && "$proxy" in (value as object)) {
|
|
181
|
+
const pspec = (value as { $proxy: { records?: string; terminals?: Record<string, unknown> } }).$proxy;
|
|
182
|
+
if (pspec === null || typeof pspec !== "object") throw new Error("$proxy needs {records?, terminals?} — a chain fake without terminals never returns data, and one without records cannot be asserted");
|
|
183
|
+
if (pspec.records !== undefined) declareRecorder(String(pspec.records));
|
|
184
|
+
const terminals: Record<string, unknown> = {};
|
|
185
|
+
for (const [name, payload] of Object.entries(pspec.terminals ?? {})) {
|
|
186
|
+
terminals[name] = decodeStubReturn(payload);
|
|
187
|
+
}
|
|
188
|
+
const self: unknown = new Proxy(function () { return self; } as (...args: unknown[]) => unknown, {
|
|
189
|
+
get(_target, prop) {
|
|
190
|
+
if (typeof prop === "symbol") return undefined;
|
|
191
|
+
if (prop in terminals) {
|
|
192
|
+
const terminal = terminals[prop];
|
|
193
|
+
return (...callArgs: unknown[]) => {
|
|
194
|
+
if (pspec.records !== undefined) recordCall(String(pspec.records), [{ field: String(prop), args: callArgs }]);
|
|
195
|
+
return terminal;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return (...callArgs: unknown[]) => {
|
|
199
|
+
if (pspec.records !== undefined) recordCall(String(pspec.records), [{ field: String(prop), args: callArgs }]);
|
|
200
|
+
return self;
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
return self;
|
|
205
|
+
}
|
|
206
|
+
if (typeof value === "object" && "$fn" in (value as object)) {
|
|
207
|
+
const spec = (value as { $fn: { returns?: unknown; throws?: unknown; returns_by_call?: unknown[]; records?: string; respond?: Vector[]; calls_arg?: boolean; calls_hook?: unknown; hook_args?: unknown[]; arg?: unknown; record_return?: boolean } }).$fn;
|
|
208
|
+
if (spec.calls_hook !== undefined) {
|
|
209
|
+
if (typeof spec.calls_hook !== "string" || spec.calls_hook.length === 0) throw new Error("$fn calls_hook needs the name of the callback the subject returns — a callback that reaches back into the subject is driven by naming the member it calls");
|
|
210
|
+
const member = String(spec.calls_hook);
|
|
211
|
+
const driven = ((spec.hook_args ?? []) as unknown[]).map((entry) => decodeValue(entry));
|
|
212
|
+
return async (...callArgs: unknown[]) => {
|
|
213
|
+
if (spec.records !== undefined) recordCall(String(spec.records), callArgs);
|
|
214
|
+
const held = ACTIVE_HOOK;
|
|
215
|
+
if (held === null || held.value === null || typeof held.value !== "object") throw new Error("$fn calls_hook names " + member + " but the row drives no hook — a callback that reaches back into the subject only exists while the subject is mounted");
|
|
216
|
+
const fn = (held.value as Record<string, unknown>)[member];
|
|
217
|
+
if (typeof fn !== "function") throw new Error("$fn calls_hook names " + member + ", which the subject does not return as a callable — a member the return does not carry cannot be driven");
|
|
218
|
+
return (fn as (...a: unknown[]) => unknown)(...(await Promise.all(driven)));
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (spec.calls_arg === true) {
|
|
222
|
+
const handed = spec.arg === undefined ? undefined : await decodeValue(spec.arg);
|
|
223
|
+
return async (...callArgs: unknown[]) => {
|
|
224
|
+
if (spec.records !== undefined) recordCall(String(spec.records), callArgs);
|
|
225
|
+
const callback = callArgs[0];
|
|
226
|
+
if (typeof callback !== "function") throw new Error("$fn declares calls_arg but was handed " + (callback === undefined ? "nothing" : typeof callback) + " where the callback goes — a transaction that runs no callback runs no work");
|
|
227
|
+
const answer = await (callback as (unit: unknown) => unknown)(handed);
|
|
228
|
+
if (spec.records !== undefined && spec.record_return === true) recordCall(String(spec.records), [{ args: callArgs, returned: answer }]);
|
|
229
|
+
return answer;
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (spec.records !== undefined) declareRecorder(String(spec.records));
|
|
233
|
+
const arms = Array.isArray(spec.respond) ? (spec.respond as Vector[]) : [];
|
|
234
|
+
if (Array.isArray(spec.returns_by_call)) {
|
|
235
|
+
const sequence = (spec.returns_by_call as unknown[]).map((entry) => decodeStubReturn(entry));
|
|
236
|
+
if (sequence.length === 0) throw new Error("$fn declares an empty returns_by_call — a sequence with no answers is a collaborator that can never be called");
|
|
237
|
+
let seen = 0;
|
|
238
|
+
return async (...callArgs: unknown[]) => {
|
|
239
|
+
if (spec.records !== undefined) recordCall(String(spec.records), callArgs);
|
|
240
|
+
const index = Math.min(seen, (sequence as unknown[]).length - 1);
|
|
241
|
+
seen++;
|
|
242
|
+
const answer = (sequence as unknown[])[index] as { $throws?: unknown };
|
|
243
|
+
if (answer !== null && typeof answer === "object" && "$throws" in answer) throw new Error(String(answer.$throws));
|
|
244
|
+
return await answer;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (arms.length > 0 && spec.returns === undefined) {
|
|
248
|
+
throw new Error("$fn declares a respond list with no fallback returns — every call no arm shapes is a call the row never answered for");
|
|
249
|
+
}
|
|
250
|
+
if (spec.returns !== undefined) {
|
|
251
|
+
const answer = decodeStubReturn(spec.returns);
|
|
252
|
+
const raisedSpec = spec.throws === undefined ? undefined : await decodeValue(spec.throws);
|
|
253
|
+
return (...callArgs: unknown[]) => {
|
|
254
|
+
if (spec.records !== undefined) recordCall(String(spec.records), callArgs);
|
|
255
|
+
for (const arm of arms) {
|
|
256
|
+
const needle = arm.args_contains === undefined ? undefined : String(arm.args_contains);
|
|
257
|
+
if (needle === undefined || JSON.stringify(callArgs).includes(needle)) {
|
|
258
|
+
if (arm.throws !== undefined) throw arm.throws;
|
|
259
|
+
return arm.returns;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (arms.length > 0) {
|
|
263
|
+
throw new Error("$fn recorded arm no answer matches the call " + JSON.stringify(callArgs).slice(0, 120) + " — declared shapes: " + arms.map((arm) => JSON.stringify(arm.args_contains)).join(", "));
|
|
264
|
+
}
|
|
265
|
+
if (raisedSpec !== undefined) throw raisedSpec;
|
|
266
|
+
return answer;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (spec.throws !== undefined) {
|
|
270
|
+
const raised = await decodeValue(spec.throws);
|
|
271
|
+
return (...callArgs: unknown[]) => {
|
|
272
|
+
if (spec.records !== undefined) recordCall(String(spec.records), callArgs);
|
|
273
|
+
throw raised;
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return () => undefined;
|
|
277
|
+
}
|
|
278
|
+
if (Array.isArray(value)) return "raw" in (value as object) ? value : await Promise.all(value.map(decodeValue));
|
|
279
|
+
if (typeof value === "object") {
|
|
280
|
+
if (Array.isArray(value)) return value;
|
|
281
|
+
const walked: Record<string, unknown> = {};
|
|
282
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
283
|
+
walked[key] = await decodeValue(inner);
|
|
284
|
+
}
|
|
285
|
+
return walked;
|
|
286
|
+
}
|
|
287
|
+
return value;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function loadExport(modulePath: string, exportName: string): Promise<unknown> {
|
|
291
|
+
const specifier = modulePath.includes("/") ? join(ROOT, modulePath) : modulePath;
|
|
292
|
+
const mod = (await import(specifier)) as Record<string, unknown>;
|
|
293
|
+
if (!(exportName in mod)) throw new Error("no export " + exportName + " in " + modulePath);
|
|
294
|
+
return mod[exportName];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function callExport(modulePath: string, exportName: string, decodedArgs: unknown[]): Promise<unknown> {
|
|
298
|
+
const fn = await loadExport(modulePath, exportName);
|
|
299
|
+
if (typeof fn !== "function") throw new Error("export " + exportName + " in " + modulePath + " is not a function — a row that wants its shape declares expect.export_shape");
|
|
300
|
+
return await fn(...decodedArgs);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const TEMPLATE_SPELLING = "template";
|
|
304
|
+
function isTemplate(spec: unknown): boolean {
|
|
305
|
+
return spec !== null && typeof spec === "object" && TEMPLATE_SPELLING in (spec as object);
|
|
306
|
+
}
|
|
307
|
+
function templateStrings(parts: string[]): TemplateStringsArray {
|
|
308
|
+
const out = parts.slice() as string[] & { raw: readonly string[] };
|
|
309
|
+
Object.defineProperty(out, "raw", { value: parts, enumerable: false, writable: false });
|
|
310
|
+
return Object.freeze(out) as unknown as TemplateStringsArray;
|
|
311
|
+
}
|
|
312
|
+
function decodeTemplate(slug: string, spec: Record<string, unknown>): unknown[] {
|
|
313
|
+
const parts = spec[TEMPLATE_SPELLING];
|
|
314
|
+
if (!Array.isArray(parts) || parts.some((part) => typeof part !== "string")) throw new Error("row " + slug + " is malformed: template is the ordered array of STATIC string parts between the interpolations — a tagged template's literals, never the statement with the value spliced in");
|
|
315
|
+
const values = spec.values === undefined || spec.values === null ? [] : spec.values;
|
|
316
|
+
if (!Array.isArray(values)) throw new Error("row " + slug + " is malformed: template values is the positional array of interpolated values, got " + typeof values);
|
|
317
|
+
if (values.length !== parts.length - 1) throw new Error("row " + slug + " is malformed: template declares " + values.length + " values for " + parts.length + " string parts — a tagged template has exactly one interpolation per gap, so values.length must be parts.length - 1");
|
|
318
|
+
return [templateStrings(parts as string[]), ...(values as unknown[])];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function applyEnv(env?: Record<string, string>): () => void {
|
|
322
|
+
const before: Record<string, string | undefined> = {};
|
|
323
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
324
|
+
before[key] = process.env[key];
|
|
325
|
+
process.env[key] = value;
|
|
326
|
+
}
|
|
327
|
+
return () => {
|
|
328
|
+
for (const [key, value] of Object.entries(before)) {
|
|
329
|
+
if (value === undefined) delete process.env[key];
|
|
330
|
+
else process.env[key] = value;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function assertWellFormed(row: Vector): void {
|
|
336
|
+
const bad: (msg: string) => never = (msg) => {
|
|
337
|
+
throw new Error("row " + row.slug + " is malformed: " + msg);
|
|
338
|
+
};
|
|
339
|
+
if (PKG !== "*" && row.package !== PKG) bad("package " + JSON.stringify(row.package) + " — expected " + JSON.stringify(PKG));
|
|
340
|
+
if (row.suite_type === "function_call") {
|
|
341
|
+
if (typeof row.export !== "string" || row.export.length === 0) bad("export must be a non-empty string");
|
|
342
|
+
if (row.args === null || row.env === null) bad("args and env must be arrays/objects, never null — null and an absent key are different states");
|
|
343
|
+
if (row.args !== undefined && !Array.isArray(row.args)) bad("args must be a positional array (ruling 18:02 #1)");
|
|
344
|
+
if (row.args === undefined && row.template === undefined) bad("declares neither args nor template — a call needs its arguments, and an absent args and an empty one are different states");
|
|
345
|
+
if (row.expect !== undefined && row.expect_error !== undefined) bad("declares both expect and expect_error — the arms are XOR");
|
|
346
|
+
if (row.expect_throw !== undefined && (row.expect !== undefined || row.expect_error !== undefined)) bad("declares expect_throw beside expect or expect_error — the arms are XOR: a throw is either synchronous (expect_throw) or awaited (expect_error), and one call cannot be both");
|
|
347
|
+
if (row.expect_void !== undefined) {
|
|
348
|
+
if (row.expect_void !== true) bad("expect_void must be true — it is the VOID claim (the call returns nothing), and false would assert a value the row never spells");
|
|
349
|
+
if (row.expect !== undefined || row.expect_error !== undefined) bad("declares expect_void beside expect or expect_error — the arms are XOR: a void subject returns nothing, so there is no value to compare and no error to match");
|
|
350
|
+
} else if (row.expect === undefined && row.expect_error === undefined && row.expect_throw === undefined && !hasThen(row) && !hasCallEach(row) && row.then_return === undefined) bad("declares neither expect nor expect_error nor expect_void nor expect_throw, and no then, then_call_each or then_return steps — a row that asserts nothing asserts nothing");
|
|
351
|
+
if (row.args === null || row.env === null) bad("args and env must be arrays/objects, never null");
|
|
352
|
+
assertOneAxis("row " + row.slug, row.expect, row.args as unknown[] | undefined, row.expect_error);
|
|
353
|
+
} else if (row.suite_type === "fs_flow") {
|
|
354
|
+
if (!Array.isArray(row.steps) || (row.steps as unknown[]).length === 0) bad("fs_flow needs non-empty ordered steps");
|
|
355
|
+
for (let i = 0; i < (row.steps as unknown[]).length; i++) {
|
|
356
|
+
const step = (row.steps as Record<string, unknown>[])[i];
|
|
357
|
+
if (typeof step.call !== "string" || step.call.length === 0) bad("steps[" + i + "].call must be a non-empty string");
|
|
358
|
+
if (step.args === null) bad("steps[" + i + "].args must be a positional array, never null — null and an absent key are different states");
|
|
359
|
+
if (step.args !== undefined && !Array.isArray(step.args)) bad("steps[" + i + "].args must be a positional array");
|
|
360
|
+
if (step.expect === undefined && step.expect_error === undefined && step.expect_void === undefined) bad("steps[" + i + "] asserts nothing");
|
|
361
|
+
if (step.expect_void !== undefined) {
|
|
362
|
+
if (step.expect_void !== true) bad("steps[" + i + "].expect_void must be true — it is the VOID claim, and false would assert a value the row never spells");
|
|
363
|
+
if (step.expect !== undefined || step.expect_error !== undefined) bad("steps[" + i + "] declares expect_void beside a value arm — the arms are XOR");
|
|
364
|
+
}
|
|
365
|
+
assertOneAxis("steps[" + i + "] of row " + row.slug, step.expect, step.args as unknown[] | undefined, step.expect_error);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
bad("unknown suite_type " + JSON.stringify(row.suite_type));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function runThen(slug: string, returned: unknown, steps: Vector[]): Promise<void> {
|
|
373
|
+
if (!Array.isArray(steps)) throw new Error("row " + slug + " is malformed: then is an ordered array of {call, args?, expect?, expect_error?} steps");
|
|
374
|
+
for (const step of steps) {
|
|
375
|
+
if (step === null || typeof step !== "object" || typeof step.call !== "string" || step.call.length === 0) throw new Error("row " + slug + " is malformed: a then step names no call");
|
|
376
|
+
const parts = String(step.call).split(".");
|
|
377
|
+
let target: unknown = returned;
|
|
378
|
+
let receiver: unknown = returned;
|
|
379
|
+
for (const part of parts) {
|
|
380
|
+
if (target === null || target === undefined) throw new Error("row " + slug + " calls " + step.call + " but the value it walks is " + String(target));
|
|
381
|
+
receiver = target;
|
|
382
|
+
target = (target as Record<string, unknown>)[part];
|
|
383
|
+
}
|
|
384
|
+
if (typeof target !== "function") throw new Error("row " + slug + " calls " + step.call + " and it is " + (target === undefined ? "absent from what the call returned" : typeof target) + " — a member the return does not carry cannot be a contract");
|
|
385
|
+
const stepArgs = step.args === undefined || step.args === null ? [] : step.args;
|
|
386
|
+
if (!Array.isArray(stepArgs)) throw new Error("row " + slug + " is malformed: then step " + step.call + " args is a positional array");
|
|
387
|
+
if (step.args !== undefined && step.template !== undefined) throw new Error("row " + slug + " then step " + step.call + " declares both args and template — two ways to say the same call is a row whose intent is unreadable");
|
|
388
|
+
if (step.args === undefined && step.template === undefined) throw new Error("row " + slug + " then step " + step.call + " declares neither args nor template — a member call needs its arguments");
|
|
389
|
+
const effectiveArgs = step.template !== undefined && step.template !== null ? decodeTemplate(slug, step.template as Record<string, unknown>) : (stepArgs as unknown[]);
|
|
390
|
+
const called = (target as (...a: unknown[]) => unknown).apply(receiver, await Promise.all(effectiveArgs.map(decodeValue)));
|
|
391
|
+
if (step.expect_error !== undefined) {
|
|
392
|
+
await expect(Promise.resolve(called)).rejects.toThrow(String(step.expect_error));
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const answer = await called;
|
|
396
|
+
if (step.expect === undefined) continue;
|
|
397
|
+
if (isStructural(step.expect) && (step.expect as Record<string, unknown>)[UNDEFINED_SPELLING] === true) expect(answer).toBeUndefined();
|
|
398
|
+
else if (isStructural(step.expect)) assertStructural(slug, answer, step.expect as Record<string, unknown>);
|
|
399
|
+
else expect(answer).toEqual(step.expect);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function expectCall(row: Vector, exportName: string, args: unknown[]): Promise<void> {
|
|
404
|
+
const restore = applyEnv(row.env);
|
|
405
|
+
try {
|
|
406
|
+
await withStubs(row, async () => {
|
|
407
|
+
if (row.hoist !== undefined && row.hoist !== null) {
|
|
408
|
+
if (!Array.isArray(row.hoist)) throw new Error("row " + row.slug + " is malformed: hoist is an ordered array of call steps, got " + typeof row.hoist);
|
|
409
|
+
for (const step of row.hoist as Vector[]) {
|
|
410
|
+
if (step === null || typeof step !== "object" || typeof step.export !== "string" || step.export.length === 0) throw new Error("row " + row.slug + " is malformed: a hoist step names no export — each step is {export, args?, module?}");
|
|
411
|
+
const stepArgs = step.args === undefined || step.args === null ? [] : step.args;
|
|
412
|
+
if (!Array.isArray(stepArgs)) throw new Error("row " + row.slug + " is malformed: hoist step " + step.export + " args is a positional array, got " + typeof stepArgs);
|
|
413
|
+
await callExport(String(step.module ?? row.module), String(step.export), await Promise.all((stepArgs as unknown[]).map(decodeValue)));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (row.expect_void === true) {
|
|
417
|
+
expect(await callExport(row.module, exportName, await Promise.all(args.map(decodeValue)))).toBeUndefined();
|
|
418
|
+
} else if (isExportShape(row.expect)) {
|
|
419
|
+
assertStructural(row.slug, await loadExport(row.module, exportName), (row.expect as Record<string, unknown>)[EXPORT_SHAPE] as Record<string, unknown>);
|
|
420
|
+
} else if (row.expect_error === undefined && row.expect_throw === undefined) {
|
|
421
|
+
const decoded = await Promise.all(args.map(decodeValue));
|
|
422
|
+
if (isStructural(row.expect) && (row.expect as Record<string, unknown>)[ARG_SHAPE] !== undefined) argTarget(row.slug, row.expect as Record<string, unknown>, decoded);
|
|
423
|
+
const value = await callExport(row.module, exportName, decoded);
|
|
424
|
+
if (row.then !== undefined && row.then !== null) await runThen(row.slug, value, row.then as Vector[]);
|
|
425
|
+
if (row.then_call_each !== undefined && row.then_call_each !== null) await callEach(row.slug, value, row.then_call_each as Vector[]);
|
|
426
|
+
if (row.then_return !== undefined && row.then_return !== null) await callReturned(row.slug, value, row.then_return as Vector);
|
|
427
|
+
if (isStructural(row.expect) && UNDEFINED_SPELLING in (row.expect as Record<string, unknown>)) {
|
|
428
|
+
if ((row.expect as Record<string, unknown>)[UNDEFINED_SPELLING] !== true) throw new Error("row " + row.slug + " is malformed: " + UNDEFINED_SPELLING + " is the claim that the call returned nothing — it takes true, and any other value is a row asserting a truthiness nobody declared");
|
|
429
|
+
expect(value).toBeUndefined();
|
|
430
|
+
}
|
|
431
|
+
else if (isStructural(row.expect)) {
|
|
432
|
+
if ((row.expect as Record<string, unknown>)[ARG_SHAPE] !== undefined) assertArgShape(row.slug, row.expect as Record<string, unknown>, decoded);
|
|
433
|
+
else assertStructural(row.slug, value, row.expect as Record<string, unknown>, decoded);
|
|
434
|
+
}
|
|
435
|
+
else if (isTextSpec(row.expect)) assertText(row.slug, value, row.expect as Record<string, unknown>);
|
|
436
|
+
else if (row.expect !== undefined) expect(value).toEqual(row.expect);
|
|
437
|
+
} else if (row.expect_throw !== undefined) {
|
|
438
|
+
const throwArgs = await Promise.all(args.map(decodeValue));
|
|
439
|
+
const syncExport = await loadExport(row.module, exportName);
|
|
440
|
+
if (typeof syncExport !== "function") throw new Error("export " + exportName + " in " + row.module + " is not a function — expect_throw needs a callable to throw from");
|
|
441
|
+
expect(() => (syncExport as (...a: unknown[]) => unknown)(...throwArgs)).toThrow(String(row.expect_throw));
|
|
442
|
+
} else {
|
|
443
|
+
const raised = callExport(row.module, exportName, await Promise.all(args.map(decodeValue)));
|
|
444
|
+
if (Array.isArray(row.expect_error)) {
|
|
445
|
+
for (const needle of row.expect_error as unknown[]) {
|
|
446
|
+
if (typeof needle !== "string") throw new Error("row " + row.slug + " is malformed: an expect_error array is a list of literal substrings, and one entry is " + typeof needle);
|
|
447
|
+
await expect(raised).rejects.toThrow(needle);
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
const wanted = row.expect_error !== null && typeof row.expect_error === "object" && "matches" in (row.expect_error as object)
|
|
451
|
+
? new RegExp(String((row.expect_error as { matches: unknown }).matches), (row.expect_error as { flags?: unknown }).flags === undefined ? "" : String((row.expect_error as { flags?: unknown }).flags))
|
|
452
|
+
: row.expect_error;
|
|
453
|
+
await expect(raised).rejects.toThrow(wanted as string | RegExp);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
} finally {
|
|
458
|
+
restore();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function stepTarget(row: Vector, step: Record<string, unknown>): [string, string] {
|
|
463
|
+
if (row.module !== undefined && row.module !== null && row.module !== "") return [String(row.module), String(step.call)];
|
|
464
|
+
const spelled = String(step.call);
|
|
465
|
+
const colon = spelled.lastIndexOf(":");
|
|
466
|
+
if (colon <= 0 || colon === spelled.length - 1) {
|
|
467
|
+
throw new Error("row " + row.slug + " is malformed: a step with no row module spells call as <module>:<export>, got " + JSON.stringify(spelled));
|
|
468
|
+
}
|
|
469
|
+
return [spelled.slice(0, colon), spelled.slice(colon + 1)];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const STRUCTURAL_VERBS = ["has_members", "member_is", "member_equals", "reads_resolve", "member_throws", "member_equals_expression", "includes", "excludes", "is_undefined", "is_null", "is_defined", "instance_of", "same_ref", "compare", "set_has", "map_get"] as const;
|
|
473
|
+
const EXPORT_SHAPE = "export_shape";
|
|
474
|
+
const ARG_SHAPE = "arg_shape";
|
|
475
|
+
const UNDEFINED_SPELLING = "$undefined";
|
|
476
|
+
const TEXT_VERBS = ["contains_text", "absent", "in_order", "repeated", "matches", "length", "max_length"] as const;
|
|
477
|
+
function containsIn(slug: string, value: unknown, spec: { path?: string; needles?: string[] }): void {
|
|
478
|
+
if (spec === null || typeof spec !== "object" || typeof spec.path !== "string" || !Array.isArray(spec.needles)) throw new Error("row " + slug + " is malformed: contains_in needs {path, needles}");
|
|
479
|
+
const found = memberAt(value, spec.path);
|
|
480
|
+
if (typeof found !== "string") throw new Error("row " + slug + " is malformed: contains_in reads " + spec.path + " and it is " + (found === undefined ? "absent from what the call returned" : typeof found) + ", not a string to read");
|
|
481
|
+
for (const needle of spec.needles as string[]) {
|
|
482
|
+
if (!(found as string).includes(needle)) throw new Error("row " + slug + " text expect did not hold: " + spec.path + " has no " + JSON.stringify(needle));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function isTextSpec(spec: unknown): boolean {
|
|
487
|
+
return (
|
|
488
|
+
spec !== null &&
|
|
489
|
+
typeof spec === "object" &&
|
|
490
|
+
!Array.isArray(spec) &&
|
|
491
|
+
Object.keys(spec as object).some((key) => (TEXT_VERBS as readonly string[]).includes(key) || key === "contains_in")
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function assertText(slug: string, value: unknown, spec: Record<string, unknown>): void {
|
|
496
|
+
const bad: (msg: string) => never = (msg) => {
|
|
497
|
+
throw new Error("row " + slug + " text expect did not hold: " + msg);
|
|
498
|
+
};
|
|
499
|
+
if (spec.contains_in !== undefined) {
|
|
500
|
+
containsIn(slug, value, spec.contains_in as { path?: string; needles?: string[] });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (typeof value !== "string") {
|
|
504
|
+
throw new Error("row " + slug + " is malformed: a text expect asserts a returned string, got " + (value === null ? "null" : typeof value));
|
|
505
|
+
}
|
|
506
|
+
const text = value as string;
|
|
507
|
+
for (const verb of Object.keys(spec)) {
|
|
508
|
+
if (!(TEXT_VERBS as readonly string[]).includes(verb)) {
|
|
509
|
+
throw new Error("row " + slug + " is malformed: text expect " + verb + " is not a declared verb " + JSON.stringify(TEXT_VERBS) + " — a predicate this runner does not understand is a case it stopped asserting");
|
|
510
|
+
}
|
|
511
|
+
const needles = spec[verb];
|
|
512
|
+
if (verb === "contains_text") {
|
|
513
|
+
for (const needle of needles as string[]) if (!text.includes(needle)) bad("the text has no " + JSON.stringify(needle));
|
|
514
|
+
} else if (verb === "absent") {
|
|
515
|
+
for (const needle of needles as string[]) if (text.includes(needle)) bad("the text carries " + JSON.stringify(needle));
|
|
516
|
+
} else if (verb === "in_order") {
|
|
517
|
+
let after = -1;
|
|
518
|
+
let previous = "the start";
|
|
519
|
+
for (const marker of needles as string[]) {
|
|
520
|
+
const at = text.indexOf(marker);
|
|
521
|
+
if (at === -1) bad(JSON.stringify(marker) + " is not in the text at all");
|
|
522
|
+
if (at <= after) bad(JSON.stringify(marker) + " is not after " + previous);
|
|
523
|
+
after = at;
|
|
524
|
+
previous = JSON.stringify(marker);
|
|
525
|
+
}
|
|
526
|
+
} else if (verb === "length") {
|
|
527
|
+
if (typeof needles !== "number" || !Number.isInteger(needles) || needles < 0) throw new Error("row " + slug + " is malformed: length needs a non-negative integer — the exact character count the text carries");
|
|
528
|
+
if (text.length !== needles) bad("the text is " + text.length + " characters, not the declared " + needles);
|
|
529
|
+
} else if (verb === "max_length") {
|
|
530
|
+
if (typeof needles !== "number" || !Number.isInteger(needles) || needles < 0) throw new Error("row " + slug + " is malformed: max_length needs a non-negative integer — the bound the text must not pass");
|
|
531
|
+
if (text.length > needles) bad("the text is " + text.length + " characters, past the declared bound of " + needles);
|
|
532
|
+
} else if (verb === "matches") {
|
|
533
|
+
if (typeof needles !== "string") throw new Error("row " + slug + " is malformed: matches needs a regex source string");
|
|
534
|
+
let matched: boolean;
|
|
535
|
+
try {
|
|
536
|
+
matched = new RegExp(needles).test(text);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
throw new Error("row " + slug + " is malformed: matches is not a usable regex — " + String(err));
|
|
539
|
+
}
|
|
540
|
+
if (!matched) bad("the text does not match " + JSON.stringify(needles));
|
|
541
|
+
} else {
|
|
542
|
+
const rep = needles as { pattern?: unknown; count?: unknown; group?: unknown };
|
|
543
|
+
if (typeof rep.pattern !== "string" || typeof rep.count !== "number") {
|
|
544
|
+
throw new Error("row " + slug + " is malformed: repeated needs {pattern, count} and an optional group — how many times the pattern matches, and which capture must not drift between the matches");
|
|
545
|
+
}
|
|
546
|
+
let matches: RegExpMatchArray[];
|
|
547
|
+
try {
|
|
548
|
+
matches = [...text.matchAll(new RegExp(rep.pattern, "g"))];
|
|
549
|
+
} catch (err) {
|
|
550
|
+
throw new Error("row " + slug + " is malformed: repeated.pattern is not a usable regex — " + String(err));
|
|
551
|
+
}
|
|
552
|
+
if (matches.length !== rep.count) bad("the pattern matched " + matches.length + " times, not the declared " + rep.count);
|
|
553
|
+
if (rep.group !== undefined) {
|
|
554
|
+
const group = rep.group as number;
|
|
555
|
+
const first = matches[0]?.[group];
|
|
556
|
+
if (first === undefined) bad("capture group " + group + " took no part in the first match");
|
|
557
|
+
for (const match of matches) {
|
|
558
|
+
if (match[group] !== first) bad("capture group " + group + " differs between matches: " + JSON.stringify(first) + " vs " + JSON.stringify(match[group]) + " — the two copies of this text must be the same set");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
function isExportShape(spec: unknown): boolean {
|
|
567
|
+
return (
|
|
568
|
+
spec !== null &&
|
|
569
|
+
typeof spec === "object" &&
|
|
570
|
+
!Array.isArray(spec) &&
|
|
571
|
+
(spec as Record<string, unknown>)[EXPORT_SHAPE] !== undefined
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function assertOneAxis(where: string, spec: unknown, args: unknown[] | undefined, expectError: unknown): void {
|
|
576
|
+
const bad: (msg: string) => never = (msg) => {
|
|
577
|
+
throw new Error(where + " is malformed: " + msg);
|
|
578
|
+
};
|
|
579
|
+
if (isExportShape(spec)) {
|
|
580
|
+
const declared = (spec as Record<string, unknown>)[EXPORT_SHAPE];
|
|
581
|
+
if (declared === null || typeof declared !== "object" || Array.isArray(declared)) bad(EXPORT_SHAPE + " must be a structural spec object");
|
|
582
|
+
if (expectError !== undefined) bad(EXPORT_SHAPE + " with expect_error — no call is made, so nothing is raised to match");
|
|
583
|
+
if (args !== undefined && args.length > 0) bad(EXPORT_SHAPE + " declares args — the export is not called, so nothing receives them");
|
|
584
|
+
for (const verb of Object.keys(spec as object)) {
|
|
585
|
+
if (verb !== EXPORT_SHAPE) bad("expect declares " + verb + " alongside " + EXPORT_SHAPE + " — one row states one axis; a row that wants both is two rows");
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (isRenderRow(spec, args)) {
|
|
590
|
+
if (args !== undefined && args.length > 1) bad("a render row takes at most one arg — the props object, not a positional list");
|
|
591
|
+
if (spec === null || spec === undefined || typeof spec !== "object" || Array.isArray(spec)) return;
|
|
592
|
+
for (const verb of Object.keys(spec as object)) {
|
|
593
|
+
if (verb !== "render") bad("expect declares " + verb + " alongside render — one row states one axis; a row that wants both is two rows");
|
|
594
|
+
}
|
|
595
|
+
const renderSpec = (spec as Record<string, unknown>).render;
|
|
596
|
+
if (renderSpec === null || typeof renderSpec !== "object" || Array.isArray(renderSpec)) bad("render must be a spec object");
|
|
597
|
+
for (const verb of Object.keys(renderSpec as object)) {
|
|
598
|
+
if (!(RENDER_VERBS as readonly string[]).includes(verb)) bad("render " + verb + " is not a declared verb " + JSON.stringify(RENDER_VERBS) + " — a predicate this runner does not understand is a case it stopped asserting");
|
|
599
|
+
}
|
|
600
|
+
const clickSelectors = (renderSpec as Record<string, unknown>).click;
|
|
601
|
+
if (clickSelectors !== undefined) {
|
|
602
|
+
if ((spec as Record<string, unknown>).expect_error !== undefined) bad("click beside expect_error — a row that expects the render to throw has nothing to click");
|
|
603
|
+
const selectors = (Array.isArray(clickSelectors) ? clickSelectors : [clickSelectors]) as unknown[];
|
|
604
|
+
if (selectors.length === 0) bad("click needs at least one selector");
|
|
605
|
+
for (const selector of selectors) {
|
|
606
|
+
if (typeof selector !== "string" || selector.length === 0) bad("click selectors must be non-empty strings, got " + JSON.stringify(selector));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (spec === null || spec === undefined || typeof spec !== "object" || Array.isArray(spec)) return;
|
|
612
|
+
if (isHook(spec)) {
|
|
613
|
+
if ((spec as Record<string, unknown>).expect_error !== undefined) bad("hook beside expect_error — a hook row's failure is the mount or the act, and the runner rethrows it; there is no separate error claim to match");
|
|
614
|
+
for (const verb of Object.keys(spec as object)) {
|
|
615
|
+
if (verb !== "hook") bad("expect declares " + verb + " alongside hook — one row states one axis; a row that wants both is two rows");
|
|
616
|
+
}
|
|
617
|
+
const hookSpec = (spec as Record<string, unknown>).hook;
|
|
618
|
+
if (hookSpec === null || typeof hookSpec !== "object" || Array.isArray(hookSpec)) bad("hook must be a spec object");
|
|
619
|
+
for (const verb of Object.keys(hookSpec as object)) {
|
|
620
|
+
if (!(HOOK_VERBS as readonly string[]).includes(verb) && !(HOOK_MOUNT_KEYS as readonly string[]).includes(verb)) bad("hook " + verb + " is not a declared verb " + JSON.stringify([...HOOK_VERBS, ...HOOK_MOUNT_KEYS]) + " — a verb this runner does not read is a claim the row never makes");
|
|
621
|
+
}
|
|
622
|
+
const resultSpec = (hookSpec as Record<string, unknown>).result;
|
|
623
|
+
if (resultSpec === null || typeof resultSpec !== "object" || Array.isArray(resultSpec)) bad("hook needs a result — a hook row that asserts nothing asserts nothing");
|
|
624
|
+
for (const verb of Object.keys(resultSpec as object)) {
|
|
625
|
+
if (!(HOOK_RESULT_VERBS as readonly string[]).includes(verb)) bad("hook.result " + verb + " is not a declared verb " + JSON.stringify(HOOK_RESULT_VERBS) + " — a verb this runner does not read is a claim the row never makes");
|
|
626
|
+
}
|
|
627
|
+
if (Object.keys(resultSpec as object).length === 0) bad("hook.result declares no verb — a result spec with no verb asserts nothing");
|
|
628
|
+
const fields = (resultSpec as Record<string, unknown>).field_equals;
|
|
629
|
+
if (fields !== undefined) {
|
|
630
|
+
if (fields === null || typeof fields !== "object" || Array.isArray(fields)) bad("hook.result.field_equals must be an object of field: value");
|
|
631
|
+
if (Object.keys(fields as object).length === 0) bad("hook.result.field_equals declares no field — a field_equals with no field equals nothing");
|
|
632
|
+
}
|
|
633
|
+
const acts = (hookSpec as Record<string, unknown>).act;
|
|
634
|
+
if (acts !== undefined) {
|
|
635
|
+
if (!Array.isArray(acts)) bad("hook.act must be an ordered array — the acts run in the order the row declares them, because each sees the state the one before left");
|
|
636
|
+
for (const [j, entry] of (acts as unknown[]).entries()) {
|
|
637
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) bad("hook.act[" + j + "] must be an object {call, args}");
|
|
638
|
+
const step = entry as Record<string, unknown>;
|
|
639
|
+
for (const k of Object.keys(step)) {
|
|
640
|
+
if (k !== "call" && k !== "args" && k !== "expect_error") bad("hook.act[" + j + "] declares " + k + " — an act names the returned callback to call, the arguments to call it with, and the failure it is expected to raise");
|
|
641
|
+
}
|
|
642
|
+
if (typeof step.call !== "string" || step.call.length === 0) bad("hook.act[" + j + "] must name the callback it calls — the hook's own return is what a row drives");
|
|
643
|
+
if (step.args !== undefined && !Array.isArray(step.args)) bad("hook.act[" + j + "].args must be an array — a callback takes positional arguments");
|
|
644
|
+
if (step.expect_error !== undefined && (typeof step.expect_error !== "string" || step.expect_error.length === 0)) bad("hook.act[" + j + "].expect_error must name text the rejection carries — an error claim with no text matches everything");
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const providerName = (hookSpec as Record<string, unknown>).provider;
|
|
648
|
+
if (providerName !== undefined) {
|
|
649
|
+
if (typeof providerName !== "string" || providerName.length === 0) bad("hook.provider must name the export the hook is mounted inside — a context consumer resolves to null without its provider, so the row has to say which wrapper supplies the value");
|
|
650
|
+
const providerArgs = (hookSpec as Record<string, unknown>).provider_args;
|
|
651
|
+
if (providerArgs !== undefined && !Array.isArray(providerArgs)) bad("hook.provider_args must be an array of positional props for the provider");
|
|
652
|
+
}
|
|
653
|
+
if ((hookSpec as Record<string, unknown>).provider_args !== undefined && providerName === undefined) bad("hook.provider_args without hook.provider — props for a wrapper the row never declares reach nothing");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if ((spec as Record<string, unknown>)[ARG_SHAPE] === undefined) return;
|
|
657
|
+
const argSpec = (spec as Record<string, unknown>)[ARG_SHAPE];
|
|
658
|
+
if (argSpec === null || typeof argSpec !== "object" || Array.isArray(argSpec)) bad(ARG_SHAPE + " must be a spec object");
|
|
659
|
+
if (typeof (argSpec as { arg?: unknown }).arg !== "number") bad(ARG_SHAPE + ".arg must be declared — the positional index of the argument the subject mutates");
|
|
660
|
+
for (const verb of Object.keys(spec as object)) {
|
|
661
|
+
if (verb !== ARG_SHAPE) bad("expect declares " + verb + " alongside " + ARG_SHAPE + " — one row states one axis; a row that wants both is two rows");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function isStructural(spec: unknown): boolean {
|
|
666
|
+
return (
|
|
667
|
+
isExportShape(spec) ||
|
|
668
|
+
(spec !== null &&
|
|
669
|
+
typeof spec === "object" &&
|
|
670
|
+
!Array.isArray(spec) &&
|
|
671
|
+
Object.keys(spec as object).some((key) =>
|
|
672
|
+
(STRUCTURAL_VERBS as readonly string[]).includes(key) || key === ARG_SHAPE || key === UNDEFINED_SPELLING
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function argTarget(slug: string, spec: Record<string, unknown>, decoded: unknown[]): number {
|
|
679
|
+
const target = (spec[ARG_SHAPE] as { arg?: unknown }).arg;
|
|
680
|
+
if (typeof target !== "number" || !Number.isInteger(target) || target < 0 || target >= decoded.length) {
|
|
681
|
+
throw new Error("row " + slug + " is malformed: " + ARG_SHAPE + ".arg must be an argument position this row declares (0.." + (decoded.length - 1) + "), got " + JSON.stringify(target));
|
|
682
|
+
}
|
|
683
|
+
return target;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function assertArgShape(slug: string, spec: Record<string, unknown>, decoded: unknown[]): void {
|
|
687
|
+
assertStructural(slug, decoded[argTarget(slug, spec, decoded)], spec[ARG_SHAPE] as Record<string, unknown>, decoded, "arg");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function memberAt(value: unknown, path: string): unknown {
|
|
691
|
+
let at: unknown = value;
|
|
692
|
+
for (const key of path.split(".")) {
|
|
693
|
+
if (at === null || at === undefined) return undefined;
|
|
694
|
+
if (key === "json") {
|
|
695
|
+
const payload = typeof at === "string" ? at : ((at as { body?: unknown } | null)?.body);
|
|
696
|
+
if (typeof payload !== "string") throw new Error("the json path step reads a wire body — the string at .body, or the string the path landed on — and found " + typeof payload + ", so nothing there is a parsable payload");
|
|
697
|
+
at = JSON.parse(payload);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
at = (at as Record<string, unknown>)[key];
|
|
701
|
+
}
|
|
702
|
+
return at;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function assertStructural(slug: string, value: unknown, spec: Record<string, unknown>, refs?: unknown[], declaration?: string): void {
|
|
706
|
+
for (const verb of Object.keys(spec)) {
|
|
707
|
+
if (verb === declaration) continue;
|
|
708
|
+
if (verb === UNDEFINED_SPELLING) continue;
|
|
709
|
+
if (!(STRUCTURAL_VERBS as readonly string[]).includes(verb)) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
"row " + slug + " is malformed: structural expect " + verb +
|
|
712
|
+
" is not a declared verb " + JSON.stringify(STRUCTURAL_VERBS) +
|
|
713
|
+
" — a predicate this runner does not understand is a case it stopped asserting");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const bad: (verb: string, detail: string) => never = (verb, detail) => {
|
|
717
|
+
throw new Error("row " + slug + " outcome " + verb + " did not hold: " + detail);
|
|
718
|
+
};
|
|
719
|
+
if (spec.has_members !== undefined) {
|
|
720
|
+
for (const path of spec.has_members as string[]) {
|
|
721
|
+
if (memberAt(value, path) === undefined) bad("has_members", path + " is not declared");
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (spec.includes !== undefined) {
|
|
725
|
+
if (!Array.isArray(value)) bad("includes", "the call returned " + (value === undefined ? "nothing" : typeof value) + ", not an array to read");
|
|
726
|
+
for (const needle of spec.includes as unknown[]) {
|
|
727
|
+
if (!(value as unknown[]).some((entry) => JSON.stringify(entry) === JSON.stringify(needle))) bad("includes", JSON.stringify(needle) + " is not among the " + (value as unknown[]).length + " returned");
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (spec.excludes !== undefined) {
|
|
731
|
+
if (!Array.isArray(value)) bad("excludes", "the call returned " + (value === undefined ? "nothing" : typeof value) + ", not an array to read");
|
|
732
|
+
for (const needle of spec.excludes as unknown[]) {
|
|
733
|
+
if ((value as unknown[]).some((entry) => JSON.stringify(entry) === JSON.stringify(needle))) bad("excludes", JSON.stringify(needle) + " is among the returned, the row declares it absent");
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (spec.member_is !== undefined) {
|
|
737
|
+
for (const [path, kind] of Object.entries(spec.member_is as Record<string, string>)) {
|
|
738
|
+
const got = memberAt(value, path);
|
|
739
|
+
if (typeof got !== kind) bad("member_is", path + " is " + (got === undefined ? "absent (undefined)" : typeof got) + ", the row declares " + kind);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (spec.member_equals !== undefined) {
|
|
743
|
+
for (const [path, want] of Object.entries(spec.member_equals as Record<string, unknown>)) {
|
|
744
|
+
expect(memberAt(value, path)).toEqual(want);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (spec.reads_resolve !== undefined) {
|
|
748
|
+
for (const path of spec.reads_resolve as string[]) {
|
|
749
|
+
try {
|
|
750
|
+
memberAt(value, path);
|
|
751
|
+
} catch (err) {
|
|
752
|
+
bad("reads_resolve", "reading " + path + " threw " + String(err));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (spec.member_throws !== undefined) {
|
|
757
|
+
for (const rel of spec.member_throws as { path?: string; contains?: string[] }[]) {
|
|
758
|
+
if (rel === null || typeof rel !== "object" || typeof rel.path !== "string" || (rel.path as string).length === 0) bad("member_throws", "needs {path, contains?} — the member whose read must throw, and the needles the message must carry");
|
|
759
|
+
let raised: unknown;
|
|
760
|
+
try {
|
|
761
|
+
memberAt(value, rel.path as string);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
raised = err;
|
|
764
|
+
}
|
|
765
|
+
if (raised === undefined) bad("member_throws", "reading " + rel.path + " returned without throwing — the wrapper admits what the row refuses");
|
|
766
|
+
const message = raised instanceof Error ? raised.message : String(raised);
|
|
767
|
+
for (const needle of rel.contains ?? []) {
|
|
768
|
+
if (!message.includes(needle)) bad("member_throws", "the throw reads " + JSON.stringify(message) + ", which does not carry " + JSON.stringify(needle));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (spec.member_equals_expression !== undefined) {
|
|
773
|
+
const rel = spec.member_equals_expression as { path?: string; equals?: string; within?: number };
|
|
774
|
+
if (rel === null || typeof rel !== "object" || typeof rel.path !== "string" || typeof rel.equals !== "string") bad("member_equals_expression", "needs {path, equals}");
|
|
775
|
+
const got = memberAt(value, rel.path);
|
|
776
|
+
if (typeof got !== "number") bad("member_equals_expression", rel.path + " is " + (got === undefined ? "absent from what the call returned" : typeof got) + ", the row states an arithmetic relation over a number");
|
|
777
|
+
const want = arithmetic(rel.equals, value, slug);
|
|
778
|
+
const within = rel.within === undefined ? 1e-9 : rel.within;
|
|
779
|
+
if (!(Math.abs(got - want) <= within)) bad("member_equals_expression", rel.path + " is " + got + ", the row states it equals " + rel.equals + " over the returned members, which is " + want + " (within " + within + ")");
|
|
780
|
+
}
|
|
781
|
+
if (spec.is_undefined !== undefined) {
|
|
782
|
+
if (spec.is_undefined !== true) throw new Error("row " + slug + " is malformed: is_undefined is the claim that the call returned nothing — it takes true, and any other value is a row asserting a truthiness nobody declared");
|
|
783
|
+
if (value !== undefined) bad("is_undefined", "the call returned a value (" + typeof value + "), not nothing");
|
|
784
|
+
}
|
|
785
|
+
if (spec.is_null !== undefined) {
|
|
786
|
+
if (spec.is_null !== true) throw new Error("row " + slug + " is malformed: is_null takes true — comparing against null as a value is equals");
|
|
787
|
+
if (value !== null) bad("is_null", "the call returned " + (value === undefined ? "nothing" : JSON.stringify(value)) + ", not null");
|
|
788
|
+
}
|
|
789
|
+
if (spec.is_defined !== undefined) {
|
|
790
|
+
if (spec.is_defined !== true) throw new Error("row " + slug + " is malformed: is_defined takes true — the claim is that the call returned something usable, not null and not nothing");
|
|
791
|
+
if (value === undefined || value === null) bad("is_defined", "the call returned " + (value === undefined ? "nothing" : "null"));
|
|
792
|
+
}
|
|
793
|
+
if (spec.instance_of !== undefined) {
|
|
794
|
+
if (typeof spec.instance_of !== "string" || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(spec.instance_of)) throw new Error("row " + slug + " is malformed: instance_of names a constructor (an identifier), got " + JSON.stringify(spec.instance_of));
|
|
795
|
+
const ctor = (globalThis as Record<string, unknown>)[spec.instance_of];
|
|
796
|
+
if (typeof ctor !== "function") throw new Error("row " + slug + " is malformed: instance_of names " + spec.instance_of + ", which this runtime does not bind");
|
|
797
|
+
if (!(value instanceof ctor)) bad("instance_of", "the call returned a value that is not an instance of " + spec.instance_of);
|
|
798
|
+
}
|
|
799
|
+
if (spec.same_ref !== undefined) {
|
|
800
|
+
const rel = spec.same_ref as { arg?: unknown; is?: boolean };
|
|
801
|
+
if (rel === null || typeof rel !== "object" || typeof rel.arg !== "number" || !Number.isInteger(rel.arg) || rel.arg < 0 || rel.arg >= (refs?.length ?? 0)) throw new Error("row " + slug + " is malformed: same_ref is {arg, is?} naming an argument position this row declares, got " + JSON.stringify(rel) + " against " + (refs?.length ?? 0) + " decoded args");
|
|
802
|
+
const same = value === refs?.[rel.arg];
|
|
803
|
+
if (rel.is === false ? same : !same) bad("same_ref", "the call returned " + (same ? "the same reference as" : "a different reference from") + " arg " + rel.arg + ", the row declares the opposite");
|
|
804
|
+
}
|
|
805
|
+
if (spec.compare !== undefined) {
|
|
806
|
+
const cmp = spec.compare as Record<string, unknown>;
|
|
807
|
+
const ops = cmp === null || typeof cmp !== "object" ? [] : Object.keys(cmp).filter((k) => ["gt", "gte", "lt", "lte"].includes(k));
|
|
808
|
+
if (ops.length !== 1) throw new Error("row " + slug + " is malformed: compare states exactly one of gt, gte, lt, lte, got " + JSON.stringify(cmp));
|
|
809
|
+
const op = ops[0] as string;
|
|
810
|
+
const bound = cmp[op];
|
|
811
|
+
if (typeof bound !== "number") throw new Error("row " + slug + " is malformed: compare." + op + " takes a number, got " + JSON.stringify(bound));
|
|
812
|
+
if (typeof value !== "number") bad("compare", "the call returned " + (value === undefined ? "nothing" : typeof value) + ", the row compares a number");
|
|
813
|
+
const held = op === "gt" ? (value as number) > bound : op === "gte" ? (value as number) >= bound : op === "lt" ? (value as number) < bound : (value as number) <= bound;
|
|
814
|
+
if (!held) bad("compare", "the call returned " + value + ", which is not " + op + " " + bound);
|
|
815
|
+
}
|
|
816
|
+
if (spec.set_has !== undefined) {
|
|
817
|
+
const rel = spec.set_has as { items?: unknown; size?: unknown };
|
|
818
|
+
if (rel === null || typeof rel !== "object" || !Array.isArray(rel.items) || rel.items.length === 0) throw new Error("row " + slug + " is malformed: set_has is {items, size?} — membership over nothing asserts nothing");
|
|
819
|
+
if (!(value instanceof Set)) bad("set_has", "the call returned " + (value === undefined ? "nothing" : typeof value) + ", not a Set");
|
|
820
|
+
for (const item of rel.items as unknown[]) {
|
|
821
|
+
if (!(value as Set<unknown>).has(item)) bad("set_has", JSON.stringify(item) + " is not a member of the returned Set");
|
|
822
|
+
}
|
|
823
|
+
if (rel.size !== undefined) {
|
|
824
|
+
if (typeof rel.size !== "number") throw new Error("row " + slug + " is malformed: set_has.size takes a number, got " + JSON.stringify(rel.size));
|
|
825
|
+
if ((value as Set<unknown>).size !== rel.size) bad("set_has", "the Set holds " + (value as Set<unknown>).size + " members, the row declares " + rel.size);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (spec.map_get !== undefined) {
|
|
829
|
+
const rel = spec.map_get as { key?: unknown; equals?: unknown; has?: boolean };
|
|
830
|
+
if (rel === null || typeof rel !== "object" || !("key" in rel) || (rel.equals === undefined && rel.has === undefined)) throw new Error("row " + slug + " is malformed: map_get is {key, equals} or {key, has} — a key with neither a value nor a presence claim says nothing");
|
|
831
|
+
if (!(value instanceof Map)) bad("map_get", "the call returned " + (value === undefined ? "nothing" : typeof value) + ", not a Map");
|
|
832
|
+
const key = rel.key;
|
|
833
|
+
if (rel.has !== undefined) {
|
|
834
|
+
if (typeof rel.has !== "boolean") throw new Error("row " + slug + " is malformed: map_get.has takes a boolean, got " + JSON.stringify(rel.has));
|
|
835
|
+
if ((value as Map<unknown, unknown>).has(key) !== rel.has) bad("map_get", "the map " + ((value as Map<unknown, unknown>).has(key) ? "holds" : "does not hold") + " " + JSON.stringify(key) + ", the row declares " + rel.has);
|
|
836
|
+
}
|
|
837
|
+
if (rel.equals !== undefined) {
|
|
838
|
+
const got = (value as Map<unknown, unknown>).get(key);
|
|
839
|
+
const want = rel.equals;
|
|
840
|
+
if (JSON.stringify(got) !== JSON.stringify(want)) bad("map_get", "the map holds " + JSON.stringify(got) + " at " + JSON.stringify(key) + ", the row declares " + JSON.stringify(want));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const RENDER_VERBS = ["contains", "absent", "selector_present", "selector_absent", "attr_equals", "attr_contains", "click"] as const;
|
|
846
|
+
const HOOK_VERBS = ["act", "result"] as const;
|
|
847
|
+
const HOOK_RESULT_VERBS = ["field_equals", "contains", "absent"] as const;
|
|
848
|
+
const HOOK_MOUNT_KEYS = ["provider", "provider_args"] as const;
|
|
849
|
+
function isHook(spec: unknown): boolean {
|
|
850
|
+
return spec !== null && typeof spec === "object" && !Array.isArray(spec) && (spec as Record<string, unknown>).hook !== undefined;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function assertHookResult(slug: string, latest: unknown, spec: Record<string, unknown>): void {
|
|
854
|
+
if (latest === null || typeof latest !== "object") throw new Error("row " + slug + " outcome hook did not hold: the hook returned " + JSON.stringify(latest) + ", which carries no field to read");
|
|
855
|
+
const value = latest as Record<string, unknown>;
|
|
856
|
+
for (const [field, want] of Object.entries((spec.field_equals ?? {}) as Record<string, unknown>)) {
|
|
857
|
+
if (!(field in value)) throw new Error("row " + slug + " outcome hook did not hold: the hook returned no field " + field + ", so a claim over it asserts nothing");
|
|
858
|
+
expect(value[field]).toEqual(want);
|
|
859
|
+
}
|
|
860
|
+
const text = JSON.stringify(value) ?? "";
|
|
861
|
+
for (const needle of (spec.contains ?? []) as string[]) {
|
|
862
|
+
if (!text.includes(needle)) throw new Error("row " + slug + " outcome hook did not hold: the hook's result does not contain " + JSON.stringify(needle));
|
|
863
|
+
}
|
|
864
|
+
for (const needle of (spec.absent ?? []) as string[]) {
|
|
865
|
+
if (text.includes(needle)) throw new Error("row " + slug + " outcome hook did not hold: the hook's result still carries " + JSON.stringify(needle));
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const ELEMENT_MARKER = "$throw_on_render";
|
|
870
|
+
|
|
871
|
+
function isRender(spec: unknown): boolean {
|
|
872
|
+
return (
|
|
873
|
+
spec !== null &&
|
|
874
|
+
typeof spec === "object" &&
|
|
875
|
+
!Array.isArray(spec) &&
|
|
876
|
+
(spec as Record<string, unknown>).render !== undefined
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function hasMarker(value: unknown): boolean {
|
|
881
|
+
if (Array.isArray(value)) return value.some(hasMarker);
|
|
882
|
+
if (value !== null && typeof value === "object") {
|
|
883
|
+
if (Object.keys(value as object).includes(ELEMENT_MARKER)) return true;
|
|
884
|
+
for (const key of Object.keys(value as object)) {
|
|
885
|
+
if (hasMarker((value as Record<string, unknown>)[key])) return true;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function isRenderRow(spec: unknown, args: unknown[] | undefined): boolean {
|
|
892
|
+
return isRender(spec) || (args !== undefined && args.some(hasMarker));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function buildElements(React: typeof import("react"), value: unknown, slug: string): unknown {
|
|
896
|
+
if (Array.isArray(value)) return value.map((item, index) => {
|
|
897
|
+
const builtItem = buildElements(React, item, slug);
|
|
898
|
+
if (builtItem !== null && typeof builtItem === "object" && (builtItem as Record<string, unknown>).$$typeof !== undefined) return React.cloneElement(builtItem, { key: String(index) });
|
|
899
|
+
return builtItem;
|
|
900
|
+
});
|
|
901
|
+
if (value !== null && typeof value === "object") {
|
|
902
|
+
const record = value as Record<string, unknown>;
|
|
903
|
+
if (Object.keys(record).includes(ELEMENT_MARKER)) {
|
|
904
|
+
if (typeof record[ELEMENT_MARKER] !== "string") throw new Error("row " + slug + " is malformed: " + ELEMENT_MARKER + " takes the error message string, got " + JSON.stringify(record[ELEMENT_MARKER]));
|
|
905
|
+
const message = record[ELEMENT_MARKER] as string;
|
|
906
|
+
return React.createElement(function Thrower(): never {
|
|
907
|
+
throw new Error(message);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
const built: Record<string, unknown> = {};
|
|
911
|
+
for (const key of Object.keys(record)) {
|
|
912
|
+
if (key.startsWith("$")) throw new Error("row " + slug + " is malformed: element marker " + key + " is not declared — a marker this runner does not know is a child that would render as inert data");
|
|
913
|
+
built[key] = buildElements(React, record[key], slug);
|
|
914
|
+
}
|
|
915
|
+
return built;
|
|
916
|
+
}
|
|
917
|
+
return value;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function assertRenderSpec(slug: string, container: HTMLElement, spec: Record<string, unknown>): void {
|
|
921
|
+
const text = container.textContent ?? "";
|
|
922
|
+
const bad: (verb: string, detail: string) => never = (verb, detail) => {
|
|
923
|
+
throw new Error("row " + slug + " outcome " + verb + " did not hold: " + detail);
|
|
924
|
+
};
|
|
925
|
+
for (const needle of (spec.contains ?? []) as string[]) {
|
|
926
|
+
if (!text.includes(needle)) bad("contains", "the rendered text has no " + JSON.stringify(needle));
|
|
927
|
+
}
|
|
928
|
+
for (const needle of (spec.absent ?? []) as string[]) {
|
|
929
|
+
if (text.includes(needle)) bad("absent", "the rendered text still carries " + JSON.stringify(needle));
|
|
930
|
+
}
|
|
931
|
+
for (const selector of (spec.selector_present ?? []) as string[]) {
|
|
932
|
+
if (container.querySelector(selector) === null) bad("selector_present", "no element matches " + selector);
|
|
933
|
+
}
|
|
934
|
+
for (const selector of (spec.selector_absent ?? []) as string[]) {
|
|
935
|
+
if (container.querySelector(selector) !== null) bad("selector_absent", selector + " is present");
|
|
936
|
+
}
|
|
937
|
+
for (const [selector, claims] of Object.entries((spec.attr_equals ?? {}) as Record<string, Record<string, string>>)) {
|
|
938
|
+
const el = container.querySelector(selector);
|
|
939
|
+
if (el === null) bad("attr_equals", selector + " matches no element, so an attribute claim over it asserts nothing");
|
|
940
|
+
for (const [attr, want] of Object.entries(claims)) {
|
|
941
|
+
const got = el.getAttribute(attr);
|
|
942
|
+
if (got !== String(want)) bad("attr_equals", selector + " carries " + attr + "=" + JSON.stringify(got) + ", the row declares " + JSON.stringify(String(want)));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
for (const [selector, claims] of Object.entries((spec.attr_contains ?? {}) as Record<string, Record<string, string[]>>)) {
|
|
946
|
+
const el = container.querySelector(selector);
|
|
947
|
+
if (el === null) bad("attr_contains", selector + " matches no element, so an attribute claim over it asserts nothing");
|
|
948
|
+
for (const [attr, needles] of Object.entries(claims)) {
|
|
949
|
+
const got = el.getAttribute(attr) ?? "";
|
|
950
|
+
for (const needle of needles) {
|
|
951
|
+
if (!got.includes(needle)) bad("attr_contains", selector + " carries " + attr + "=" + JSON.stringify(got) + ", which does not carry " + JSON.stringify(needle));
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function renderRow(row: Vector): Promise<void> {
|
|
958
|
+
const restore = applyEnv(row.env);
|
|
959
|
+
try {
|
|
960
|
+
await withStubs(row, async () => {
|
|
961
|
+
if (!globalThis.document) {
|
|
962
|
+
const { GlobalRegistrator } = await import("@happy-dom/global-registrator");
|
|
963
|
+
await GlobalRegistrator.register({ url: "http://localhost" });
|
|
964
|
+
}
|
|
965
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
966
|
+
const React = await import("react");
|
|
967
|
+
const { createRoot } = await import("react-dom/client");
|
|
968
|
+
const act = (React as unknown as { act: (run: () => Promise<void>) => Promise<void> }).act;
|
|
969
|
+
const Component = await loadExport(row.module, row.export);
|
|
970
|
+
if (typeof Component !== "function") throw new Error("export " + row.export + " in " + row.module + " is not a component — a render row needs a callable component export");
|
|
971
|
+
const decoded = await Promise.all(((row.args ?? []) as unknown[]).map(decodeValue));
|
|
972
|
+
const props = decoded.length === 0 ? {} : buildElements(React, decoded[0], row.slug);
|
|
973
|
+
const host = document.createElement("div");
|
|
974
|
+
document.body.appendChild(host);
|
|
975
|
+
const root = createRoot(host);
|
|
976
|
+
let caught: unknown;
|
|
977
|
+
try {
|
|
978
|
+
await act(async () => {
|
|
979
|
+
root.render(React.createElement(Component as never, props as Record<string, unknown>));
|
|
980
|
+
});
|
|
981
|
+
} catch (err) {
|
|
982
|
+
caught = err;
|
|
983
|
+
}
|
|
984
|
+
if (row.expect_error !== undefined) {
|
|
985
|
+
if (caught === undefined) throw new Error("row " + row.slug + " expected the render to throw " + JSON.stringify(row.expect_error) + ", and it returned a tree");
|
|
986
|
+
const message = caught instanceof Error ? caught.message : String(caught);
|
|
987
|
+
if (!message.includes(String(row.expect_error))) throw new Error("row " + row.slug + " outcome expect_error did not hold: the render threw " + JSON.stringify(message));
|
|
988
|
+
await act(async () => { root.unmount(); });
|
|
989
|
+
host.remove();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (caught !== undefined) throw caught instanceof Error ? caught : new Error(String(caught));
|
|
993
|
+
const clickSelectors = ((row.expect as Record<string, unknown>).render as Record<string, unknown>).click;
|
|
994
|
+
if (clickSelectors !== undefined) {
|
|
995
|
+
for (const selector of (Array.isArray(clickSelectors) ? clickSelectors : [clickSelectors]) as string[]) {
|
|
996
|
+
const target = host.querySelector(selector);
|
|
997
|
+
if (target === null) throw new Error("row " + row.slug + " outcome click did not hold: no element matches " + selector);
|
|
998
|
+
await act(async () => {
|
|
999
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
assertRenderSpec(row.slug, host, (row.expect as Record<string, unknown>).render as Record<string, unknown>);
|
|
1004
|
+
await act(async () => { root.unmount(); });
|
|
1005
|
+
host.remove();
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
} finally {
|
|
1009
|
+
restore();
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function hookRow(row: Vector): Promise<void> {
|
|
1013
|
+
const restore = applyEnv(row.env);
|
|
1014
|
+
try {
|
|
1015
|
+
await withStubs(row, async () => {
|
|
1016
|
+
if (!globalThis.document) {
|
|
1017
|
+
const { GlobalRegistrator } = await import("@happy-dom/global-registrator");
|
|
1018
|
+
await GlobalRegistrator.register({ url: "http://localhost" });
|
|
1019
|
+
}
|
|
1020
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
1021
|
+
const React = await import("react");
|
|
1022
|
+
const { createRoot } = await import("react-dom/client");
|
|
1023
|
+
const act = (React as unknown as { act: (run: () => Promise<void>) => Promise<void> }).act;
|
|
1024
|
+
const hook = await loadExport(row.module, row.export);
|
|
1025
|
+
if (typeof hook !== "function") throw new Error("export " + row.export + " in " + row.module + " is not a hook — a hook row mounts the export and drives what it returns");
|
|
1026
|
+
const decoded = await Promise.all(((row.args ?? []) as unknown[]).map(decodeValue));
|
|
1027
|
+
if (decoded.length > 1) throw new Error("a hook row takes at most one arg — the options object the hook is mounted with");
|
|
1028
|
+
const options = decoded.length === 0 ? {} : decoded[0];
|
|
1029
|
+
let latest: unknown = undefined;
|
|
1030
|
+
const held: { value: unknown } = { value: undefined };
|
|
1031
|
+
ACTIVE_HOOK = held;
|
|
1032
|
+
function Probe(): null {
|
|
1033
|
+
latest = (hook as (o: unknown) => unknown)(options);
|
|
1034
|
+
held.value = latest;
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
const spec = ((row.expect as Record<string, unknown>).hook) as Record<string, unknown>;
|
|
1038
|
+
const providerName = spec.provider as string | undefined;
|
|
1039
|
+
let wrapped: unknown = React.createElement(Probe);
|
|
1040
|
+
if (providerName !== undefined) {
|
|
1041
|
+
const provider = await loadExport(row.module, providerName);
|
|
1042
|
+
if (typeof provider !== "function") throw new Error("row " + row.slug + " names hook.provider " + providerName + ", which " + row.module + " does not export as a component — a wrapper the module does not export leaves the hook mounted in nothing");
|
|
1043
|
+
const providerProps = await Promise.all(((spec.provider_args ?? []) as unknown[]).map(decodeValue));
|
|
1044
|
+
wrapped = React.createElement(provider as (props: unknown) => unknown, (providerProps.length === 0 ? null : providerProps[0]) as never, React.createElement(Probe));
|
|
1045
|
+
}
|
|
1046
|
+
const root = createRoot(document.createElement("div"));
|
|
1047
|
+
await act(async () => { root.render(wrapped); });
|
|
1048
|
+
for (const [j, entry] of ((spec.act ?? []) as Record<string, unknown>[]).entries()) {
|
|
1049
|
+
const returned = latest as Record<string, unknown> | null | undefined;
|
|
1050
|
+
const fn = returned === null || returned === undefined ? undefined : returned[entry.call as string];
|
|
1051
|
+
if (typeof fn !== "function") throw new Error("row " + row.slug + " outcome hook did not hold: the hook returned no callable " + entry.call + " at act[" + j + "] — a callback the row drives is one the hook itself returns");
|
|
1052
|
+
const actArgs = await Promise.all(((entry.args ?? []) as unknown[]).map(decodeValue));
|
|
1053
|
+
let raised: unknown;
|
|
1054
|
+
let failed = false;
|
|
1055
|
+
try {
|
|
1056
|
+
await act(async () => { await (fn as (...a: unknown[]) => unknown)(...actArgs); });
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
raised = err;
|
|
1059
|
+
failed = true;
|
|
1060
|
+
}
|
|
1061
|
+
if (entry.expect_error === undefined) {
|
|
1062
|
+
if (failed) throw raised;
|
|
1063
|
+
} else {
|
|
1064
|
+
if (!failed) throw new Error("row " + row.slug + " declares expect_error " + JSON.stringify(String(entry.expect_error)) + " on act[" + j + "] and " + entry.call + " RESOLVED — an act whose failure is the claim must be answered by a rejection");
|
|
1065
|
+
const message = String((raised as Error)?.message ?? raised);
|
|
1066
|
+
if (!message.includes(String(entry.expect_error))) throw new Error("row " + row.slug + " act[" + j + "] " + entry.call + " rejected with " + JSON.stringify(message) + ", which does not carry " + JSON.stringify(String(entry.expect_error)));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
assertHookResult(row.slug, latest, spec.result as Record<string, unknown>);
|
|
1070
|
+
await act(async () => { root.unmount(); });
|
|
1071
|
+
ACTIVE_HOOK = null;
|
|
1072
|
+
});
|
|
1073
|
+
} finally {
|
|
1074
|
+
ACTIVE_HOOK = null;
|
|
1075
|
+
restore();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function runFsFlow(row: Vector): Promise<void> {
|
|
1079
|
+
return (async () => {
|
|
1080
|
+
const workspace = await mkdtemp(join(tmpdir(), "contract-"));
|
|
1081
|
+
const before = process.env.SCALA_WORKSPACE_ROOT;
|
|
1082
|
+
process.env.SCALA_WORKSPACE_ROOT = workspace;
|
|
1083
|
+
try {
|
|
1084
|
+
await withStubs(row, async () => {
|
|
1085
|
+
for (const step of row.steps as Vector[]) {
|
|
1086
|
+
const [modPath, stepExport] = stepTarget(row, step);
|
|
1087
|
+
const args = await Promise.all((step.args ?? []).map(decodeValue));
|
|
1088
|
+
if (isExportShape(step.expect)) {
|
|
1089
|
+
assertStructural(row.slug, await loadExport(modPath, stepExport), (step.expect as Record<string, unknown>)[EXPORT_SHAPE] as Record<string, unknown>);
|
|
1090
|
+
} else if (step.expect_error !== undefined) {
|
|
1091
|
+
await expect(callExport(modPath, stepExport, args)).rejects.toThrow(step.expect_error);
|
|
1092
|
+
} else {
|
|
1093
|
+
if (isStructural(step.expect) && (step.expect as Record<string, unknown>)[ARG_SHAPE] !== undefined) argTarget(row.slug, step.expect as Record<string, unknown>, args);
|
|
1094
|
+
const value = await callExport(modPath, stepExport, args);
|
|
1095
|
+
if (isStructural(step.expect)) {
|
|
1096
|
+
if ((step.expect as Record<string, unknown>)[ARG_SHAPE] !== undefined) assertArgShape(row.slug, step.expect as Record<string, unknown>, args);
|
|
1097
|
+
else assertStructural(row.slug, value, step.expect as Record<string, unknown>, args);
|
|
1098
|
+
}
|
|
1099
|
+
else if (isTextSpec(step.expect)) assertText(row.slug, value, step.expect as Record<string, unknown>);
|
|
1100
|
+
else if (step.expect_void === true) expect(value).toBeUndefined();
|
|
1101
|
+
else expect(value).toEqual(step.expect);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
} finally {
|
|
1106
|
+
if (before === undefined) delete process.env.SCALA_WORKSPACE_ROOT;
|
|
1107
|
+
else process.env.SCALA_WORKSPACE_ROOT = before;
|
|
1108
|
+
await rm(workspace, { recursive: true, force: true });
|
|
1109
|
+
}
|
|
1110
|
+
})();
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
const OUTCOME_VERBS = ["count", "equals", "contains", "absent", "sql_contains", "sql_absent", "binds"] as const;
|
|
1115
|
+
|
|
1116
|
+
type Stub = Vector & { module?: string; export?: string; global?: string; hang?: boolean; record?: string; returns?: unknown; error?: unknown; when_sql_contains?: string; when_body_contains?: string; element?: unknown; value?: unknown };
|
|
1117
|
+
type Call = { args: unknown[] };
|
|
1118
|
+
|
|
1119
|
+
function stubPath(row: Vector, stub: Stub): string {
|
|
1120
|
+
const spec = String(stub.module);
|
|
1121
|
+
if (!spec.startsWith(".") && !spec.startsWith("/")) return spec;
|
|
1122
|
+
return resolve(dirname(resolve(ROOT, String(row.module))), spec);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function decodeStubReturn(value: unknown): unknown {
|
|
1126
|
+
if (Array.isArray(value)) return value.map(decodeStubReturn);
|
|
1127
|
+
if (value instanceof Promise) return value;
|
|
1128
|
+
if (typeof value === "object" && value !== null && "$date" in value) {
|
|
1129
|
+
const stubbed = new Date(String((value as { $date: unknown }).$date));
|
|
1130
|
+
if (Number.isNaN(stubbed.getTime())) throw new Error("$date " + JSON.stringify((value as { $date: unknown }).$date) + " is not an instant this runtime can read");
|
|
1131
|
+
return stubbed;
|
|
1132
|
+
}
|
|
1133
|
+
if (typeof value === "object" && value !== null) {
|
|
1134
|
+
if ("$fn" in value) {
|
|
1135
|
+
const spec = (value as { $fn: { returns?: unknown; throws?: unknown; returns_by_call?: unknown[]; records?: string; respond?: Vector[]; calls_arg?: boolean; calls_hook?: unknown; hook_args?: unknown[]; arg?: unknown; record_return?: boolean } }).$fn;
|
|
1136
|
+
if (Array.isArray(spec.returns_by_call)) {
|
|
1137
|
+
const answers = (spec.returns_by_call as unknown[]).map(decodeStubReturn);
|
|
1138
|
+
let served = 0;
|
|
1139
|
+
return () => {
|
|
1140
|
+
if (served >= answers.length) throw new Error("a stubbed member was called " + (served + 1) + " times but declares " + answers.length + " answers — the next one is a value no row declared");
|
|
1141
|
+
return answers[served++];
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
if (spec.returns !== undefined) {
|
|
1145
|
+
const answer = decodeStubReturn(spec.returns);
|
|
1146
|
+
if (spec.records === undefined) return () => answer;
|
|
1147
|
+
declareRecorder(String(spec.records));
|
|
1148
|
+
return (...callArgs: unknown[]) => {
|
|
1149
|
+
recordCall(String(spec.records), callArgs);
|
|
1150
|
+
return answer;
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
if (spec.throws !== undefined) {
|
|
1154
|
+
const raised = decodeStubReturn(spec.throws);
|
|
1155
|
+
return () => {
|
|
1156
|
+
throw raised;
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
throw new Error("a fixture stub return declares $fn with neither returns nor throws — a function whose behaviour is undeclared is a case the runner cannot run");
|
|
1160
|
+
}
|
|
1161
|
+
if ("$error" in value) {
|
|
1162
|
+
throw new Error("a fixture stub return declares $error — a stub's failure is the stub-level error key; a return is what the call answers with");
|
|
1163
|
+
}
|
|
1164
|
+
const walked: Record<string, unknown> = {};
|
|
1165
|
+
for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
|
|
1166
|
+
walked[key] = decodeStubReturn(inner);
|
|
1167
|
+
}
|
|
1168
|
+
return walked;
|
|
1169
|
+
}
|
|
1170
|
+
return value;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function hangForever(slug: string, call: Call): Promise<never> {
|
|
1174
|
+
return new Promise((_resolve, reject) => {
|
|
1175
|
+
const raw = typeof call.args[1] === "object" && call.args[1] !== null && "signal" in (call.args[1] as object) ? (call.args[1] as { signal?: unknown }).signal : undefined;
|
|
1176
|
+
if (raw !== undefined && !(raw instanceof AbortSignal)) throw new Error(`row ${slug} is malformed: a hanging stub's call carries a signal that is not an AbortSignal`);
|
|
1177
|
+
if (raw instanceof AbortSignal) {
|
|
1178
|
+
if (raw.aborted) {
|
|
1179
|
+
reject(raw.reason instanceof Error ? raw.reason : new Error(String(raw.reason ?? "aborted")));
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
raw.addEventListener("abort", () => reject(raw.reason instanceof Error ? raw.reason : new Error(String(raw.reason ?? "aborted"))), { once: true });
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function stubReturn(slug: string, group: Stub[], call: Call): unknown {
|
|
1188
|
+
const sql = typeof call.args[0] === "string" ? (call.args[0] as string) : "";
|
|
1189
|
+
const body = typeof call.args[1] === "object" && call.args[1] !== null && "body" in (call.args[1] as object) ? String((call.args[1] as { body?: unknown }).body ?? "") : "";
|
|
1190
|
+
for (const stub of group) {
|
|
1191
|
+
if (stub.when_sql_contains !== undefined && !sql.includes(String(stub.when_sql_contains))) continue;
|
|
1192
|
+
if (stub.when_body_contains !== undefined && !body.includes(String(stub.when_body_contains))) continue;
|
|
1193
|
+
if (stub.hang === true) return hangForever(slug, call);
|
|
1194
|
+
if (stub.error !== undefined) throw stub.error;
|
|
1195
|
+
return stub.returns;
|
|
1196
|
+
}
|
|
1197
|
+
if (group.every((stub) => stub.when_sql_contains !== undefined || stub.when_body_contains !== undefined)) {
|
|
1198
|
+
throw new Error(
|
|
1199
|
+
`row ${slug} is malformed: no fixture stub matches the request ` +
|
|
1200
|
+
JSON.stringify({ sql: sql.slice(0, 120), body: body.slice(0, 120) }) + " — " +
|
|
1201
|
+
"declared shapes: " + group.map((stub) => JSON.stringify(stub.when_sql_contains ?? stub.when_body_contains)).join(", "));
|
|
1202
|
+
}
|
|
1203
|
+
return group[0].returns;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function reactCreateElement(): Promise<(tag: string, attrs: Record<string, unknown>) => unknown> {
|
|
1207
|
+
const react = (await import("react")) as { createElement?: unknown };
|
|
1208
|
+
if (typeof react.createElement !== "function") throw new Error("a fixture element stub needs react.createElement, and this package does not resolve react — the arm mocks a COMPONENT, so the runtime that draws one is part of the stub");
|
|
1209
|
+
return react.createElement as (tag: string, attrs: Record<string, unknown>) => unknown;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function propsAt(props: unknown, path: string): unknown {
|
|
1213
|
+
let at: unknown = props;
|
|
1214
|
+
for (const key of path.split(".")) {
|
|
1215
|
+
if (at === null || at === undefined) return undefined;
|
|
1216
|
+
at = (at as Record<string, unknown>)[key];
|
|
1217
|
+
}
|
|
1218
|
+
return at;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function buildElement(slug: string, name: string, shape: unknown, create: (tag: string, attrs: Record<string, unknown>) => unknown): (props: unknown) => unknown {
|
|
1222
|
+
const spec = shape as { tag?: unknown; attrs?: unknown; from_props?: unknown };
|
|
1223
|
+
if (spec === null || typeof spec !== "object" || typeof spec.tag !== "string" || spec.tag.length === 0) throw new Error("row " + slug + " is malformed: stub " + name + " element is {tag, attrs?, from_props?} — a component stub draws ONE declared element, and a stub with no tag is a shape no row declared");
|
|
1224
|
+
const attrs = (spec.attrs ?? {}) as Record<string, string>;
|
|
1225
|
+
const fromProps = (spec.from_props ?? {}) as Record<string, string>;
|
|
1226
|
+
for (const [attr, path] of Object.entries(fromProps)) {
|
|
1227
|
+
if (typeof attr !== "string" || attr.length === 0 || typeof path !== "string" || path.length === 0) throw new Error("row " + slug + " is malformed: stub " + name + " element.from_props maps an attribute name to a dot path into the props it is handed, got " + JSON.stringify({ attr, path }));
|
|
1228
|
+
}
|
|
1229
|
+
return (props: unknown) => {
|
|
1230
|
+
const merged: Record<string, unknown> = { ...attrs };
|
|
1231
|
+
for (const [attr, path] of Object.entries(fromProps)) {
|
|
1232
|
+
const value = propsAt(props, path);
|
|
1233
|
+
if (value !== undefined) merged[attr] = JSON.stringify(value);
|
|
1234
|
+
}
|
|
1235
|
+
return create(spec.tag as string, merged);
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function withStubs<T>(row: Vector, fn: () => Promise<T>): Promise<T> {
|
|
1240
|
+
const stubs = (row.fixtures?.stubs ?? []) as Stub[];
|
|
1241
|
+
if (stubs.length === 0) {
|
|
1242
|
+
const result = await fn();
|
|
1243
|
+
returnOutcome(row.slug, ACTIVE_RECORDERS, row.outcomes as Record<string, Vector[]> | undefined);
|
|
1244
|
+
return result;
|
|
1245
|
+
}
|
|
1246
|
+
const slug = row.slug;
|
|
1247
|
+
for (const stub of stubs) {
|
|
1248
|
+
if (stub.module === undefined && stub.global === undefined) throw new Error("row " + slug + " is malformed: a fixture stub names neither module nor global — a stub answers a module export or the bare global");
|
|
1249
|
+
if (stub.record !== undefined) declareRecorder(String(stub.record));
|
|
1250
|
+
if (stub.value !== undefined && stub.global === undefined) throw new Error("row " + slug + " is malformed: a fixture stub declares value without global — value INSTALLS an object over a bare global; on a module export the thing to state is the value that export returns");
|
|
1251
|
+
if (stub.element !== undefined && (stub.module === undefined || stub.export === undefined)) throw new Error("row " + slug + " is malformed: a fixture stub declares element without module and export — an element stub draws a component another module exports, and names nothing without those two");
|
|
1252
|
+
}
|
|
1253
|
+
const moduleStubs = stubs.filter((stub) => stub.module !== undefined);
|
|
1254
|
+
const globalStubs = stubs.filter((stub) => stub.global !== undefined);
|
|
1255
|
+
const paths = [...new Set(moduleStubs.map((stub) => stubPath(row, stub)))];
|
|
1256
|
+
const needsElement = moduleStubs.some((stub) => stub.element !== undefined);
|
|
1257
|
+
const createElement = needsElement ? await reactCreateElement() : undefined;
|
|
1258
|
+
for (const path of paths) {
|
|
1259
|
+
const group = moduleStubs.filter((stub) => stubPath(row, stub) === path);
|
|
1260
|
+
const exported: Record<string, unknown> = {};
|
|
1261
|
+
for (const name of new Set(group.map((stub) => String(stub.export)))) {
|
|
1262
|
+
const arms = group.filter((stub) => String(stub.export) === name);
|
|
1263
|
+
const elementArm = arms.find((stub) => stub.element !== undefined);
|
|
1264
|
+
if (elementArm !== undefined) {
|
|
1265
|
+
if (arms.length > 1) throw new Error("row " + slug + " is malformed: stub " + name + " declares both element and " + (arms.length - 1) + " answering arm(s) — an element stub draws a component, and an answering arm returns data; one export is one or the other");
|
|
1266
|
+
exported[name] = buildElement(slug, name, elementArm.element, createElement as (tag: string, attrs: Record<string, unknown>) => unknown);
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
exported[name] = (...args: unknown[]) => {
|
|
1270
|
+
const call: Call = { args };
|
|
1271
|
+
for (const recorder of new Set(arms.map((arm) => arm.record))) {
|
|
1272
|
+
if (recorder !== undefined) (ACTIVE_RECORDERS[recorder] ??= []).push(call);
|
|
1273
|
+
}
|
|
1274
|
+
return decodeStubReturn(stubReturn(slug, arms, call));
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
mock.module(path, () => exported);
|
|
1278
|
+
}
|
|
1279
|
+
const target = globalThis as Record<string, unknown>;
|
|
1280
|
+
const savedGlobals: [string, unknown][] = [];
|
|
1281
|
+
for (const name of new Set(globalStubs.map((stub) => String(stub.global)))) {
|
|
1282
|
+
const arms = globalStubs.filter((stub) => String(stub.global) === name);
|
|
1283
|
+
savedGlobals.push([name, target[name]]);
|
|
1284
|
+
const valueArm = arms.find((stub) => stub.value !== undefined);
|
|
1285
|
+
if (valueArm !== undefined) {
|
|
1286
|
+
if (arms.length > 1) throw new Error("row " + slug + " is malformed: stub " + name + " declares both value and " + (arms.length - 1) + " answering arm(s) — an object install replaces the global, an answering arm stands in for it being called; one global is one or the other");
|
|
1287
|
+
target[name] = valueArm.value;
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
target[name] = (...callArgs: unknown[]) => {
|
|
1291
|
+
const call: Call = { args: callArgs };
|
|
1292
|
+
for (const recorder of new Set(arms.map((arm) => arm.record))) {
|
|
1293
|
+
if (recorder !== undefined) (ACTIVE_RECORDERS[recorder] ??= []).push(call);
|
|
1294
|
+
}
|
|
1295
|
+
return decodeStubReturn(stubReturn(slug, arms, call));
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
const result = await fn();
|
|
1300
|
+
returnOutcome(slug, ACTIVE_RECORDERS, row.outcomes as Record<string, Vector[]> | undefined);
|
|
1301
|
+
return result;
|
|
1302
|
+
} finally {
|
|
1303
|
+
for (const [name, original] of savedGlobals) target[name] = original;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function hasFixtures(row: Vector): boolean {
|
|
1308
|
+
return Array.isArray(row.fixtures?.stubs) && (row.fixtures!.stubs as Stub[]).length > 0;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function hasHoist(row: Vector): boolean {
|
|
1312
|
+
return Array.isArray(row.hoist) && (row.hoist as unknown[]).length > 0;
|
|
1313
|
+
}
|
|
1314
|
+
function hasThen(row: Vector): boolean {
|
|
1315
|
+
return Array.isArray(row.then) && (row.then as unknown[]).length > 0;
|
|
1316
|
+
}
|
|
1317
|
+
function hasCallEach(row: Vector): boolean {
|
|
1318
|
+
return Array.isArray(row.then_call_each) && (row.then_call_each as unknown[]).length > 0;
|
|
1319
|
+
}
|
|
1320
|
+
interface Recorded { field: string; method: string; args: unknown[] }
|
|
1321
|
+
function recordingAccessor(sink: Recorded[]): unknown {
|
|
1322
|
+
return new Proxy({}, {
|
|
1323
|
+
get(_target, field) {
|
|
1324
|
+
return new Proxy({}, {
|
|
1325
|
+
get(_t2, method) {
|
|
1326
|
+
return (...args: unknown[]) => {
|
|
1327
|
+
const recorded: Recorded = { field: String(field), method: String(method), args };
|
|
1328
|
+
(recorded as Recorded & { not: () => unknown }).not = () => recorded;
|
|
1329
|
+
sink.push(recorded);
|
|
1330
|
+
return recorded;
|
|
1331
|
+
};
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
},
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
async function callReturned(slug: string, returned: unknown, spec: Vector): Promise<void> {
|
|
1338
|
+
if (typeof returned !== "function") throw new Error("row " + slug + " declares then_return but the call returned " + (returned === null ? "null" : typeof returned) + ", not a callable to invoke — a factory is the only subject this arm reaches");
|
|
1339
|
+
const args = await Promise.all(((spec.args ?? []) as unknown[]).map((a) => decodeValue(a)));
|
|
1340
|
+
try {
|
|
1341
|
+
const answer = await (returned as (...a: unknown[]) => unknown)(...args);
|
|
1342
|
+
if (spec.expect_error !== undefined) throw new Error("row " + slug + " declares expect_error but the returned callable RESOLVED with " + JSON.stringify(answer) + " — an error row must be answered by a rejection");
|
|
1343
|
+
expect(answer).toEqual(spec.expect);
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
if (spec.expect_error === undefined) throw err;
|
|
1346
|
+
if (!String((err as Error).message).includes(String(spec.expect_error))) throw new Error("row " + slug + " then_return rejected with " + JSON.stringify(String((err as Error).message)) + ", which does not carry " + JSON.stringify(String(spec.expect_error)));
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
async function callEach(slug: string, returned: unknown, want: unknown): Promise<void> {
|
|
1350
|
+
if (!Array.isArray(returned)) throw new Error("row " + slug + " declares then_call_each but the call returned " + (returned === null ? "null" : typeof returned) + ", not a list of predicates to call");
|
|
1351
|
+
const declared = want as Recorded[];
|
|
1352
|
+
const recorded: Recorded[] = [];
|
|
1353
|
+
const accessor = recordingAccessor(recorded);
|
|
1354
|
+
for (const [index, predicate] of (returned as unknown[]).entries()) {
|
|
1355
|
+
if (typeof predicate !== "function") throw new Error("row " + slug + " then_call_each element " + index + " is " + (predicate === null ? "null" : typeof predicate) + " — a list element nobody can call is not a predicate");
|
|
1356
|
+
await (predicate as (acc: unknown) => unknown)(accessor);
|
|
1357
|
+
}
|
|
1358
|
+
const shape = (calls: Recorded[]): unknown => calls.map((call) => ({ field: call.field, method: call.method, args: call.args }));
|
|
1359
|
+
if (JSON.stringify(shape(recorded)) !== JSON.stringify(declared.map((call) => ({ field: String((call as Recorded).field), method: String((call as Recorded).method), args: (call as Recorded).args })))) {
|
|
1360
|
+
throw new Error("row " + slug + " then_call_each did not hold:\n the subject emitted " + JSON.stringify(shape(recorded)) + "\n the row declared " + JSON.stringify(declared));
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function runIsolated(row: Vector): void {
|
|
1365
|
+
const child = Bun.spawnSync(["bun", "test", SELF, "--test-name-pattern", row.slug], {
|
|
1366
|
+
cwd: ROOT,
|
|
1367
|
+
env: { ...process.env, SCALA_CONTRACT_ROW: row.slug },
|
|
1368
|
+
stdout: "pipe",
|
|
1369
|
+
stderr: "pipe",
|
|
1370
|
+
});
|
|
1371
|
+
if (child.exitCode !== 0) {
|
|
1372
|
+
const evidence = (child.stderr.toString() || child.stdout.toString()).trimEnd().split("\n").slice(-14).join("\n");
|
|
1373
|
+
throw new Error(`row ${row.slug} failed in its isolated run:\n${evidence}`);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function returnOutcome(slug: string, recorders: Record<string, Call[]>, outcomes?: Record<string, Vector[]>): void {
|
|
1378
|
+
if (outcomes === undefined) return;
|
|
1379
|
+
for (const [recorder, predicates] of Object.entries(outcomes)) {
|
|
1380
|
+
const calls = recorders[recorder] ?? [];
|
|
1381
|
+
for (const predicate of predicates) {
|
|
1382
|
+
if (predicate.nth !== undefined) {
|
|
1383
|
+
for (const key of Object.keys(predicate)) {
|
|
1384
|
+
if (key !== "nth" && key !== "member_equals") throw new Error(`row ${slug} is malformed: outcome ${recorder}.${key} beside nth — the recorded-call predicate is {nth, member_equals} and nothing else`);
|
|
1385
|
+
}
|
|
1386
|
+
const at = calls[Number(predicate.nth)];
|
|
1387
|
+
if (at === undefined) throw new Error(`row ${slug} outcome ${recorder} asserts call #${predicate.nth} and only ${calls.length} were recorded`);
|
|
1388
|
+
if (predicate.member_equals !== undefined) {
|
|
1389
|
+
for (const [path, want] of Object.entries(predicate.member_equals as Record<string, unknown>)) {
|
|
1390
|
+
expect(memberAt({ args: at.args }, path)).toEqual(want);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
if (predicate.member_equals !== undefined) throw new Error(`row ${slug} is malformed: outcome ${recorder}.member_equals without nth — member_equals on an outcome asserts a recorded CALL; spell {nth, member_equals}`);
|
|
1396
|
+
for (const verb of Object.keys(predicate)) {
|
|
1397
|
+
if (!(OUTCOME_VERBS as readonly string[]).includes(verb)) {
|
|
1398
|
+
throw new Error(
|
|
1399
|
+
`row ${slug} is malformed: outcome ${recorder}.${verb} is not a declared verb ` +
|
|
1400
|
+
JSON.stringify(OUTCOME_VERBS) + " — a predicate this runner does not understand is a case it stopped asserting");
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
const say: (verb: string) => never = (verb) => {
|
|
1404
|
+
throw new Error(`row ${slug} outcome ${recorder}.${verb} did not hold`);
|
|
1405
|
+
};
|
|
1406
|
+
if (predicate.count !== undefined && calls.length !== Number(predicate.count)) say("count");
|
|
1407
|
+
if (predicate.sql_absent !== undefined) {
|
|
1408
|
+
for (const call of calls) {
|
|
1409
|
+
if (typeof call.args[0] === "string" && (call.args[0] as string).includes(String(predicate.sql_absent))) say("sql_absent");
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
const text = JSON.stringify(calls);
|
|
1413
|
+
if (predicate.contains !== undefined && !text.includes(String(predicate.contains))) say("contains");
|
|
1414
|
+
if (predicate.absent !== undefined && text.includes(String(predicate.absent))) say("absent");
|
|
1415
|
+
if (predicate.equals !== undefined && text !== JSON.stringify(predicate.equals)) say("equals");
|
|
1416
|
+
if (predicate.sql_contains !== undefined && !calls.some((call) => typeof call.args[0] === "string" && (call.args[0] as string).includes(String(predicate.sql_contains)))) say("sql_contains");
|
|
1417
|
+
if (predicate.binds !== undefined) {
|
|
1418
|
+
const selected = predicate.sql_contains === undefined
|
|
1419
|
+
? calls[0]
|
|
1420
|
+
: calls.find((call) => typeof call.args[0] === "string" && (call.args[0] as string).includes(String(predicate.sql_contains)));
|
|
1421
|
+
if (selected === undefined) say("binds");
|
|
1422
|
+
const templateStringsAt = selected.args.findIndex((arg) => Array.isArray(arg) && "raw" in (arg as object));
|
|
1423
|
+
const binds = templateStringsAt >= 0 ? selected.args.slice(templateStringsAt + 1) : selected.args.find((arg) => Array.isArray(arg));
|
|
1424
|
+
if (JSON.stringify(binds) !== JSON.stringify(predicate.binds)) say("binds");
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
function runRow(row: Vector): Promise<void> | void {
|
|
1430
|
+
assertWellFormed(row);
|
|
1431
|
+
ACTIVE_RECORDERS = {};
|
|
1432
|
+
if (SELECTED === undefined && (hasFixtures(row) || hasHoist(row))) return runIsolated(row);
|
|
1433
|
+
if (isRenderRow(row.expect, row.args as unknown[] | undefined)) return renderRow(row);
|
|
1434
|
+
if (isHook(row.expect)) return hookRow(row);
|
|
1435
|
+
if (row.suite_type === "fs_flow") return runFsFlow(row);
|
|
1436
|
+
if (row.template !== undefined && row.template !== null) {
|
|
1437
|
+
if (row.args !== undefined && row.args !== null) throw new Error("row " + row.slug + " declares both args and template — a tagged-template subject takes its statement as template, and two ways to say the same call is a row whose intent is unreadable");
|
|
1438
|
+
return expectCall(row, row.export, decodeTemplate(row.slug, row.template as Record<string, unknown>));
|
|
1439
|
+
}
|
|
1440
|
+
return expectCall(row, row.export, row.args ?? []);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
describe("@teamscala/service-boot function_call contracts", () => {
|
|
1444
|
+
for (const row of ROWS) {
|
|
1445
|
+
if (SELECTED !== undefined && row.slug !== SELECTED) continue;
|
|
1446
|
+
it(row.slug, async () => {
|
|
1447
|
+
await runRow(row);
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
});
|