@systemfsoftware/xstate-test 0.0.0-bootstrap.0 → 2.0.0-beta.2
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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +1821 -2
- package/dist/adapter-Dd4Go0U3.d.ts +58 -0
- package/dist/effect-schema.d.ts +28 -0
- package/dist/effect-schema.mjs +40 -0
- package/dist/finalizerError-DOptAIDs.mjs +8 -0
- package/dist/index-CSbyUxJr.d.ts +2062 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.mjs +178 -0
- package/dist/playwright.d.ts +204 -0
- package/dist/playwright.mjs +287 -0
- package/dist/propertyTest-5bO0RsUu.mjs +4660 -0
- package/dist/propertyTest-BHnBV8ax.d.ts +147 -0
- package/dist/schema.d.ts +59 -0
- package/dist/schema.mjs +822 -0
- package/dist/vitest.d.ts +74 -0
- package/dist/vitest.mjs +139 -0
- package/package.json +86 -5
|
@@ -0,0 +1,4660 @@
|
|
|
1
|
+
import { t as ensuringFinalizerWins } from "./finalizerError-DOptAIDs.mjs";
|
|
2
|
+
import { eventsFromSchemas, isTypeOnlySchema } from "./schema.mjs";
|
|
3
|
+
import { dual } from "effect/Function";
|
|
4
|
+
import { SimulatedClock, createActor, createAsyncLogic, getInitialMicrosteps, getMicrosteps, initialTransition, transition } from "@systemfsoftware/xstate";
|
|
5
|
+
import { getDescendantStateNodes, getPathsFromEvents, getShortestPaths, getSimplePaths, serializeSnapshot } from "@systemfsoftware/xstate/graph";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Ref from "effect/Ref";
|
|
8
|
+
import * as Cause from "effect/Cause";
|
|
9
|
+
import { squash } from "effect/Cause";
|
|
10
|
+
import * as Data from "effect/Data";
|
|
11
|
+
import * as DateTime from "effect/DateTime";
|
|
12
|
+
import * as Exit from "effect/Exit";
|
|
13
|
+
import * as Logger from "effect/Logger";
|
|
14
|
+
import * as fc from "fast-check";
|
|
15
|
+
// src/engine/coverage.ts
|
|
16
|
+
function dimension() {
|
|
17
|
+
return {
|
|
18
|
+
counts: {},
|
|
19
|
+
declarations: /* @__PURE__ */ new Map()
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function declare(target, id, declaration = {}) {
|
|
23
|
+
const previous = target.declarations.get(id);
|
|
24
|
+
target.declarations.set(id, {
|
|
25
|
+
unreachable: (previous?.unreachable ?? false) || (declaration.unreachable ?? false),
|
|
26
|
+
unknown: (previous?.unknown ?? false) || (declaration.unknown ?? false)
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function declareAggregate(target, id, declaration) {
|
|
30
|
+
const previous = target.declarations.get(id);
|
|
31
|
+
target.declarations.set(id, {
|
|
32
|
+
unreachable: previous !== void 0 ? previous.unreachable && declaration.unreachable : declaration.unreachable,
|
|
33
|
+
unknown: previous !== void 0 ? previous.unknown && declaration.unknown : declaration.unknown
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const incrementCoverage = dual(2, function incrementCoverage(target, id) {
|
|
37
|
+
target.counts[id] = (target.counts[id] ?? 0) + 1;
|
|
38
|
+
});
|
|
39
|
+
function getPropertyConfigurationId(snapshot) {
|
|
40
|
+
const nodes = snapshot.nodes ?? snapshot._nodes;
|
|
41
|
+
return JSON.stringify((nodes?.map((node) => node.id) ?? []).sort());
|
|
42
|
+
}
|
|
43
|
+
function stableSerialize(value) {
|
|
44
|
+
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
|
|
45
|
+
if (value !== void 0 && value !== null && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`).join(",")}}`;
|
|
46
|
+
return JSON.stringify(value) ?? String(value);
|
|
47
|
+
}
|
|
48
|
+
function getPropertyStateId(snapshot) {
|
|
49
|
+
return stableSerialize(snapshot.value ?? null);
|
|
50
|
+
}
|
|
51
|
+
function getPropertyTransitionId(coverage, transition) {
|
|
52
|
+
return coverage.transitionIds.get(transition) ?? JSON.stringify([
|
|
53
|
+
"transition",
|
|
54
|
+
transition.source.id,
|
|
55
|
+
transition.eventType || "@eventless",
|
|
56
|
+
"dynamic"
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolves the default target(s) of a history state node. History nodes are
|
|
61
|
+
* never part of an active configuration themselves; entering one enters these
|
|
62
|
+
* nodes instead, so anything reachable only through a history default target
|
|
63
|
+
* would otherwise be misreported as unreachable.
|
|
64
|
+
*/
|
|
65
|
+
function getHistoryDefaultTargets(node) {
|
|
66
|
+
const parent = node.parent;
|
|
67
|
+
if (parent === void 0) return [];
|
|
68
|
+
const configTarget = node.config.target;
|
|
69
|
+
const normalized = configTarget === void 0 || configTarget === "" ? void 0 : Array.isArray(configTarget) ? configTarget : [configTarget];
|
|
70
|
+
if (normalized === void 0) return parent.type === "parallel" ? [parent] : parent.initial?.target ?? [];
|
|
71
|
+
const targets = [];
|
|
72
|
+
for (const target of normalized) {
|
|
73
|
+
if (typeof target !== "string") {
|
|
74
|
+
targets.push(target);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
targets.push(resolveStateNodePath(parent, target));
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
return targets;
|
|
82
|
+
}
|
|
83
|
+
/** Resolves a `'#id'` or (escaped) dot-separated path relative to `node`. */
|
|
84
|
+
function resolveStateNodePath(node, path) {
|
|
85
|
+
if (path.startsWith("#")) try {
|
|
86
|
+
return node.machine.getStateNodeById(path);
|
|
87
|
+
} catch {}
|
|
88
|
+
const segments = [];
|
|
89
|
+
let segment = "";
|
|
90
|
+
for (let index = 0; index < path.length; index++) {
|
|
91
|
+
const char = path[index];
|
|
92
|
+
if (char === "\\") segment += path[++index] ?? "";
|
|
93
|
+
else if (char === ".") {
|
|
94
|
+
segments.push(segment);
|
|
95
|
+
segment = "";
|
|
96
|
+
} else segment += char;
|
|
97
|
+
}
|
|
98
|
+
segments.push(segment);
|
|
99
|
+
let current = node;
|
|
100
|
+
for (const key of segments) {
|
|
101
|
+
if (key.length === 0) break;
|
|
102
|
+
const child = current.states[key];
|
|
103
|
+
if (child === void 0) throw new Error(`Child state '${key}' does not exist on '${current.id}'`);
|
|
104
|
+
current = child;
|
|
105
|
+
}
|
|
106
|
+
return current;
|
|
107
|
+
}
|
|
108
|
+
function collectReachableNodes(root) {
|
|
109
|
+
const reachable = /* @__PURE__ */ new Set([root.id]);
|
|
110
|
+
const queue = [root];
|
|
111
|
+
while (queue.length !== 0) {
|
|
112
|
+
const node = queue.shift();
|
|
113
|
+
const candidates = [
|
|
114
|
+
...node.initial?.target ?? [],
|
|
115
|
+
...node.type === "parallel" ? Object.values(node.states) : [],
|
|
116
|
+
...node.type === "history" ? getHistoryDefaultTargets(node) : [],
|
|
117
|
+
...[...node.transitions.values()].flatMap((definitions) => definitions.flatMap((definition) => definition.target ?? [])),
|
|
118
|
+
...(node.always ?? []).flatMap((definition) => definition.target ?? [])
|
|
119
|
+
];
|
|
120
|
+
for (const target of candidates) if (!reachable.has(target.id)) {
|
|
121
|
+
reachable.add(target.id);
|
|
122
|
+
queue.push(target);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return reachable;
|
|
126
|
+
}
|
|
127
|
+
function registerTransition(coverage, transition, index, reachable, reachabilityUnknown) {
|
|
128
|
+
const id = JSON.stringify([
|
|
129
|
+
"transition",
|
|
130
|
+
transition.source.id,
|
|
131
|
+
transition.eventType || "@eventless",
|
|
132
|
+
index
|
|
133
|
+
]);
|
|
134
|
+
const sourceUnreachable = !reachable.has(transition.source.id);
|
|
135
|
+
coverage.transitionIds.set(transition, id);
|
|
136
|
+
declare(coverage.transitions, id, {
|
|
137
|
+
unreachable: sourceUnreachable && !reachabilityUnknown,
|
|
138
|
+
unknown: sourceUnreachable && reachabilityUnknown
|
|
139
|
+
});
|
|
140
|
+
declareAggregate(coverage.eventTypes, transition.eventType || "@eventless", {
|
|
141
|
+
unreachable: sourceUnreachable && !reachabilityUnknown,
|
|
142
|
+
unknown: sourceUnreachable && reachabilityUnknown
|
|
143
|
+
});
|
|
144
|
+
return id;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Upper bound on statically enumerated transition pairs. Pair universes grow
|
|
148
|
+
* quadratically, so large machines report a truncated universe rather than
|
|
149
|
+
* spending unbounded time and memory on it.
|
|
150
|
+
*/
|
|
151
|
+
const TRANSITION_PAIR_UNIVERSE_LIMIT = 2e3;
|
|
152
|
+
/**
|
|
153
|
+
* Machines with more transitions than this skip pair enumeration entirely,
|
|
154
|
+
* since even the static pair universe can grow as O(T^2).
|
|
155
|
+
*/
|
|
156
|
+
const TRANSITION_PAIR_MACHINE_LIMIT = 500;
|
|
157
|
+
function getPropertyTransitionPairId(first, second) {
|
|
158
|
+
return `${first} -> ${second}`;
|
|
159
|
+
}
|
|
160
|
+
function getDescendantIds(node) {
|
|
161
|
+
const ids = /* @__PURE__ */ new Set([node.id]);
|
|
162
|
+
for (const descendant of getDescendantStateNodes(node)) ids.add(descendant.id);
|
|
163
|
+
return ids;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Declares the pairs of transitions that can run back to back. A pair
|
|
167
|
+
* `(t1, t2)` is possible when `t2`'s source node is within the configuration
|
|
168
|
+
* `t1` can leave behind, approximated as the descendants-or-self of `t1`'s
|
|
169
|
+
* targets (or of its own source when `t1` is targetless).
|
|
170
|
+
*
|
|
171
|
+
* Only pairs of static transitions are declared. A dynamic transition's target
|
|
172
|
+
* is unknown until it runs, so pairs involving one are not part of the
|
|
173
|
+
* universe; when observed at runtime they are still reported as covered.
|
|
174
|
+
*/
|
|
175
|
+
function declareTransitionPairs(coverage, registered) {
|
|
176
|
+
if (registered.length > TRANSITION_PAIR_MACHINE_LIMIT) {
|
|
177
|
+
coverage.transitionPairsTruncated = true;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const staticTransitions = registered.filter((entry) => entry.transition.to === void 0);
|
|
181
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
182
|
+
for (const entry of staticTransitions) {
|
|
183
|
+
const sourceId = entry.transition.source.id;
|
|
184
|
+
let entries = bySource.get(sourceId);
|
|
185
|
+
if (entries === void 0) {
|
|
186
|
+
entries = [];
|
|
187
|
+
bySource.set(sourceId, entries);
|
|
188
|
+
}
|
|
189
|
+
entries.push(entry);
|
|
190
|
+
}
|
|
191
|
+
const descendants = /* @__PURE__ */ new Map();
|
|
192
|
+
let declared = 0;
|
|
193
|
+
for (const first of staticTransitions) {
|
|
194
|
+
const roots = first.transition.target !== void 0 && first.transition.target.length !== 0 ? first.transition.target : [first.transition.source];
|
|
195
|
+
const firstDeclaration = coverage.transitions.declarations.get(first.id);
|
|
196
|
+
const reachableSources = /* @__PURE__ */ new Set();
|
|
197
|
+
for (const root of roots) {
|
|
198
|
+
let ids = descendants.get(root.id);
|
|
199
|
+
if (ids === void 0) {
|
|
200
|
+
ids = getDescendantIds(root);
|
|
201
|
+
descendants.set(root.id, ids);
|
|
202
|
+
}
|
|
203
|
+
for (const id of ids) reachableSources.add(id);
|
|
204
|
+
}
|
|
205
|
+
for (const sourceId of reachableSources) for (const second of bySource.get(sourceId) ?? []) {
|
|
206
|
+
if (declared >= TRANSITION_PAIR_UNIVERSE_LIMIT) {
|
|
207
|
+
coverage.transitionPairsTruncated = true;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
declared++;
|
|
211
|
+
const secondDeclaration = coverage.transitions.declarations.get(second.id);
|
|
212
|
+
declare(coverage.transitionPairs, getPropertyTransitionPairId(first.id, second.id), {
|
|
213
|
+
unreachable: firstDeclaration?.unreachable === true || secondDeclaration?.unreachable === true,
|
|
214
|
+
unknown: firstDeclaration?.unknown === true || secondDeclaration?.unknown === true
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function normalizeRequirements(meta) {
|
|
220
|
+
const requirements = meta?.requirements;
|
|
221
|
+
if (typeof requirements === "string") return [requirements];
|
|
222
|
+
if (Array.isArray(requirements)) return requirements.filter((requirement) => typeof requirement === "string");
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
function declareRequirements(coverage, requirements, source, owner, ownerId) {
|
|
226
|
+
if (requirements.length === 0) return;
|
|
227
|
+
owner.set(ownerId, [...owner.get(ownerId) ?? [], ...requirements]);
|
|
228
|
+
for (const requirement of requirements) {
|
|
229
|
+
declare(coverage.requirements, requirement);
|
|
230
|
+
const sources = coverage.requirementSources[requirement] ??= [];
|
|
231
|
+
if (!sources.includes(source)) sources.push(source);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function recordRequirements(coverage, requirements) {
|
|
235
|
+
for (const requirement of requirements ?? []) incrementCoverage(coverage.requirements, requirement);
|
|
236
|
+
}
|
|
237
|
+
function createTestCoverage(logic) {
|
|
238
|
+
const coverage = {
|
|
239
|
+
runs: 0,
|
|
240
|
+
steps: 0,
|
|
241
|
+
skipped: 0,
|
|
242
|
+
prefixSteps: 0,
|
|
243
|
+
generatedSteps: 0,
|
|
244
|
+
invariantChecks: 0,
|
|
245
|
+
temporalChecks: 0,
|
|
246
|
+
clockAdvances: 0,
|
|
247
|
+
checkpoints: 0,
|
|
248
|
+
stops: 0,
|
|
249
|
+
sutComparisons: 0,
|
|
250
|
+
oracleComparisons: 0,
|
|
251
|
+
states: dimension(),
|
|
252
|
+
stateNodes: dimension(),
|
|
253
|
+
configurations: dimension(),
|
|
254
|
+
statuses: dimension(),
|
|
255
|
+
eventTypes: dimension(),
|
|
256
|
+
eventCases: {},
|
|
257
|
+
transitions: dimension(),
|
|
258
|
+
transitionPairs: dimension(),
|
|
259
|
+
transitionPairsTruncated: false,
|
|
260
|
+
requirements: dimension(),
|
|
261
|
+
requirementSources: {},
|
|
262
|
+
requirementsByStateNode: /* @__PURE__ */ new Map(),
|
|
263
|
+
requirementsByTransition: /* @__PURE__ */ new Map(),
|
|
264
|
+
previousTransitionIds: null,
|
|
265
|
+
frontiers: dimension(),
|
|
266
|
+
labels: {},
|
|
267
|
+
temporal: {
|
|
268
|
+
counts: {},
|
|
269
|
+
campaignFailed: /* @__PURE__ */ new Set(),
|
|
270
|
+
warnings: []
|
|
271
|
+
},
|
|
272
|
+
shrinkRuns: 0,
|
|
273
|
+
transitionIds: /* @__PURE__ */ new WeakMap(),
|
|
274
|
+
maximumObservedSequenceLength: 0,
|
|
275
|
+
pendingActorSteps: 0
|
|
276
|
+
};
|
|
277
|
+
const machine = logic;
|
|
278
|
+
if (machine.root === void 0) {
|
|
279
|
+
for (const target of [
|
|
280
|
+
coverage.states,
|
|
281
|
+
coverage.stateNodes,
|
|
282
|
+
coverage.configurations,
|
|
283
|
+
coverage.eventTypes,
|
|
284
|
+
coverage.transitions,
|
|
285
|
+
coverage.transitionPairs
|
|
286
|
+
]) declare(target, "(not statically enumerable)", { unknown: true });
|
|
287
|
+
return coverage;
|
|
288
|
+
}
|
|
289
|
+
const nodes = [machine.root, ...getDescendantStateNodes(machine.root)];
|
|
290
|
+
const reachable = collectReachableNodes(machine.root);
|
|
291
|
+
const hasReachableDynamicTransition = nodes.some((node) => reachable.has(node.id) && ([...node.transitions.values()].some((definitions) => definitions.some((definition) => definition.to !== void 0)) || (node.always ?? []).some((definition) => definition.to !== void 0)));
|
|
292
|
+
const registered = [];
|
|
293
|
+
for (const node of nodes) {
|
|
294
|
+
declareRequirements(coverage, normalizeRequirements(node.meta), `stateNode:${node.id}`, coverage.requirementsByStateNode, node.id);
|
|
295
|
+
declare(coverage.stateNodes, node.id, {
|
|
296
|
+
unreachable: !reachable.has(node.id) && !hasReachableDynamicTransition,
|
|
297
|
+
unknown: !reachable.has(node.id) && hasReachableDynamicTransition
|
|
298
|
+
});
|
|
299
|
+
for (const definitions of node.transitions.values()) for (let index = 0; index < definitions.length; index++) {
|
|
300
|
+
const definition = definitions[index];
|
|
301
|
+
if (definition === void 0) continue;
|
|
302
|
+
registered.push({
|
|
303
|
+
id: registerTransition(coverage, definition, index, reachable, !reachable.has(node.id) && hasReachableDynamicTransition),
|
|
304
|
+
transition: definition
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const always = node.always ?? [];
|
|
308
|
+
for (let index = 0; index < always.length; index++) {
|
|
309
|
+
const definition = always[index];
|
|
310
|
+
if (definition === void 0) continue;
|
|
311
|
+
registered.push({
|
|
312
|
+
id: registerTransition(coverage, definition, index, reachable, !reachable.has(node.id) && hasReachableDynamicTransition),
|
|
313
|
+
transition: definition
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
for (const entry of registered) declareRequirements(coverage, normalizeRequirements(entry.transition.meta), `transition:${entry.id}`, coverage.requirementsByTransition, entry.id);
|
|
318
|
+
declareTransitionPairs(coverage, registered);
|
|
319
|
+
declare(coverage.states, "(runtime serialized states)", { unknown: true });
|
|
320
|
+
declare(coverage.configurations, "(runtime configurations)", { unknown: true });
|
|
321
|
+
return coverage;
|
|
322
|
+
}
|
|
323
|
+
const recordPropertySnapshot = dual(2, function(coverage, snapshot) {
|
|
324
|
+
incrementCoverage(coverage.states, getPropertyStateId(snapshot));
|
|
325
|
+
incrementCoverage(coverage.configurations, getPropertyConfigurationId(snapshot));
|
|
326
|
+
incrementCoverage(coverage.statuses, snapshot.status);
|
|
327
|
+
const nodes = snapshot.nodes ?? snapshot._nodes;
|
|
328
|
+
for (const node of nodes ?? []) {
|
|
329
|
+
incrementCoverage(coverage.stateNodes, node.id);
|
|
330
|
+
recordRequirements(coverage, coverage.requirementsByStateNode.get(node.id));
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
const recordPropertyTransitions = dual(3, function(coverage, event, transitions) {
|
|
334
|
+
incrementCoverage(coverage.eventTypes, event.type);
|
|
335
|
+
const ids = [];
|
|
336
|
+
for (const selected of transitions) {
|
|
337
|
+
const id = getPropertyTransitionId(coverage, selected);
|
|
338
|
+
ids.push(id);
|
|
339
|
+
incrementCoverage(coverage.transitions, id);
|
|
340
|
+
recordRequirements(coverage, coverage.requirementsByTransition.get(id));
|
|
341
|
+
}
|
|
342
|
+
if (ids.length !== 0) {
|
|
343
|
+
for (const previous of coverage.previousTransitionIds ?? []) for (const current of ids) incrementCoverage(coverage.transitionPairs, getPropertyTransitionPairId(previous, current));
|
|
344
|
+
coverage.previousTransitionIds = ids;
|
|
345
|
+
}
|
|
346
|
+
return ids;
|
|
347
|
+
});
|
|
348
|
+
/**
|
|
349
|
+
* Clears the consecutive-transition chain so pairs are only counted within a
|
|
350
|
+
* single run.
|
|
351
|
+
*/
|
|
352
|
+
function resetPropertyTransitionPairs(coverage) {
|
|
353
|
+
coverage.previousTransitionIds = null;
|
|
354
|
+
}
|
|
355
|
+
const getPropertyEventCaseId = dual(2, function(eventType, caseName) {
|
|
356
|
+
return JSON.stringify([
|
|
357
|
+
"event-case",
|
|
358
|
+
eventType,
|
|
359
|
+
caseName
|
|
360
|
+
]);
|
|
361
|
+
});
|
|
362
|
+
/**
|
|
363
|
+
* Parses an id produced by {@link getPropertyEventCaseId} back into its event
|
|
364
|
+
* type and case name. Returns `undefined` for ids of any other shape.
|
|
365
|
+
*/
|
|
366
|
+
function parsePropertyEventCaseId(id) {
|
|
367
|
+
let parsed;
|
|
368
|
+
try {
|
|
369
|
+
parsed = JSON.parse(id);
|
|
370
|
+
} catch {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (!Array.isArray(parsed) || parsed.length !== 3 || parsed[0] !== "event-case" || typeof parsed[1] !== "string" || typeof parsed[2] !== "string") return;
|
|
374
|
+
return {
|
|
375
|
+
type: parsed[1],
|
|
376
|
+
name: parsed[2]
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const declarePropertyEventCase = dual(3, function(coverage, id, weight) {
|
|
380
|
+
const existing = coverage.eventCases[id];
|
|
381
|
+
if (existing === void 0) {
|
|
382
|
+
coverage.eventCases[id] = {
|
|
383
|
+
weight: weight ?? 1,
|
|
384
|
+
generated: 0,
|
|
385
|
+
applicable: 0,
|
|
386
|
+
executed: 0,
|
|
387
|
+
ignored: 0
|
|
388
|
+
};
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (weight !== void 0 && existing.weight !== weight) coverage.eventCases[id] = {
|
|
392
|
+
...existing,
|
|
393
|
+
weight
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
const recordPropertyEventCase = dual(3, function(coverage, id, stage) {
|
|
397
|
+
declarePropertyEventCase(coverage, id, void 0);
|
|
398
|
+
const counts = coverage.eventCases[id];
|
|
399
|
+
counts[stage]++;
|
|
400
|
+
});
|
|
401
|
+
function finalizeDimension(dimension) {
|
|
402
|
+
const covered = Object.keys(dimension.counts).sort();
|
|
403
|
+
const uncovered = [];
|
|
404
|
+
const unreachable = [];
|
|
405
|
+
const unknown = [];
|
|
406
|
+
for (const [id, declaration] of dimension.declarations) {
|
|
407
|
+
if (dimension.counts[id] !== void 0) continue;
|
|
408
|
+
if (declaration.unknown) unknown.push(id);
|
|
409
|
+
else if (declaration.unreachable) unreachable.push(id);
|
|
410
|
+
else uncovered.push(id);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
counts: { ...dimension.counts },
|
|
414
|
+
covered,
|
|
415
|
+
uncovered: uncovered.sort(),
|
|
416
|
+
unreachable: unreachable.sort(),
|
|
417
|
+
unknown: unknown.sort()
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const finalizeTestCoverage = dual((args) => args.length >= 2 || typeof args[0] === "object" && args[0] !== null && "eventCases" in args[0], function(coverage, exploration = {
|
|
421
|
+
strategy: "property",
|
|
422
|
+
mode: "pure",
|
|
423
|
+
configuredRuns: null,
|
|
424
|
+
completedRuns: coverage.runs,
|
|
425
|
+
attemptedRuns: coverage.runs,
|
|
426
|
+
shrinkRuns: coverage.shrinkRuns,
|
|
427
|
+
maximumSequenceLength: null,
|
|
428
|
+
maximumObservedSequenceLength: coverage.maximumObservedSequenceLength,
|
|
429
|
+
frontiers: [],
|
|
430
|
+
seeds: [],
|
|
431
|
+
swarm: null,
|
|
432
|
+
target: {
|
|
433
|
+
best: -Infinity,
|
|
434
|
+
improvements: 0
|
|
435
|
+
},
|
|
436
|
+
truncated: false,
|
|
437
|
+
truncationReasons: [],
|
|
438
|
+
stoppedBecause: "budget",
|
|
439
|
+
pendingActorSteps: coverage.pendingActorSteps
|
|
440
|
+
}) {
|
|
441
|
+
const labelRuns = (exploration.attemptedRuns || coverage.runs) - coverage.shrinkRuns;
|
|
442
|
+
const temporalCounts = coverage.temporal.counts;
|
|
443
|
+
const temporalIds = Object.keys(temporalCounts).sort();
|
|
444
|
+
const temporalCountsOf = (id) => {
|
|
445
|
+
const counts = temporalCounts[id];
|
|
446
|
+
if (counts === void 0) throw new TypeError(`Missing temporal counts for '${id}'`);
|
|
447
|
+
return counts;
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
runs: coverage.runs,
|
|
451
|
+
steps: coverage.steps,
|
|
452
|
+
skipped: coverage.skipped,
|
|
453
|
+
prefixSteps: coverage.prefixSteps,
|
|
454
|
+
generatedSteps: coverage.generatedSteps,
|
|
455
|
+
invariantChecks: coverage.invariantChecks,
|
|
456
|
+
temporalChecks: coverage.temporalChecks,
|
|
457
|
+
clockAdvances: coverage.clockAdvances,
|
|
458
|
+
checkpoints: coverage.checkpoints,
|
|
459
|
+
stops: coverage.stops,
|
|
460
|
+
sutComparisons: coverage.sutComparisons,
|
|
461
|
+
oracleComparisons: coverage.oracleComparisons,
|
|
462
|
+
states: finalizeDimension(coverage.states),
|
|
463
|
+
stateNodes: finalizeDimension(coverage.stateNodes),
|
|
464
|
+
configurations: finalizeDimension(coverage.configurations),
|
|
465
|
+
statuses: finalizeDimension(coverage.statuses),
|
|
466
|
+
eventTypes: finalizeDimension(coverage.eventTypes),
|
|
467
|
+
eventCases: Object.fromEntries(Object.entries(coverage.eventCases).sort(([left], [right]) => left.localeCompare(right)).map(([id, counts]) => [id, { ...counts }])),
|
|
468
|
+
transitions: finalizeDimension(coverage.transitions),
|
|
469
|
+
transitionPairs: {
|
|
470
|
+
...finalizeDimension(coverage.transitionPairs),
|
|
471
|
+
truncated: coverage.transitionPairsTruncated
|
|
472
|
+
},
|
|
473
|
+
requirements: {
|
|
474
|
+
...finalizeDimension(coverage.requirements),
|
|
475
|
+
sources: Object.fromEntries(Object.entries(coverage.requirementSources).sort(([left], [right]) => left.localeCompare(right)).map(([id, sources]) => [id, [...sources].sort()]))
|
|
476
|
+
},
|
|
477
|
+
frontiers: finalizeDimension(coverage.frontiers),
|
|
478
|
+
labels: Object.fromEntries(Object.entries(coverage.labels).sort(([left], [right]) => left.localeCompare(right)).map(([name, entry]) => [name, {
|
|
479
|
+
count: entry.count,
|
|
480
|
+
values: Object.fromEntries(Object.entries(entry.values).sort(([left], [right]) => left.localeCompare(right))),
|
|
481
|
+
share: labelRuns !== 0 ? Math.min(1, entry.runs / labelRuns) : 0
|
|
482
|
+
}])),
|
|
483
|
+
temporal: {
|
|
484
|
+
satisfied: temporalIds.filter((id) => temporalCountsOf(id).satisfied > 0),
|
|
485
|
+
failed: [.../* @__PURE__ */ new Set([...temporalIds.filter((id) => temporalCountsOf(id).failed > 0), ...coverage.temporal.campaignFailed])].sort(),
|
|
486
|
+
inconclusive: temporalIds.filter((id) => temporalCountsOf(id).inconclusive > 0 && temporalCountsOf(id).satisfied === 0 && !coverage.temporal.campaignFailed.has(id)),
|
|
487
|
+
counts: Object.fromEntries(temporalIds.map((id) => [id, { ...temporalCountsOf(id) }])),
|
|
488
|
+
warnings: coverage.temporal.warnings.slice()
|
|
489
|
+
},
|
|
490
|
+
exploration
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
/**
|
|
494
|
+
* Records one occurrence of a label. `seen` is the set of label names already
|
|
495
|
+
* recorded in the current run, so each run contributes at most once to a
|
|
496
|
+
* label's `share`.
|
|
497
|
+
*/
|
|
498
|
+
const recordPropertyLabel = dual(4, function(coverage, name, value, seen) {
|
|
499
|
+
const entry = coverage.labels[name] ??= {
|
|
500
|
+
count: 0,
|
|
501
|
+
values: {},
|
|
502
|
+
runs: 0
|
|
503
|
+
};
|
|
504
|
+
entry.count++;
|
|
505
|
+
if (value !== void 0) {
|
|
506
|
+
const key = String(value);
|
|
507
|
+
entry.values[key] = (entry.values[key] ?? 0) + 1;
|
|
508
|
+
}
|
|
509
|
+
if (!seen.has(name)) {
|
|
510
|
+
seen.add(name);
|
|
511
|
+
entry.runs++;
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
/** Records one run's outcome for a temporal property. */
|
|
515
|
+
const recordPropertyTemporal = dual(3, function(coverage, id, outcome) {
|
|
516
|
+
const counts = coverage.temporal.counts[id] ??= {
|
|
517
|
+
satisfied: 0,
|
|
518
|
+
failed: 0,
|
|
519
|
+
inconclusive: 0
|
|
520
|
+
};
|
|
521
|
+
counts[outcome]++;
|
|
522
|
+
});
|
|
523
|
+
const declarePropertyFrontier = dual(2, function(coverage, id) {
|
|
524
|
+
declare(coverage.frontiers, id);
|
|
525
|
+
});
|
|
526
|
+
function isStateMachine(logic) {
|
|
527
|
+
const machine = logic;
|
|
528
|
+
return machine.root !== void 0 && typeof machine.getStateNodeById === "function" && typeof machine.getTransitionData === "function";
|
|
529
|
+
}
|
|
530
|
+
function withMicrostepDetails(result, getSteps) {
|
|
531
|
+
let microsteps;
|
|
532
|
+
try {
|
|
533
|
+
microsteps = getSteps();
|
|
534
|
+
} catch {
|
|
535
|
+
microsteps = [];
|
|
536
|
+
}
|
|
537
|
+
return [
|
|
538
|
+
result[0],
|
|
539
|
+
result[1],
|
|
540
|
+
microsteps.flatMap(([, , transitions]) => transitions)
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* The pure `transition()`, also returning the transitions taken (from
|
|
545
|
+
* `getMicrosteps()`) for coverage attribution.
|
|
546
|
+
*/
|
|
547
|
+
const transitionWithDetails = dual(3, function(logic, snapshot, event) {
|
|
548
|
+
const result = transition(logic, snapshot, event);
|
|
549
|
+
if (!isStateMachine(logic) || result[0] === snapshot) return [
|
|
550
|
+
result[0],
|
|
551
|
+
result[1],
|
|
552
|
+
[]
|
|
553
|
+
];
|
|
554
|
+
return withMicrostepDetails(result, () => getMicrosteps(logic, snapshot, event));
|
|
555
|
+
});
|
|
556
|
+
/** The pure `initialTransition()`, with the details of {@link transitionWithDetails}. */
|
|
557
|
+
const initialTransitionWithDetails = dual(2, function(logic, input) {
|
|
558
|
+
const result = initialTransition(logic, input);
|
|
559
|
+
if (!isStateMachine(logic)) return [
|
|
560
|
+
result[0],
|
|
561
|
+
result[1],
|
|
562
|
+
[]
|
|
563
|
+
];
|
|
564
|
+
return withMicrostepDetails(result, () => getInitialMicrosteps(logic, input));
|
|
565
|
+
});
|
|
566
|
+
// src/engine/utils.ts
|
|
567
|
+
function simpleStringify(value) {
|
|
568
|
+
return JSON.stringify(value);
|
|
569
|
+
}
|
|
570
|
+
/** FNV-1a over a string, as an unsigned 32-bit integer. */
|
|
571
|
+
function fnv1a(value) {
|
|
572
|
+
let hash = 2166136261;
|
|
573
|
+
for (let index = 0; index < value.length; index++) {
|
|
574
|
+
hash ^= value.charCodeAt(index);
|
|
575
|
+
hash = Math.imul(hash, 16777619);
|
|
576
|
+
}
|
|
577
|
+
return hash >>> 0;
|
|
578
|
+
}
|
|
579
|
+
/** A deterministic 32-bit PRNG (mulberry32), seeded by `seed`. */
|
|
580
|
+
function createSeededRng(seed) {
|
|
581
|
+
let a = seed >>> 0;
|
|
582
|
+
return () => {
|
|
583
|
+
a = a + 1831565813 >>> 0;
|
|
584
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
585
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
586
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** Events synthesized from the transitions of the active state nodes. */
|
|
590
|
+
function getAllOwnEvents(snapshot) {
|
|
591
|
+
const events = snapshot.nodes.flatMap((stateNode) => [...stateNode.transitions.values()].flatMap((transitions) => transitions.map((transition) => {
|
|
592
|
+
const event = {
|
|
593
|
+
type: transition.eventType,
|
|
594
|
+
...transition.matches
|
|
595
|
+
};
|
|
596
|
+
if ("actorId" in event && (event.type === "xstate.done.actor" || event.type === "xstate.error.actor" || event.type === "xstate.snapshot.actor" || event.type === "xstate.timeout.actor")) event["sessionId"] = snapshot.children[event["actorId"]]?.sessionId;
|
|
597
|
+
return event;
|
|
598
|
+
})));
|
|
599
|
+
return events.filter((event, index) => events.findIndex((candidate) => {
|
|
600
|
+
const keys = Object.keys(event);
|
|
601
|
+
return keys.length === Object.keys(candidate).length && keys.every((key) => Object.is(event[key], candidate[key]));
|
|
602
|
+
}) === index);
|
|
603
|
+
}
|
|
604
|
+
// src/engine/eventDescriptors.ts
|
|
605
|
+
/**
|
|
606
|
+
* Distinguishes the descriptor form (`{ generate, case?, weight?, ... }`) from
|
|
607
|
+
* a bare generator. Generators are opaque adapter values that can themselves
|
|
608
|
+
* be objects with a `generate` *method* (fast-check arbitraries have one), so
|
|
609
|
+
* a `generate` key only marks a descriptor when it does not hold a function;
|
|
610
|
+
* any of the descriptor-only keys marks one regardless.
|
|
611
|
+
* @experimental
|
|
612
|
+
*/
|
|
613
|
+
function isEventDescriptorObject(value) {
|
|
614
|
+
if (value === null || typeof value !== "object") return false;
|
|
615
|
+
if ("case" in value || "when" in value || "resolve" in value || "weight" in value) return true;
|
|
616
|
+
return Object.prototype.hasOwnProperty.call(value, "generate") && typeof value.generate !== "function";
|
|
617
|
+
}
|
|
618
|
+
/** @experimental */
|
|
619
|
+
const assertTestWeight = dual(2, function assertTestWeight(weight, location) {
|
|
620
|
+
if (weight === void 0) return 1;
|
|
621
|
+
if (typeof weight !== "number" || !Number.isFinite(weight) || weight <= 0) throw new Error(`Property ${location} has an invalid \`weight\` (${String(weight)}). Weights must be positive, finite numbers.`);
|
|
622
|
+
return weight;
|
|
623
|
+
});
|
|
624
|
+
/**
|
|
625
|
+
* Normalizes the shared `events` option — a map of event type to a bare
|
|
626
|
+
* generator, a descriptor object, or an array of either — into the flat list of
|
|
627
|
+
* declared cases both `propertyTest()` and `testPaths()` work from.
|
|
628
|
+
* @experimental
|
|
629
|
+
*/
|
|
630
|
+
function normalizeEventDescriptors(events) {
|
|
631
|
+
const descriptors = /* @__PURE__ */ new Map();
|
|
632
|
+
const cases = [];
|
|
633
|
+
for (const [type, configured] of Object.entries(events ?? {})) {
|
|
634
|
+
const configuredCases = Array.isArray(configured) ? configured : [configured];
|
|
635
|
+
for (const eventCase of configuredCases) {
|
|
636
|
+
const descriptor = isEventDescriptorObject(eventCase) ? eventCase : { generate: eventCase };
|
|
637
|
+
const caseName = descriptor.case ?? "default";
|
|
638
|
+
if (caseName.length === 0) throw new Error(`Property event case for "${type}" must not be empty`);
|
|
639
|
+
const caseId = getPropertyEventCaseId(type, caseName);
|
|
640
|
+
if (descriptors.has(caseId)) throw new Error(`Property event case "${caseName}" is duplicated for "${type}"`);
|
|
641
|
+
descriptors.set(caseId, descriptor);
|
|
642
|
+
cases.push({
|
|
643
|
+
type,
|
|
644
|
+
caseName,
|
|
645
|
+
caseId,
|
|
646
|
+
generator: descriptor.generate,
|
|
647
|
+
weight: assertTestWeight(descriptor.weight, `event case "${caseName}" for "${type}"`),
|
|
648
|
+
descriptor
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
cases,
|
|
654
|
+
descriptors
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* The per-case seed used when sampling payloads. Deriving it from the case id
|
|
659
|
+
* keeps each case's samples stable when other cases are added or removed.
|
|
660
|
+
* @experimental
|
|
661
|
+
*/
|
|
662
|
+
const deriveCaseSeed = dual(2, function deriveCaseSeed(seed, caseId) {
|
|
663
|
+
return (seed ^ fnv1a(caseId)) >>> 0;
|
|
664
|
+
});
|
|
665
|
+
/**
|
|
666
|
+
* Draws `count` values from a {@link TestGenerator}.
|
|
667
|
+
*
|
|
668
|
+
* @experimental
|
|
669
|
+
*/
|
|
670
|
+
const sampleGenerator = dual(3, function sampleGenerator(generator, rng, count) {
|
|
671
|
+
const draw = typeof generator === "function" ? generator : typeof generator?.sample === "function" ? (rng2) => generator.sample(rng2) : void 0;
|
|
672
|
+
if (draw === void 0) throw new Error("Path generation requires each `generate` value to be a function `(rng) => value` or an object with a `sample(rng)` method. In `@xstate/test`, fast-check arbitraries are adapted automatically.");
|
|
673
|
+
const values = [];
|
|
674
|
+
for (let index = 0; index < count; index++) values.push(draw(rng));
|
|
675
|
+
return values;
|
|
676
|
+
});
|
|
677
|
+
/**
|
|
678
|
+
* Builds a {@link TestPickDescriptor} around any index generator. `pick()` in
|
|
679
|
+
* `xstate/graph` and in `@xstate/test` differ only in the generator.
|
|
680
|
+
* @experimental
|
|
681
|
+
*/
|
|
682
|
+
const createPickDescriptor = dual(3, function createPickDescriptor(generate, select, toPayload) {
|
|
683
|
+
return {
|
|
684
|
+
generate,
|
|
685
|
+
resolve: ({ snapshot, generated }) => {
|
|
686
|
+
const items = select(snapshot);
|
|
687
|
+
if (items.length === 0) return;
|
|
688
|
+
const item = items[Math.abs(Math.trunc(generated)) % items.length];
|
|
689
|
+
if (item === void 0) return;
|
|
690
|
+
return toPayload !== void 0 ? toPayload(item, snapshot) : item;
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
});
|
|
694
|
+
// src/engine/constants.ts
|
|
695
|
+
const XSTATE_INIT = "@xstate.init";
|
|
696
|
+
const XSTATE_STOP = "@xstate.stop";
|
|
697
|
+
// src/engine/outcomes.ts
|
|
698
|
+
/**
|
|
699
|
+
* Queues and hands out actor outcomes for stubbed invoke sources.
|
|
700
|
+
*
|
|
701
|
+
* A stub actor asks the registry for its outcome when it starts. If an
|
|
702
|
+
* outcome is already queued for its source it resolves immediately; otherwise
|
|
703
|
+
* the stub stays pending until an `outcome` command (or a seeded replay
|
|
704
|
+
* record) supplies one, which is what lets fast-check shrink service results.
|
|
705
|
+
*/
|
|
706
|
+
var PropertyOutcomeRegistry = class {
|
|
707
|
+
queued = /* @__PURE__ */ new Map();
|
|
708
|
+
waiting = /* @__PURE__ */ new Map();
|
|
709
|
+
/** Called by a stub actor when it starts. */
|
|
710
|
+
request(src) {
|
|
711
|
+
const next = this.queued.get(src)?.shift();
|
|
712
|
+
if (next !== void 0) return Promise.resolve(next);
|
|
713
|
+
return Effect.runPromise(Effect.callback((resume) => {
|
|
714
|
+
const resolve = (outcome) => {
|
|
715
|
+
resume(Effect.succeed(outcome));
|
|
716
|
+
};
|
|
717
|
+
const waiters = this.waiting.get(src);
|
|
718
|
+
if (waiters !== void 0) waiters.push(resolve);
|
|
719
|
+
else this.waiting.set(src, [resolve]);
|
|
720
|
+
}));
|
|
721
|
+
}
|
|
722
|
+
/** Resolves the oldest pending stub for `src`, or queues for the next one. */
|
|
723
|
+
provide(src, outcome) {
|
|
724
|
+
const waiter = this.waiting.get(src)?.shift();
|
|
725
|
+
if (waiter !== void 0) {
|
|
726
|
+
waiter(outcome);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const queue = this.queued.get(src);
|
|
730
|
+
if (queue !== void 0) queue.push(outcome);
|
|
731
|
+
else this.queued.set(src, [outcome]);
|
|
732
|
+
}
|
|
733
|
+
/** Pre-loads recorded outcomes so a replay never calls a real service. */
|
|
734
|
+
seed(records) {
|
|
735
|
+
for (const record of records) this.provide(record.src, record.outcome);
|
|
736
|
+
}
|
|
737
|
+
reset() {
|
|
738
|
+
this.queued = /* @__PURE__ */ new Map();
|
|
739
|
+
this.waiting = /* @__PURE__ */ new Map();
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
/**
|
|
743
|
+
* Builds the stub {@link ActorLogic} that replaces an invoke source named
|
|
744
|
+
* `src`, resolving its outcome from `registry` — the registry of the campaign
|
|
745
|
+
* that built the stub. One stub is built per campaign, so concurrent campaigns
|
|
746
|
+
* never resolve each other's outcomes.
|
|
747
|
+
*
|
|
748
|
+
* `registry` is `undefined` for stubs built only so a machine missing an
|
|
749
|
+
* implementation can be provided for pure-mode traversal, which steps logic
|
|
750
|
+
* without running it; a run that starts such a stub is a bug.
|
|
751
|
+
*/
|
|
752
|
+
const createOutcomeStub = dual(2, function createOutcomeStub(src, registry) {
|
|
753
|
+
return createAsyncLogic({ run: () => Effect.runPromise(Effect.gen(function* () {
|
|
754
|
+
if (registry === void 0) throw new Error(`Property outcome stub for "${src}" ran outside an executed-mode property run`);
|
|
755
|
+
const outcome = yield* Effect.promise(() => registry.request(src));
|
|
756
|
+
if (outcome.ok) return outcome.output;
|
|
757
|
+
throw outcome.error;
|
|
758
|
+
})) });
|
|
759
|
+
});
|
|
760
|
+
/** Applies `actors` to a machine, rejecting logic that cannot be provided. */
|
|
761
|
+
const provideActors = dual(2, function provideActors(logic, actors) {
|
|
762
|
+
const provide = logic.provide;
|
|
763
|
+
if (typeof provide !== "function") throw new Error("Property `actors` and `outcomes` require a state machine; the provided actor logic has no `provide()`");
|
|
764
|
+
return provide.call(logic, { actors });
|
|
765
|
+
});
|
|
766
|
+
// src/engine/report.ts
|
|
767
|
+
/**
|
|
768
|
+
* Writes each log message to the console unchanged. The reports this package
|
|
769
|
+
* prints are its output contract, so logging through `Effect.log` with this
|
|
770
|
+
* logger keeps the exact lines callers (and the test suite) rely on.
|
|
771
|
+
*/
|
|
772
|
+
const consoleLineLogger = Logger.withConsoleLog(Logger.make(({ message }) => String(message)));
|
|
773
|
+
/**
|
|
774
|
+
* Tells the data-first form of a coverage formatter from its data-last form: a
|
|
775
|
+
* one-argument call is data-first only when it passes a coverage, the only
|
|
776
|
+
* argument shape that carries `eventCases`.
|
|
777
|
+
*/
|
|
778
|
+
const isCoverageDataFirst = (args) => args.length >= 2 || typeof args[0] === "object" && args[0] !== null && "eventCases" in args[0];
|
|
779
|
+
const DIMENSION_KEYS = [
|
|
780
|
+
"states",
|
|
781
|
+
"stateNodes",
|
|
782
|
+
"configurations",
|
|
783
|
+
"statuses",
|
|
784
|
+
"eventTypes",
|
|
785
|
+
"transitions",
|
|
786
|
+
"transitionPairs",
|
|
787
|
+
"requirements",
|
|
788
|
+
"frontiers"
|
|
789
|
+
];
|
|
790
|
+
/**
|
|
791
|
+
* Coverage declares a `(runtime …)` placeholder in the dimensions whose
|
|
792
|
+
* universe is only known at runtime (serialized states, configurations). It
|
|
793
|
+
* marks the universe as open-ended; it is not an item, so reports leave it out
|
|
794
|
+
* of the counts and lists.
|
|
795
|
+
*/
|
|
796
|
+
function isRuntimePlaceholder(id) {
|
|
797
|
+
return id.startsWith("(runtime ");
|
|
798
|
+
}
|
|
799
|
+
function getDimension(coverage, key) {
|
|
800
|
+
const dimension = coverage[key];
|
|
801
|
+
if (!dimension.unknown.some(isRuntimePlaceholder)) return dimension;
|
|
802
|
+
return {
|
|
803
|
+
...dimension,
|
|
804
|
+
unknown: dimension.unknown.filter((id) => !isRuntimePlaceholder(id))
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
function totalOf(dimension) {
|
|
808
|
+
return dimension.covered.length + dimension.uncovered.length + dimension.unreachable.length + dimension.unknown.length;
|
|
809
|
+
}
|
|
810
|
+
function percentage(covered, total) {
|
|
811
|
+
if (total === 0) return "100.0%";
|
|
812
|
+
return `${(covered / total * 100).toFixed(1)}%`;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Renders a coverage id in a human-readable form. Ids are stable JSON strings;
|
|
816
|
+
* ids that are not recognized are rendered as-is.
|
|
817
|
+
* @experimental
|
|
818
|
+
*/
|
|
819
|
+
function formatTestCoverageId(id) {
|
|
820
|
+
let parsed;
|
|
821
|
+
try {
|
|
822
|
+
parsed = JSON.parse(id);
|
|
823
|
+
} catch {
|
|
824
|
+
return id;
|
|
825
|
+
}
|
|
826
|
+
if (!Array.isArray(parsed)) return id;
|
|
827
|
+
const [kind, ...rest] = parsed;
|
|
828
|
+
if (kind === "transition" && rest.length === 3) {
|
|
829
|
+
const [source, eventType, index] = rest;
|
|
830
|
+
return `${source} --${eventType}--> #${String(index)}`;
|
|
831
|
+
}
|
|
832
|
+
if (kind === "event-case" && rest.length === 2) {
|
|
833
|
+
const [eventType, caseName] = rest;
|
|
834
|
+
return `${eventType} / ${caseName}`;
|
|
835
|
+
}
|
|
836
|
+
return id;
|
|
837
|
+
}
|
|
838
|
+
function dimensionLine(name, dimension) {
|
|
839
|
+
const total = totalOf(dimension);
|
|
840
|
+
const covered = dimension.covered.length;
|
|
841
|
+
return `${name}: ${covered}/${total} covered (${percentage(covered, total)}), ${dimension.uncovered.length} uncovered, ${dimension.unreachable.length} unreachable, ${dimension.unknown.length} unknown`;
|
|
842
|
+
}
|
|
843
|
+
function explorationLines(exploration) {
|
|
844
|
+
const lines = [
|
|
845
|
+
`runs: configured ${exploration.configuredRuns ?? "n/a"}, completed ${exploration.completedRuns}, attempted ${exploration.attemptedRuns}` + (exploration.shrinkRuns !== 0 ? `, shrinking ${exploration.shrinkRuns}` : ""),
|
|
846
|
+
`sequence length: max ${exploration.maximumSequenceLength ?? "n/a"}, max observed ${exploration.maximumObservedSequenceLength}`,
|
|
847
|
+
`stopped because: ${exploration.stoppedBecause}`,
|
|
848
|
+
...exploration.swarm !== null ? [`swarm: ${exploration.swarm.runs} runs, ${exploration.swarm.averageEnabled.toFixed(2)} cases enabled on average`] : [],
|
|
849
|
+
...exploration.target.improvements !== 0 ? [`target: best ${exploration.target.best}${exploration.target.label !== void 0 && exploration.target.label.length > 0 ? ` (${exploration.target.label})` : ""}, ${exploration.target.improvements} improvements`] : [],
|
|
850
|
+
...exploration.truncated ? [`truncated: true${exploration.truncationReasons.length !== 0 ? ` (${[...exploration.truncationReasons].sort().join(", ")})` : ""}`] : [],
|
|
851
|
+
...exploration.pendingActorSteps !== 0 ? [`pending actors: ${exploration.pendingActorSteps} step(s) settled with actor work in flight`] : []
|
|
852
|
+
];
|
|
853
|
+
for (const frontier of exploration.frontiers) lines.push(`frontier ${frontier.id}: prefix ${frontier.prefixLength}, budget ${frontier.runBudget ?? "n/a"}, configured ${frontier.configuredRuns ?? "n/a"}, completed ${frontier.completedRuns}, attempted ${frontier.attemptedRuns}`);
|
|
854
|
+
for (const seed of exploration.seeds) lines.push(`seed ${seed.frontierId}: engine ${seed.engine ?? "n/a"}, seed ${seed.seed ?? "n/a"}, path ${seed.path ?? "n/a"}`);
|
|
855
|
+
return lines;
|
|
856
|
+
}
|
|
857
|
+
/** One line per temporal property with its run counts, then the warnings. */
|
|
858
|
+
function temporalLines(coverage, indent) {
|
|
859
|
+
return [...Object.entries(coverage.temporal.counts).sort(([left], [right]) => left.localeCompare(right)).map(([id, counts]) => `${indent}- ${id}: ${counts.satisfied} satisfied, ${counts.failed} failed, ${counts.inconclusive} inconclusive${coverage.temporal.failed.includes(id) && counts.failed === 0 ? " (never held)" : ""}`), ...coverage.temporal.warnings.map((warning) => `${indent}warning: ${warning}`)];
|
|
860
|
+
}
|
|
861
|
+
function listLines(title, ids) {
|
|
862
|
+
if (ids.length === 0) return [];
|
|
863
|
+
return [`${title}:`, ...[...ids].sort().map((id) => ` - ${formatTestCoverageId(id)}`)];
|
|
864
|
+
}
|
|
865
|
+
function markdownTable(headers, rows) {
|
|
866
|
+
return [
|
|
867
|
+
`| ${headers.join(" | ")} |`,
|
|
868
|
+
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
869
|
+
...rows.map((row) => `| ${row.join(" | ")} |`)
|
|
870
|
+
];
|
|
871
|
+
}
|
|
872
|
+
function formatText(coverage) {
|
|
873
|
+
const lines = ["Test coverage", ""];
|
|
874
|
+
for (const key of DIMENSION_KEYS) lines.push(dimensionLine(key, getDimension(coverage, key)));
|
|
875
|
+
lines.push("");
|
|
876
|
+
for (const key of DIMENSION_KEYS) {
|
|
877
|
+
const dimension = getDimension(coverage, key);
|
|
878
|
+
lines.push(...listLines(`uncovered ${key}`, dimension.uncovered), ...listLines(`unreachable ${key}`, dimension.unreachable), ...listLines(`unknown ${key}`, dimension.unknown));
|
|
879
|
+
}
|
|
880
|
+
const eventCases = Object.entries(coverage.eventCases).sort(([left], [right]) => left.localeCompare(right));
|
|
881
|
+
if (eventCases.length !== 0) {
|
|
882
|
+
lines.push("event cases:");
|
|
883
|
+
for (const [id, counts] of eventCases) lines.push(` - ${formatTestCoverageId(id)}: ${counts.generated} generated, ${counts.applicable} applicable, ${counts.executed} executed, ${counts.ignored} ignored`);
|
|
884
|
+
}
|
|
885
|
+
const labels = Object.entries(coverage.labels).sort(([left], [right]) => left.localeCompare(right));
|
|
886
|
+
if (labels.length !== 0) {
|
|
887
|
+
lines.push("labels:");
|
|
888
|
+
for (const [name, label] of labels) {
|
|
889
|
+
const values = Object.entries(label.values).sort(([left], [right]) => left.localeCompare(right)).map(([value, count]) => `${value}=${count}`).join(", ");
|
|
890
|
+
lines.push(` - ${name}: ${label.count} recorded, ${percentage(Math.round(label.share * 1e3), 1e3)} of runs${values.length > 0 ? ` (${values})` : ""}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
lines.push(`temporal: ${coverage.temporal.satisfied.length} satisfied, ${coverage.temporal.failed.length} failed, ${coverage.temporal.inconclusive.length} inconclusive`, ...temporalLines(coverage, " "), "", "exploration:", ...explorationLines(coverage.exploration).map((line) => ` ${line}`));
|
|
894
|
+
return lines.join("\n");
|
|
895
|
+
}
|
|
896
|
+
function formatMarkdown(coverage) {
|
|
897
|
+
const lines = ["# Test coverage", ""];
|
|
898
|
+
lines.push(...markdownTable([
|
|
899
|
+
"Dimension",
|
|
900
|
+
"Covered",
|
|
901
|
+
"Total",
|
|
902
|
+
"Ratio",
|
|
903
|
+
"Uncovered",
|
|
904
|
+
"Unreachable",
|
|
905
|
+
"Unknown"
|
|
906
|
+
], DIMENSION_KEYS.map((key) => {
|
|
907
|
+
const dimension = getDimension(coverage, key);
|
|
908
|
+
const total = totalOf(dimension);
|
|
909
|
+
return [
|
|
910
|
+
key,
|
|
911
|
+
String(dimension.covered.length),
|
|
912
|
+
String(total),
|
|
913
|
+
percentage(dimension.covered.length, total),
|
|
914
|
+
String(dimension.uncovered.length),
|
|
915
|
+
String(dimension.unreachable.length),
|
|
916
|
+
String(dimension.unknown.length)
|
|
917
|
+
];
|
|
918
|
+
})), "");
|
|
919
|
+
const outstanding = [];
|
|
920
|
+
for (const key of DIMENSION_KEYS) {
|
|
921
|
+
const dimension = getDimension(coverage, key);
|
|
922
|
+
for (const status of [
|
|
923
|
+
"uncovered",
|
|
924
|
+
"unreachable",
|
|
925
|
+
"unknown"
|
|
926
|
+
]) for (const id of [...dimension[status]].sort()) outstanding.push([
|
|
927
|
+
key,
|
|
928
|
+
status,
|
|
929
|
+
formatTestCoverageId(id)
|
|
930
|
+
]);
|
|
931
|
+
}
|
|
932
|
+
lines.push("## Outstanding", "");
|
|
933
|
+
if (outstanding.length !== 0) lines.push(...markdownTable([
|
|
934
|
+
"Dimension",
|
|
935
|
+
"Status",
|
|
936
|
+
"Id"
|
|
937
|
+
], outstanding), "");
|
|
938
|
+
else lines.push("Everything declared was covered.", "");
|
|
939
|
+
const eventCases = Object.entries(coverage.eventCases).sort(([left], [right]) => left.localeCompare(right));
|
|
940
|
+
if (eventCases.length !== 0) lines.push("## Event cases", "", ...markdownTable([
|
|
941
|
+
"Case",
|
|
942
|
+
"Generated",
|
|
943
|
+
"Applicable",
|
|
944
|
+
"Executed",
|
|
945
|
+
"Ignored"
|
|
946
|
+
], eventCases.map(([id, counts]) => [
|
|
947
|
+
formatTestCoverageId(id),
|
|
948
|
+
String(counts.generated),
|
|
949
|
+
String(counts.applicable),
|
|
950
|
+
String(counts.executed),
|
|
951
|
+
String(counts.ignored)
|
|
952
|
+
])), "");
|
|
953
|
+
const labels = Object.entries(coverage.labels).sort(([left], [right]) => left.localeCompare(right));
|
|
954
|
+
if (labels.length !== 0) lines.push("## Labels", "", ...markdownTable([
|
|
955
|
+
"Label",
|
|
956
|
+
"Count",
|
|
957
|
+
"Share",
|
|
958
|
+
"Values"
|
|
959
|
+
], labels.map(([name, label]) => [
|
|
960
|
+
name,
|
|
961
|
+
String(label.count),
|
|
962
|
+
percentage(Math.round(label.share * 1e3), 1e3),
|
|
963
|
+
Object.entries(label.values).sort(([left], [right]) => left.localeCompare(right)).map(([value, count]) => `${value}=${count}`).join("; ") || "-"
|
|
964
|
+
])), "");
|
|
965
|
+
lines.push("## Temporal", "", ...markdownTable([
|
|
966
|
+
"Status",
|
|
967
|
+
"Count",
|
|
968
|
+
"Ids"
|
|
969
|
+
], [
|
|
970
|
+
"satisfied",
|
|
971
|
+
"failed",
|
|
972
|
+
"inconclusive"
|
|
973
|
+
].map((status) => [
|
|
974
|
+
status,
|
|
975
|
+
String(coverage.temporal[status].length),
|
|
976
|
+
[...coverage.temporal[status]].sort().join("; ") || "-"
|
|
977
|
+
])), "", ...Object.keys(coverage.temporal.counts).length !== 0 ? [...markdownTable([
|
|
978
|
+
"Property",
|
|
979
|
+
"Satisfied runs",
|
|
980
|
+
"Failed runs",
|
|
981
|
+
"Inconclusive runs"
|
|
982
|
+
], Object.entries(coverage.temporal.counts).sort(([left], [right]) => left.localeCompare(right)).map(([id, counts]) => [
|
|
983
|
+
id,
|
|
984
|
+
String(counts.satisfied),
|
|
985
|
+
String(counts.failed),
|
|
986
|
+
String(counts.inconclusive)
|
|
987
|
+
])), ""] : [], ...coverage.temporal.warnings.map((warning) => `> Warning: ${warning}`), ...coverage.temporal.warnings.length !== 0 ? [""] : [], "## Exploration", "", ...explorationLines(coverage.exploration).map((line) => `- ${line}`));
|
|
988
|
+
return lines.join("\n");
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Formats a {@link TestCoverage} as human-readable text or markdown.
|
|
992
|
+
*
|
|
993
|
+
* @experimental
|
|
994
|
+
*/
|
|
995
|
+
const formatTestCoverage = dual(isCoverageDataFirst, function formatTestCoverage(coverage, options = {}) {
|
|
996
|
+
return options.format === "markdown" ? formatMarkdown(coverage) : formatText(coverage);
|
|
997
|
+
});
|
|
998
|
+
function dimensionToJSON(dimension) {
|
|
999
|
+
const total = totalOf(dimension);
|
|
1000
|
+
const covered = dimension.covered.length;
|
|
1001
|
+
return {
|
|
1002
|
+
total,
|
|
1003
|
+
covered,
|
|
1004
|
+
ratio: total !== 0 ? covered / total : 1,
|
|
1005
|
+
counts: { ...dimension.counts },
|
|
1006
|
+
coveredIds: [...dimension.covered],
|
|
1007
|
+
uncovered: [...dimension.uncovered],
|
|
1008
|
+
unreachable: [...dimension.unreachable],
|
|
1009
|
+
unknown: [...dimension.unknown]
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Converts a {@link TestCoverage} to stable, versioned, JSON-safe data.
|
|
1014
|
+
*
|
|
1015
|
+
* @experimental
|
|
1016
|
+
*/
|
|
1017
|
+
function testCoverageToJSON(coverage) {
|
|
1018
|
+
const dimensions = {};
|
|
1019
|
+
for (const key of DIMENSION_KEYS) dimensions[key] = dimensionToJSON(getDimension(coverage, key));
|
|
1020
|
+
return {
|
|
1021
|
+
formatVersion: 1,
|
|
1022
|
+
totals: {
|
|
1023
|
+
runs: coverage.runs,
|
|
1024
|
+
steps: coverage.steps,
|
|
1025
|
+
skipped: coverage.skipped,
|
|
1026
|
+
prefixSteps: coverage.prefixSteps,
|
|
1027
|
+
generatedSteps: coverage.generatedSteps,
|
|
1028
|
+
invariantChecks: coverage.invariantChecks,
|
|
1029
|
+
temporalChecks: coverage.temporalChecks,
|
|
1030
|
+
clockAdvances: coverage.clockAdvances,
|
|
1031
|
+
checkpoints: coverage.checkpoints,
|
|
1032
|
+
stops: coverage.stops,
|
|
1033
|
+
sutComparisons: coverage.sutComparisons,
|
|
1034
|
+
oracleComparisons: coverage.oracleComparisons
|
|
1035
|
+
},
|
|
1036
|
+
dimensions,
|
|
1037
|
+
eventCases: Object.fromEntries(Object.entries(coverage.eventCases).sort(([left], [right]) => left.localeCompare(right)).map(([id, counts]) => [id, {
|
|
1038
|
+
weight: counts.weight,
|
|
1039
|
+
generated: counts.generated,
|
|
1040
|
+
applicable: counts.applicable,
|
|
1041
|
+
executed: counts.executed,
|
|
1042
|
+
ignored: counts.ignored
|
|
1043
|
+
}])),
|
|
1044
|
+
temporal: {
|
|
1045
|
+
satisfied: [...coverage.temporal.satisfied],
|
|
1046
|
+
failed: [...coverage.temporal.failed],
|
|
1047
|
+
inconclusive: [...coverage.temporal.inconclusive],
|
|
1048
|
+
counts: Object.fromEntries(Object.entries(coverage.temporal.counts).sort(([left], [right]) => left.localeCompare(right)).map(([id, counts]) => [id, { ...counts }])),
|
|
1049
|
+
warnings: [...coverage.temporal.warnings]
|
|
1050
|
+
},
|
|
1051
|
+
labels: Object.fromEntries(Object.entries(coverage.labels).sort(([left], [right]) => left.localeCompare(right)).map(([name, label]) => [name, {
|
|
1052
|
+
count: label.count,
|
|
1053
|
+
values: { ...label.values },
|
|
1054
|
+
share: label.share
|
|
1055
|
+
}])),
|
|
1056
|
+
exploration: {
|
|
1057
|
+
stoppedBecause: coverage.exploration.stoppedBecause,
|
|
1058
|
+
configuredRuns: coverage.exploration.configuredRuns,
|
|
1059
|
+
completedRuns: coverage.exploration.completedRuns,
|
|
1060
|
+
attemptedRuns: coverage.exploration.attemptedRuns,
|
|
1061
|
+
shrinkRuns: coverage.exploration.shrinkRuns,
|
|
1062
|
+
maximumSequenceLength: coverage.exploration.maximumSequenceLength,
|
|
1063
|
+
maximumObservedSequenceLength: coverage.exploration.maximumObservedSequenceLength,
|
|
1064
|
+
truncated: coverage.exploration.truncated,
|
|
1065
|
+
truncationReasons: [...coverage.exploration.truncationReasons],
|
|
1066
|
+
frontiers: coverage.exploration.frontiers.map((frontier) => ({
|
|
1067
|
+
id: frontier.id,
|
|
1068
|
+
prefixLength: frontier.prefixLength,
|
|
1069
|
+
runBudget: frontier.runBudget,
|
|
1070
|
+
configuredRuns: frontier.configuredRuns,
|
|
1071
|
+
completedRuns: frontier.completedRuns,
|
|
1072
|
+
attemptedRuns: frontier.attemptedRuns
|
|
1073
|
+
})),
|
|
1074
|
+
seeds: coverage.exploration.seeds.map((seed) => ({
|
|
1075
|
+
frontierId: seed.frontierId,
|
|
1076
|
+
engine: seed.engine ?? null,
|
|
1077
|
+
seed: seed.seed ?? null,
|
|
1078
|
+
path: seed.path ?? null
|
|
1079
|
+
})),
|
|
1080
|
+
...coverage.exploration.swarm !== null ? { swarm: {
|
|
1081
|
+
runs: coverage.exploration.swarm.runs,
|
|
1082
|
+
averageEnabled: coverage.exploration.swarm.averageEnabled
|
|
1083
|
+
} } : {},
|
|
1084
|
+
...coverage.exploration.target.improvements !== 0 ? { target: {
|
|
1085
|
+
best: coverage.exploration.target.best,
|
|
1086
|
+
label: coverage.exploration.target.label ?? null,
|
|
1087
|
+
improvements: coverage.exploration.target.improvements
|
|
1088
|
+
} } : {}
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
function escapeXML(value) {
|
|
1093
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Formats a {@link TestCoverage} as JUnit XML, with one `<testcase>` per
|
|
1097
|
+
* transition and per state node.
|
|
1098
|
+
* @experimental
|
|
1099
|
+
*/
|
|
1100
|
+
const formatTestCoverageJUnit = dual(isCoverageDataFirst, function formatTestCoverageJUnit(coverage, options = {}) {
|
|
1101
|
+
const suiteName = options.suiteName ?? "test-coverage";
|
|
1102
|
+
const cases = [];
|
|
1103
|
+
let tests = 0;
|
|
1104
|
+
let failures = 0;
|
|
1105
|
+
let skipped = 0;
|
|
1106
|
+
for (const key of ["transitions", "stateNodes"]) {
|
|
1107
|
+
const dimension = getDimension(coverage, key);
|
|
1108
|
+
const entries = [
|
|
1109
|
+
...dimension.covered.map((id) => [id, "covered"]),
|
|
1110
|
+
...dimension.uncovered.map((id) => [id, "uncovered"]),
|
|
1111
|
+
...dimension.unreachable.map((id) => [id, "unreachable"]),
|
|
1112
|
+
...dimension.unknown.map((id) => [id, "unknown"])
|
|
1113
|
+
];
|
|
1114
|
+
entries.sort(([left], [right]) => left.localeCompare(right));
|
|
1115
|
+
for (const [id, status] of entries) {
|
|
1116
|
+
tests++;
|
|
1117
|
+
const name = escapeXML(formatTestCoverageId(id));
|
|
1118
|
+
if (status === "covered") cases.push(` <testcase classname="${key}" name="${name}" />`);
|
|
1119
|
+
else if (status === "uncovered") {
|
|
1120
|
+
failures++;
|
|
1121
|
+
cases.push(` <testcase classname="${key}" name="${name}">\n <failure message="uncovered" type="uncovered" />\n </testcase>`);
|
|
1122
|
+
} else {
|
|
1123
|
+
skipped++;
|
|
1124
|
+
cases.push(` <testcase classname="${key}" name="${name}">\n <skipped message="${status}" />\n </testcase>`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const properties = explorationLines(coverage.exploration).map((line, index) => ` <property name="exploration.${index}" value="${escapeXML(line)}" />`);
|
|
1129
|
+
return [
|
|
1130
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
1131
|
+
"<testsuites>",
|
|
1132
|
+
` <testsuite name="${escapeXML(suiteName)}" tests="${tests}" failures="${failures}" skipped="${skipped}">`,
|
|
1133
|
+
" <properties>",
|
|
1134
|
+
...properties,
|
|
1135
|
+
" </properties>",
|
|
1136
|
+
...cases,
|
|
1137
|
+
" </testsuite>",
|
|
1138
|
+
"</testsuites>",
|
|
1139
|
+
""
|
|
1140
|
+
].join("\n");
|
|
1141
|
+
});
|
|
1142
|
+
function escapeHTML(value) {
|
|
1143
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Formats a {@link TestCoverage} as a self-contained HTML document.
|
|
1147
|
+
*
|
|
1148
|
+
* @experimental
|
|
1149
|
+
*/
|
|
1150
|
+
const formatTestCoverageHTML = dual(isCoverageDataFirst, function formatTestCoverageHTML(coverage, options = {}) {
|
|
1151
|
+
const title = options.title ?? "Test coverage";
|
|
1152
|
+
const cards = DIMENSION_KEYS.map((key) => {
|
|
1153
|
+
const dimension = getDimension(coverage, key);
|
|
1154
|
+
const total = totalOf(dimension);
|
|
1155
|
+
return `<div class="card"><h2>${escapeHTML(key)}</h2><p class="ratio">${dimension.covered.length}/${total} (${percentage(dimension.covered.length, total)})</p><p class="detail">${dimension.uncovered.length} uncovered, ${dimension.unreachable.length} unreachable, ${dimension.unknown.length} unknown</p></div>`;
|
|
1156
|
+
}).join("");
|
|
1157
|
+
const rows = [];
|
|
1158
|
+
for (const key of DIMENSION_KEYS) {
|
|
1159
|
+
const dimension = getDimension(coverage, key);
|
|
1160
|
+
for (const status of [
|
|
1161
|
+
"uncovered",
|
|
1162
|
+
"unreachable",
|
|
1163
|
+
"unknown"
|
|
1164
|
+
]) for (const id of [...dimension[status]].sort()) rows.push(`<tr><td>${escapeHTML(key)}</td><td>${escapeHTML(status)}</td><td>${escapeHTML(formatTestCoverageId(id))}</td></tr>`);
|
|
1165
|
+
}
|
|
1166
|
+
const explorationItems = explorationLines(coverage.exploration).map((line) => `<li>${escapeHTML(line)}</li>`).join("");
|
|
1167
|
+
return [
|
|
1168
|
+
"<!doctype html>",
|
|
1169
|
+
"<html lang=\"en\">",
|
|
1170
|
+
"<head>",
|
|
1171
|
+
"<meta charset=\"utf-8\" />",
|
|
1172
|
+
`<title>${escapeHTML(title)}</title>`,
|
|
1173
|
+
"<style>",
|
|
1174
|
+
"body{font-family:system-ui,sans-serif;margin:2rem;color:#111;background:#fff}",
|
|
1175
|
+
".cards{display:flex;flex-wrap:wrap;gap:1rem}",
|
|
1176
|
+
".card{border:1px solid #ddd;border-radius:8px;padding:1rem;min-width:12rem}",
|
|
1177
|
+
".card h2{font-size:.9rem;margin:0 0 .5rem;text-transform:uppercase}",
|
|
1178
|
+
".ratio{font-size:1.4rem;margin:0}",
|
|
1179
|
+
".detail{color:#555;font-size:.8rem;margin:.25rem 0 0}",
|
|
1180
|
+
"table{border-collapse:collapse;margin-top:1rem;width:100%}",
|
|
1181
|
+
"th,td{border:1px solid #ddd;padding:.4rem .6rem;text-align:left;font-size:.85rem}",
|
|
1182
|
+
"</style>",
|
|
1183
|
+
"</head>",
|
|
1184
|
+
"<body>",
|
|
1185
|
+
`<h1>${escapeHTML(title)}</h1>`,
|
|
1186
|
+
`<p>${coverage.runs} runs, ${coverage.steps} steps, ${coverage.invariantChecks} invariant checks</p>`,
|
|
1187
|
+
`<div class="cards">${cards}</div>`,
|
|
1188
|
+
"<h2>Outstanding</h2>",
|
|
1189
|
+
rows.length !== 0 ? `<table><thead><tr><th>Dimension</th><th>Status</th><th>Id</th></tr></thead><tbody>${rows.join("")}</tbody></table>` : "<p>Everything declared was covered.</p>",
|
|
1190
|
+
"<h2>Temporal</h2>",
|
|
1191
|
+
`<p>${coverage.temporal.satisfied.length} satisfied, ${coverage.temporal.failed.length} failed, ${coverage.temporal.inconclusive.length} inconclusive</p>`,
|
|
1192
|
+
...coverage.temporal.warnings.map((warning) => `<p><strong>Warning:</strong> ${escapeHTML(warning)}</p>`),
|
|
1193
|
+
"<h2>Exploration</h2>",
|
|
1194
|
+
`<ul>${explorationItems}</ul>`,
|
|
1195
|
+
"</body>",
|
|
1196
|
+
"</html>",
|
|
1197
|
+
""
|
|
1198
|
+
].join("\n");
|
|
1199
|
+
});
|
|
1200
|
+
/**
|
|
1201
|
+
* Throws an `Error` containing the formatted coverage text when any dimension's
|
|
1202
|
+
* covered ratio — `covered / (covered + uncovered)` — is below its threshold.
|
|
1203
|
+
* Thresholds are ratios between `0` and `1`.
|
|
1204
|
+
* @experimental
|
|
1205
|
+
*/
|
|
1206
|
+
const assertTestCoverage = dual(2, function assertTestCoverage(coverage, thresholds) {
|
|
1207
|
+
const failures = [];
|
|
1208
|
+
for (const key of DIMENSION_KEYS) {
|
|
1209
|
+
const threshold = thresholds[key];
|
|
1210
|
+
if (threshold === void 0) continue;
|
|
1211
|
+
const dimension = getDimension(coverage, key);
|
|
1212
|
+
const considered = dimension.covered.length + dimension.uncovered.length;
|
|
1213
|
+
if ((considered !== 0 ? dimension.covered.length / considered : 1) < threshold) failures.push(`${key}: ${percentage(dimension.covered.length, considered)} covered, below the ${percentage(threshold, 1)} threshold`);
|
|
1214
|
+
}
|
|
1215
|
+
if (failures.length !== 0) throw new Error(`Test coverage thresholds not met:\n${failures.map((failure) => ` - ${failure}`).join("\n")}\n\n${formatTestCoverage(coverage)}`);
|
|
1216
|
+
});
|
|
1217
|
+
function formatShare(share) {
|
|
1218
|
+
return `${(share * 100).toFixed(1)}%`.padStart(6);
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Formats the distribution of generated data: how executed events split
|
|
1222
|
+
* across event cases, and the share of runs that recorded each label. Shrink
|
|
1223
|
+
* attempts are not counted. Pass `statistics: true` to `propertyTest()` or
|
|
1224
|
+
* `testPaths()` to print it after a passing campaign, or a function,
|
|
1225
|
+
* `(report) => void | Promise<void>`, to receive the same string instead.
|
|
1226
|
+
* @experimental
|
|
1227
|
+
*/
|
|
1228
|
+
function formatTestStatistics(coverage) {
|
|
1229
|
+
const runs = coverage.exploration.attemptedRuns - coverage.exploration.shrinkRuns;
|
|
1230
|
+
const lines = [`Test statistics (${runs} run${runs === 1 ? "" : "s"})`];
|
|
1231
|
+
const eventCases = Object.entries(coverage.eventCases).sort(([left], [right]) => left.localeCompare(right));
|
|
1232
|
+
const executed = eventCases.reduce((total, [, counts]) => total + counts.executed, 0);
|
|
1233
|
+
if (eventCases.length !== 0) {
|
|
1234
|
+
lines.push("", "event cases (share of executed events):");
|
|
1235
|
+
for (const [id, counts] of eventCases) lines.push(` ${formatShare(executed !== 0 ? counts.executed / executed : 0)} ${formatTestCoverageId(id)}: ${counts.executed} executed, ${counts.ignored} ignored`);
|
|
1236
|
+
}
|
|
1237
|
+
const labels = Object.entries(coverage.labels).sort(([left], [right]) => left.localeCompare(right));
|
|
1238
|
+
if (labels.length !== 0) {
|
|
1239
|
+
lines.push("", "labels (share of runs):");
|
|
1240
|
+
for (const [name, label] of labels) {
|
|
1241
|
+
const values = Object.entries(label.values).sort(([left], [right]) => left.localeCompare(right)).map(([value, count]) => `${value}=${count}`).join(", ");
|
|
1242
|
+
lines.push(` ${formatShare(label.share)} ${name}: ${label.count} recorded${values.length > 0 ? ` (${values})` : ""}`);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return lines.join("\n");
|
|
1246
|
+
}
|
|
1247
|
+
// src/engine/propertyTest.ts
|
|
1248
|
+
/**
|
|
1249
|
+
* Awaits a temporal predicate, which may hold synchronously or as a promise.
|
|
1250
|
+
* A rejection stays the same error object when the effect is run.
|
|
1251
|
+
*/
|
|
1252
|
+
const temporalPredicate = (predicate, context) => Effect.promise(() => Promise.resolve(predicate(context)));
|
|
1253
|
+
/**
|
|
1254
|
+
* Runs an oracle's comparison, which may hold synchronously or as a promise,
|
|
1255
|
+
* falling back to structural equivalence. A rejection stays the same error
|
|
1256
|
+
* object when the effect is run.
|
|
1257
|
+
*/
|
|
1258
|
+
const oracleMatches = (equivalent, model, observed) => {
|
|
1259
|
+
const compare = equivalent;
|
|
1260
|
+
return compare === void 0 ? Effect.succeed(defaultEquivalent(model, observed)) : Effect.promise(() => Promise.resolve(compare(model, observed)));
|
|
1261
|
+
};
|
|
1262
|
+
function getCauseMessage(cause) {
|
|
1263
|
+
if (cause instanceof Error) return cause.message || cause.name;
|
|
1264
|
+
if (typeof cause === "string") return cause;
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Builds the full failure message before `Error` captures the stack. The
|
|
1268
|
+
* formatted trace is part of the message so reporters that only print
|
|
1269
|
+
* `error.stack` still show the counterexample (GH #3435).
|
|
1270
|
+
*/
|
|
1271
|
+
function getPropertyFailureMessage(summary, trace, cause, replay, fixture, format, extras) {
|
|
1272
|
+
const causeMessage = getCauseMessage(cause);
|
|
1273
|
+
const lines = [causeMessage !== void 0 && causeMessage.length > 0 && !summary.includes(causeMessage) ? `${summary}: ${causeMessage}` : summary];
|
|
1274
|
+
const reproduce = [
|
|
1275
|
+
...replay?.seed === void 0 ? [] : [`seed ${replay.seed}`],
|
|
1276
|
+
...replay?.path === void 0 ? [] : [`path "${replay.path}"`],
|
|
1277
|
+
...replay?.replayPath === void 0 ? [] : [`replayPath "${replay.replayPath}"`]
|
|
1278
|
+
];
|
|
1279
|
+
if (reproduce.length !== 0) lines.push(`Reproduce: ${reproduce.join(", ")}`);
|
|
1280
|
+
if (fixture !== void 0) lines.push("Fixture: failure.fixture (replayTest)");
|
|
1281
|
+
if (replay !== void 0 && replay.numShrinks !== void 0 && replay.numShrinks !== 0) lines.push(`Shrunk ${replay.numShrinks} time(s)`);
|
|
1282
|
+
lines.push(...extras?.notes ?? []);
|
|
1283
|
+
try {
|
|
1284
|
+
lines.push("", formatTestTrace(trace, format));
|
|
1285
|
+
} catch {}
|
|
1286
|
+
if (extras?.report !== void 0 && extras.report.length > 0) lines.push("", extras.report);
|
|
1287
|
+
return lines.join("\n");
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Thrown by `propertyTest()`, `testPaths()`, and `replayTest()` when an
|
|
1291
|
+
* oracle fails. Carries the trace, a portable replay fixture, and the coverage
|
|
1292
|
+
* accumulated up to the failure.
|
|
1293
|
+
* @experimental
|
|
1294
|
+
*/
|
|
1295
|
+
const ModelTestFailureBase = Data.TaggedError("@systemfsoftware/xstate-test/engine/propertyTest/ModelTestFailure");
|
|
1296
|
+
var ModelTestFailure = class extends ModelTestFailureBase {
|
|
1297
|
+
trace;
|
|
1298
|
+
cause;
|
|
1299
|
+
replay;
|
|
1300
|
+
fixture;
|
|
1301
|
+
coverage;
|
|
1302
|
+
format;
|
|
1303
|
+
extras;
|
|
1304
|
+
/**
|
|
1305
|
+
* `instanceof` narrows to the default type arguments rather than `any`, so
|
|
1306
|
+
* `error.trace` stays typed in a `catch` block.
|
|
1307
|
+
*/
|
|
1308
|
+
static [Symbol.hasInstance](value) {
|
|
1309
|
+
return Function.prototype[Symbol.hasInstance].call(this, value);
|
|
1310
|
+
}
|
|
1311
|
+
/** The short message, without the formatted trace. */
|
|
1312
|
+
summary;
|
|
1313
|
+
constructor(summary, trace, cause, replay, fixture, coverage, format, extras) {
|
|
1314
|
+
super({ message: getPropertyFailureMessage(summary, trace, cause, replay, fixture, format, extras) });
|
|
1315
|
+
this.trace = trace;
|
|
1316
|
+
this.cause = cause;
|
|
1317
|
+
this.replay = replay;
|
|
1318
|
+
this.fixture = fixture;
|
|
1319
|
+
this.coverage = coverage;
|
|
1320
|
+
this.format = format;
|
|
1321
|
+
this.extras = extras;
|
|
1322
|
+
this.name = "ModelTestFailure";
|
|
1323
|
+
this.summary = summary;
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
/**
|
|
1327
|
+
* Thrown when a campaign completes without a counterexample but a
|
|
1328
|
+
* campaign-level assertion does not hold: a `sometimes` property that held in
|
|
1329
|
+
* no run, or a `reachable` target that no run entered.
|
|
1330
|
+
* @experimental
|
|
1331
|
+
*/
|
|
1332
|
+
const TestCampaignErrorBase = Data.TaggedError("@systemfsoftware/xstate-test/engine/propertyTest/TestCampaignError");
|
|
1333
|
+
var TestCampaignError = class extends TestCampaignErrorBase {
|
|
1334
|
+
failures;
|
|
1335
|
+
coverage;
|
|
1336
|
+
name = "TestCampaignError";
|
|
1337
|
+
constructor(failures, coverage) {
|
|
1338
|
+
super({ message: `Campaign assertions failed:\n${failures.map((failure) => ` - ${failure}`).join("\n")}` });
|
|
1339
|
+
this.failures = failures;
|
|
1340
|
+
this.coverage = coverage;
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
/**
|
|
1344
|
+
* Structural, key-order insensitive deep equality used to compare model
|
|
1345
|
+
* projections against reference/SUT observations. Cycle-safe.
|
|
1346
|
+
* @experimental
|
|
1347
|
+
*/
|
|
1348
|
+
const defaultEquivalent = dual(2, function defaultEquivalent(left, right) {
|
|
1349
|
+
return deepEqual(left, right, /* @__PURE__ */ new Map());
|
|
1350
|
+
});
|
|
1351
|
+
function deepEqual(left, right, visited) {
|
|
1352
|
+
if (Object.is(left, right)) return true;
|
|
1353
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return left === right;
|
|
1354
|
+
const seen = visited.get(left);
|
|
1355
|
+
if (seen?.has(right) === true) return true;
|
|
1356
|
+
if (seen !== void 0) seen.add(right);
|
|
1357
|
+
else visited.set(left, /* @__PURE__ */ new Set([right]));
|
|
1358
|
+
if (left instanceof Date || right instanceof Date) return left instanceof Date && right instanceof Date && left.getTime() === right.getTime();
|
|
1359
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
1360
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
1361
|
+
return left.length === right.length && left.every((value, index) => deepEqual(value, right[index], visited));
|
|
1362
|
+
}
|
|
1363
|
+
if (left instanceof Map || right instanceof Map) {
|
|
1364
|
+
if (!(left instanceof Map) || !(right instanceof Map)) return false;
|
|
1365
|
+
if (left.size !== right.size) return false;
|
|
1366
|
+
for (const [key, value] of left) if (!right.has(key) || !deepEqual(value, right.get(key), visited)) return false;
|
|
1367
|
+
return true;
|
|
1368
|
+
}
|
|
1369
|
+
if (left instanceof Set || right instanceof Set) {
|
|
1370
|
+
if (!(left instanceof Set) || !(right instanceof Set)) return false;
|
|
1371
|
+
if (left.size !== right.size) return false;
|
|
1372
|
+
for (const value of left) if (!right.has(value)) return false;
|
|
1373
|
+
return true;
|
|
1374
|
+
}
|
|
1375
|
+
const leftKeys = Object.keys(left);
|
|
1376
|
+
const rightKeys = Object.keys(right);
|
|
1377
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
1378
|
+
return leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && deepEqual(left[key], right[key], visited));
|
|
1379
|
+
}
|
|
1380
|
+
function assertEventPayload(payload, type, caseId) {
|
|
1381
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1382
|
+
const location = caseId !== void 0 && caseId.length > 0 ? `Property event case ${caseId}` : `Property event "${type}"`;
|
|
1383
|
+
throw new Error(`${location} generated a non-object payload (${typeof payload === "string" ? JSON.stringify(payload) : String(payload)}). Event payloads must be plain objects; use a \`resolve\` function to map generated values onto an event payload.`);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
/** Microtask turns awaited per drain round. */
|
|
1387
|
+
const DRAIN_MICROTASKS = 8;
|
|
1388
|
+
/** Upper bound on drain rounds awaited before an executed step is settled. */
|
|
1389
|
+
const MAX_DRAIN_ROUNDS = 40;
|
|
1390
|
+
/** Consecutive rounds without new inspection events that settle a step. */
|
|
1391
|
+
const QUIET_DRAIN_ROUNDS = 2;
|
|
1392
|
+
/** `count` consecutive indices; a `const` loop counter for generator bodies. */
|
|
1393
|
+
const indexRange = (count) => Array.from({ length: count }, (_, index) => index);
|
|
1394
|
+
/** One drain turn: `setImmediate` and a zero timer alternate to flush both queues. */
|
|
1395
|
+
const drainMacrotaskTurn = (round) => round % 2 === 0 && typeof setImmediate === "function" ? Effect.callback((resume) => {
|
|
1396
|
+
setImmediate(() => resume(Effect.void));
|
|
1397
|
+
}) : Effect.sleep(1);
|
|
1398
|
+
/** Drains `count` microtask turns in order, so queued continuations settle. */
|
|
1399
|
+
const microtaskTurns = (count) => count === 0 ? Effect.void : Effect.promise(() => Promise.resolve()).pipe(Effect.andThen(microtaskTurns(count - 1)));
|
|
1400
|
+
/**
|
|
1401
|
+
* Records the observation a step produced on its timeline entry. The
|
|
1402
|
+
* published entry types freeze `observation` as readonly, so the write goes
|
|
1403
|
+
* through one place instead of at every step.
|
|
1404
|
+
*/
|
|
1405
|
+
const setEntryObservation = (entry, observation) => {
|
|
1406
|
+
Object.assign(entry, { observation });
|
|
1407
|
+
};
|
|
1408
|
+
/** The modelled transition when no actor is executed. */
|
|
1409
|
+
const pureTransitionStep = (logic, coverage, previousSnapshot, event) => {
|
|
1410
|
+
const [snapshot, effects, selected] = transitionWithDetails(logic, previousSnapshot, event);
|
|
1411
|
+
return {
|
|
1412
|
+
snapshot,
|
|
1413
|
+
effects,
|
|
1414
|
+
transitionIds: recordPropertyTransitions(coverage, event, selected),
|
|
1415
|
+
drained: []
|
|
1416
|
+
};
|
|
1417
|
+
};
|
|
1418
|
+
/** Stops the executed actor and drains the transitions it leaves behind. */
|
|
1419
|
+
const stopExecutedStep = (engine, coverage) => Effect.gen(function* () {
|
|
1420
|
+
engine.stop();
|
|
1421
|
+
yield* Effect.promise(() => engine.drain());
|
|
1422
|
+
const drained = engine.consume(coverage);
|
|
1423
|
+
return {
|
|
1424
|
+
snapshot: engine.getSnapshot(),
|
|
1425
|
+
effects: [],
|
|
1426
|
+
transitionIds: [],
|
|
1427
|
+
drained
|
|
1428
|
+
};
|
|
1429
|
+
});
|
|
1430
|
+
/** Sends one event through the executed actor and attributes its transitions. */
|
|
1431
|
+
const sendExecutedStep = (engine, coverage, event) => Effect.gen(function* () {
|
|
1432
|
+
engine.send(event);
|
|
1433
|
+
yield* Effect.promise(() => engine.drain());
|
|
1434
|
+
const drained = engine.consume(coverage);
|
|
1435
|
+
const snapshot = engine.getSnapshot();
|
|
1436
|
+
const primaryIndex = drained.findIndex((entry) => entry.source === "root" && entry.event.type === event.type);
|
|
1437
|
+
const primary = primaryIndex === -1 ? void 0 : drained[primaryIndex];
|
|
1438
|
+
return {
|
|
1439
|
+
snapshot,
|
|
1440
|
+
effects: primary?.effects ?? [],
|
|
1441
|
+
transitionIds: primary?.transitionIds ?? [],
|
|
1442
|
+
drained: primaryIndex === -1 ? drained : drained.slice(primaryIndex + 1)
|
|
1443
|
+
};
|
|
1444
|
+
});
|
|
1445
|
+
/**
|
|
1446
|
+
* Drives a real actor on a `SimulatedClock` and turns its inspection
|
|
1447
|
+
* stream into the same transition details the pure path returns.
|
|
1448
|
+
*/
|
|
1449
|
+
var PropertyExecutionEngine = class {
|
|
1450
|
+
stubbedSources;
|
|
1451
|
+
clock = new SimulatedClock();
|
|
1452
|
+
actor;
|
|
1453
|
+
buffer = [];
|
|
1454
|
+
rootRef;
|
|
1455
|
+
srcByActorId = /* @__PURE__ */ new Map();
|
|
1456
|
+
resolvedBySrc = /* @__PURE__ */ new Map();
|
|
1457
|
+
/** Outcomes observed this run, in resolution order. */
|
|
1458
|
+
outcomes = [];
|
|
1459
|
+
constructor(logic, input, startingSnapshot, stubbedSources = /* @__PURE__ */ new Set()) {
|
|
1460
|
+
this.stubbedSources = stubbedSources;
|
|
1461
|
+
const actor = createActor(logic, {
|
|
1462
|
+
clock: this.clock,
|
|
1463
|
+
input,
|
|
1464
|
+
...startingSnapshot !== void 0 ? { snapshot: startingSnapshot } : {},
|
|
1465
|
+
inspect: (event) => {
|
|
1466
|
+
this.buffer.push(event);
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
this.rootRef = actor;
|
|
1470
|
+
this.actor = actor;
|
|
1471
|
+
}
|
|
1472
|
+
start() {
|
|
1473
|
+
this.actor.start();
|
|
1474
|
+
}
|
|
1475
|
+
send(event) {
|
|
1476
|
+
this.actor.send(event);
|
|
1477
|
+
}
|
|
1478
|
+
advance(milliseconds) {
|
|
1479
|
+
this.clock.increment(milliseconds);
|
|
1480
|
+
}
|
|
1481
|
+
stop() {
|
|
1482
|
+
this.actor.stop();
|
|
1483
|
+
}
|
|
1484
|
+
getSnapshot() {
|
|
1485
|
+
return this.actor.getSnapshot();
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Runs microtasks and macrotasks until the inspection stream stops growing.
|
|
1489
|
+
* Rounds alternate between `setImmediate` and a zero `setTimeout`, so work
|
|
1490
|
+
* queued on either macrotask queue (a promise actor awaiting
|
|
1491
|
+
* `setTimeout(0)`, say) settles within the step that started it. While a
|
|
1492
|
+
* child actor has asynchronous work in flight, a step settles only after
|
|
1493
|
+
* {@link QUIET_DRAIN_ROUNDS} consecutive rounds without new inspection
|
|
1494
|
+
* events; otherwise one quiet round suffices. The actor's own timers are on
|
|
1495
|
+
* the simulated clock, so nothing here can fire a delayed transition.
|
|
1496
|
+
*/
|
|
1497
|
+
drain() {
|
|
1498
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1499
|
+
const quietRounds = yield* Ref.make(0);
|
|
1500
|
+
for (const round of indexRange(MAX_DRAIN_ROUNDS)) {
|
|
1501
|
+
const seen = this.buffer.length;
|
|
1502
|
+
yield* microtaskTurns(DRAIN_MICROTASKS);
|
|
1503
|
+
yield* drainMacrotaskTurn(round);
|
|
1504
|
+
yield* Ref.update(quietRounds, (value) => this.buffer.length === seen ? value + 1 : 0);
|
|
1505
|
+
const current = yield* Ref.get(quietRounds);
|
|
1506
|
+
if (current >= QUIET_DRAIN_ROUNDS || current > 0 && this.getPendingActors().length === 0) return;
|
|
1507
|
+
}
|
|
1508
|
+
}.bind(this)));
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Direct children of the tested actor whose asynchronous work (a promise
|
|
1512
|
+
* body, for instance) is still in flight. Stubbed invoke sources are left
|
|
1513
|
+
* out: they wait for an `outcome` command by design.
|
|
1514
|
+
*/
|
|
1515
|
+
getPendingActors() {
|
|
1516
|
+
const children = this.actor.getSnapshot().children ?? {};
|
|
1517
|
+
const pending = [];
|
|
1518
|
+
for (const [id, child] of Object.entries(children)) {
|
|
1519
|
+
const src = this.srcByActorId.get(child?.id ?? id);
|
|
1520
|
+
if (src !== void 0 && this.stubbedSources.has(src)) continue;
|
|
1521
|
+
const snapshot = child?.getSnapshot?.();
|
|
1522
|
+
if (snapshot?.status !== "active" || snapshot.effects === void 0) continue;
|
|
1523
|
+
if (Object.entries(snapshot.effects).some(([key, effect]) => key !== "callback" && effect?.status === "active")) pending.push(id);
|
|
1524
|
+
}
|
|
1525
|
+
return pending.sort();
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* The milliseconds until the pending `xstate.after` timer for `event` is
|
|
1529
|
+
* due on the simulated clock, or `undefined` when no such timer is
|
|
1530
|
+
* scheduled on the tested actor.
|
|
1531
|
+
*/
|
|
1532
|
+
getAfterTimerRemaining(event) {
|
|
1533
|
+
const timers = this.actor.getSnapshot().timers ?? {};
|
|
1534
|
+
const timerId = Object.keys(timers).find((id) => {
|
|
1535
|
+
const timerEvent = timers[id]?.event;
|
|
1536
|
+
return timerEvent?.type === "xstate.after" && timerEvent.delay === event.delay && timerEvent.stateId === event.stateId;
|
|
1537
|
+
});
|
|
1538
|
+
if (timerId === void 0) return;
|
|
1539
|
+
const system = this.rootRef.system;
|
|
1540
|
+
const scheduled = Object.values(system?.getSnapshot?.()._scheduledTimers ?? {}).find((timer) => timer.source === this.rootRef && timer.id === timerId);
|
|
1541
|
+
if (scheduled === void 0) return;
|
|
1542
|
+
return Math.max(0, scheduled.dueAt - this.clock.now());
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Consumes the buffered inspection events, records root transitions into
|
|
1546
|
+
* `coverage`, and returns one {@link DrainedTransition} per transition.
|
|
1547
|
+
*/
|
|
1548
|
+
consume(coverage) {
|
|
1549
|
+
const buffered = this.buffer;
|
|
1550
|
+
this.buffer = [];
|
|
1551
|
+
const drained = [];
|
|
1552
|
+
for (const inspected of buffered) {
|
|
1553
|
+
if (inspected.type === "@xstate.actor") {
|
|
1554
|
+
if (typeof inspected.src === "string") this.srcByActorId.set(inspected.id, inspected.src);
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
if (inspected.type !== "@xstate.transition") continue;
|
|
1558
|
+
const isRoot = inspected.actorRef === this.rootRef;
|
|
1559
|
+
const actorId = inspected.actorRef?.id ?? "(unknown)";
|
|
1560
|
+
if (!isRoot) this.recordOutcome(actorId, inspected.snapshot);
|
|
1561
|
+
drained.push({
|
|
1562
|
+
source: isRoot ? "root" : "child",
|
|
1563
|
+
actorId,
|
|
1564
|
+
event: inspected.event,
|
|
1565
|
+
snapshot: inspected.snapshot,
|
|
1566
|
+
effects: inspected.actions,
|
|
1567
|
+
transitionIds: isRoot ? recordPropertyTransitions(coverage, inspected.event, inspected.microsteps) : []
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
return drained;
|
|
1571
|
+
}
|
|
1572
|
+
recordOutcome(actorId, snapshot) {
|
|
1573
|
+
if (snapshot.status !== "done" && snapshot.status !== "error") return;
|
|
1574
|
+
const src = this.srcByActorId.get(actorId);
|
|
1575
|
+
if (src === void 0) return;
|
|
1576
|
+
const occurrence = this.resolvedBySrc.get(src) ?? 0;
|
|
1577
|
+
this.resolvedBySrc.set(src, occurrence + 1);
|
|
1578
|
+
this.outcomes.push({
|
|
1579
|
+
src,
|
|
1580
|
+
occurrence,
|
|
1581
|
+
outcome: snapshot.status === "done" ? {
|
|
1582
|
+
ok: true,
|
|
1583
|
+
output: snapshot.output
|
|
1584
|
+
} : {
|
|
1585
|
+
ok: false,
|
|
1586
|
+
error: snapshot.error
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
/** @experimental */
|
|
1592
|
+
var PropertyScenarioRunner = class {
|
|
1593
|
+
logic;
|
|
1594
|
+
input;
|
|
1595
|
+
startingSnapshot;
|
|
1596
|
+
serializeStartingSnapshot;
|
|
1597
|
+
prefixEvents;
|
|
1598
|
+
frontierId;
|
|
1599
|
+
sut;
|
|
1600
|
+
states;
|
|
1601
|
+
reference;
|
|
1602
|
+
invariant;
|
|
1603
|
+
eventDescriptors;
|
|
1604
|
+
coverage;
|
|
1605
|
+
executionConfig;
|
|
1606
|
+
snapshot;
|
|
1607
|
+
initialSnapshot;
|
|
1608
|
+
initialEffects = [];
|
|
1609
|
+
initialTransitionIds = [];
|
|
1610
|
+
timeline = [];
|
|
1611
|
+
temporal;
|
|
1612
|
+
stableStep = 0;
|
|
1613
|
+
started = false;
|
|
1614
|
+
finished = false;
|
|
1615
|
+
sutSession;
|
|
1616
|
+
referenceSession;
|
|
1617
|
+
lastObservation;
|
|
1618
|
+
inconclusiveTemporalIds = [];
|
|
1619
|
+
generatedCommandCount = 0;
|
|
1620
|
+
labelsSeen = /* @__PURE__ */ new Set();
|
|
1621
|
+
execution;
|
|
1622
|
+
swarmCaseIds;
|
|
1623
|
+
swarmEnabled;
|
|
1624
|
+
targetFunction;
|
|
1625
|
+
targetObservations = [];
|
|
1626
|
+
/** Set for runs the adapter starts while shrinking a counterexample. */
|
|
1627
|
+
shrinkRun = false;
|
|
1628
|
+
/** The failure this run ended in, if an oracle failed. */
|
|
1629
|
+
failure;
|
|
1630
|
+
/** Records a label for this run. */
|
|
1631
|
+
label = (name, value) => {
|
|
1632
|
+
if (this.shrinkRun) return;
|
|
1633
|
+
recordPropertyLabel(this.coverage, name, value, this.labelsSeen);
|
|
1634
|
+
};
|
|
1635
|
+
/**
|
|
1636
|
+
* Marks this run as a shrink attempt: its labels and event cases are left
|
|
1637
|
+
* out of the campaign's statistics.
|
|
1638
|
+
*/
|
|
1639
|
+
markShrinkRun() {
|
|
1640
|
+
this.shrinkRun = true;
|
|
1641
|
+
}
|
|
1642
|
+
recordEventCase(caseId, stage) {
|
|
1643
|
+
if (!this.shrinkRun) recordPropertyEventCase(this.coverage, caseId, stage);
|
|
1644
|
+
}
|
|
1645
|
+
/** Records `name` when `condition` holds. */
|
|
1646
|
+
classify = (condition, name) => {
|
|
1647
|
+
if (condition) this.label(name);
|
|
1648
|
+
};
|
|
1649
|
+
/** Records an observation for targeted search. */
|
|
1650
|
+
target = (observation, label) => {
|
|
1651
|
+
if (typeof observation !== "number" || Number.isNaN(observation)) throw new Error("target() requires a numeric observation");
|
|
1652
|
+
this.targetObservations.push({
|
|
1653
|
+
value: observation,
|
|
1654
|
+
label,
|
|
1655
|
+
index: this.timeline.length
|
|
1656
|
+
});
|
|
1657
|
+
};
|
|
1658
|
+
/**
|
|
1659
|
+
* Restricts this run to a subset of the declared event cases. Every other
|
|
1660
|
+
* case reports itself as inapplicable, so a run only exercises the enabled
|
|
1661
|
+
* ones. See the `swarm` option.
|
|
1662
|
+
*/
|
|
1663
|
+
setSwarm(caseIds) {
|
|
1664
|
+
this.swarmCaseIds = caseIds;
|
|
1665
|
+
this.swarmEnabled = new Set(caseIds);
|
|
1666
|
+
}
|
|
1667
|
+
formatOptions;
|
|
1668
|
+
/** Sets how failure messages render snapshots. See `formatSnapshot`. */
|
|
1669
|
+
setFormatSnapshot(formatSnapshot) {
|
|
1670
|
+
this.formatOptions = formatSnapshot !== void 0 ? { formatSnapshot } : void 0;
|
|
1671
|
+
}
|
|
1672
|
+
/** Evaluates `target` on every stable step. See the `target` option. */
|
|
1673
|
+
setTargetFunction(targetFunction) {
|
|
1674
|
+
this.targetFunction = targetFunction;
|
|
1675
|
+
}
|
|
1676
|
+
/** Outcomes observed while the executed run was alive. */
|
|
1677
|
+
executionOutcomes;
|
|
1678
|
+
/** Observations recorded with `target()` during this run. */
|
|
1679
|
+
getTargetObservations() {
|
|
1680
|
+
return this.targetObservations;
|
|
1681
|
+
}
|
|
1682
|
+
/** `true` when the run completed without a property failure. */
|
|
1683
|
+
isFinished() {
|
|
1684
|
+
return this.finished;
|
|
1685
|
+
}
|
|
1686
|
+
constructor(logic, input, startingSnapshot, serializeStartingSnapshot, prefixEvents, frontierId, sut, states, reference, invariant, temporal, eventDescriptors, coverage, executionConfig) {
|
|
1687
|
+
this.logic = logic;
|
|
1688
|
+
this.input = input;
|
|
1689
|
+
this.startingSnapshot = startingSnapshot;
|
|
1690
|
+
this.serializeStartingSnapshot = serializeStartingSnapshot;
|
|
1691
|
+
this.prefixEvents = prefixEvents;
|
|
1692
|
+
this.frontierId = frontierId;
|
|
1693
|
+
this.sut = sut;
|
|
1694
|
+
this.states = states;
|
|
1695
|
+
this.reference = reference;
|
|
1696
|
+
this.invariant = invariant;
|
|
1697
|
+
this.eventDescriptors = eventDescriptors;
|
|
1698
|
+
this.coverage = coverage;
|
|
1699
|
+
this.executionConfig = executionConfig;
|
|
1700
|
+
this.temporal = temporal.map((definition) => ({
|
|
1701
|
+
definition,
|
|
1702
|
+
satisfied: false
|
|
1703
|
+
}));
|
|
1704
|
+
}
|
|
1705
|
+
start() {
|
|
1706
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1707
|
+
resetPropertyTransitionPairs(this.coverage);
|
|
1708
|
+
const [initial, effects, selected] = this.startingSnapshot !== void 0 ? [
|
|
1709
|
+
this.startingSnapshot,
|
|
1710
|
+
[],
|
|
1711
|
+
[]
|
|
1712
|
+
] : initialTransitionWithDetails(this.logic, this.input);
|
|
1713
|
+
const snapshot = initial;
|
|
1714
|
+
this.initialTransitionIds = recordPropertyTransitions(this.coverage, { type: XSTATE_INIT }, selected);
|
|
1715
|
+
const initialSnapshot = yield* Ref.make(snapshot);
|
|
1716
|
+
const initialEffects = yield* Ref.make(effects);
|
|
1717
|
+
const executionConfig = this.executionConfig;
|
|
1718
|
+
if (executionConfig !== void 0 && executionConfig.mode === "executed") {
|
|
1719
|
+
executionConfig.registry.reset();
|
|
1720
|
+
executionConfig.registry.seed(executionConfig.seededOutcomes ?? []);
|
|
1721
|
+
const execution = new PropertyExecutionEngine(this.logic, this.input, this.startingSnapshot, new Set(executionConfig.stubbedSources ?? []));
|
|
1722
|
+
this.execution = execution;
|
|
1723
|
+
execution.start();
|
|
1724
|
+
yield* Effect.promise(() => execution.drain());
|
|
1725
|
+
execution.consume(this.coverage);
|
|
1726
|
+
yield* Ref.set(initialSnapshot, execution.getSnapshot());
|
|
1727
|
+
yield* Ref.set(initialEffects, []);
|
|
1728
|
+
}
|
|
1729
|
+
const finalSnapshot = yield* Ref.get(initialSnapshot);
|
|
1730
|
+
const finalEffects = yield* Ref.get(initialEffects);
|
|
1731
|
+
this.snapshot = finalSnapshot;
|
|
1732
|
+
this.initialSnapshot = finalSnapshot;
|
|
1733
|
+
this.initialEffects = finalEffects;
|
|
1734
|
+
this.started = true;
|
|
1735
|
+
this.recordSnapshot(finalSnapshot);
|
|
1736
|
+
const context = {
|
|
1737
|
+
logic: this.logic,
|
|
1738
|
+
input: this.input,
|
|
1739
|
+
snapshot: this.startingSnapshot,
|
|
1740
|
+
label: this.label,
|
|
1741
|
+
classify: this.classify,
|
|
1742
|
+
target: this.target
|
|
1743
|
+
};
|
|
1744
|
+
const reference = this.reference;
|
|
1745
|
+
if (reference !== void 0) this.referenceSession = yield* Effect.promise(() => Promise.resolve(reference.create(context)));
|
|
1746
|
+
const sut = this.sut;
|
|
1747
|
+
if (sut !== void 0) this.sutSession = yield* Effect.promise(() => Promise.resolve(sut.create(context)));
|
|
1748
|
+
yield* this.checkStable(void 0, finalSnapshot, finalSnapshot, finalEffects);
|
|
1749
|
+
for (const event of this.prefixEvents) yield* this.executeEvent(event, "prefix", "frontier", true);
|
|
1750
|
+
if (this.frontierId !== void 0 && this.frontierId.length > 0) incrementCoverage(this.coverage.frontiers, this.frontierId);
|
|
1751
|
+
}.bind(this)));
|
|
1752
|
+
}
|
|
1753
|
+
canRun(event, caseId) {
|
|
1754
|
+
return this.canRunResolved(event, caseId);
|
|
1755
|
+
}
|
|
1756
|
+
canRunGenerated(type, generated, caseId) {
|
|
1757
|
+
return this.canRunResolved(this.resolveGeneratedEvent(type, generated, caseId), caseId);
|
|
1758
|
+
}
|
|
1759
|
+
canRunResolved(event, caseId) {
|
|
1760
|
+
this.recordEventCase(caseId, "generated");
|
|
1761
|
+
const descriptor = this.eventDescriptors.get(caseId);
|
|
1762
|
+
const canRun = (this.swarmEnabled?.has(caseId) ?? true) && event !== void 0 && this.snapshot.status === "active" && (descriptor?.when?.({
|
|
1763
|
+
snapshot: this.snapshot,
|
|
1764
|
+
event
|
|
1765
|
+
}) ?? true);
|
|
1766
|
+
if (!canRun) {
|
|
1767
|
+
this.coverage.skipped++;
|
|
1768
|
+
this.recordEventCase(caseId, "ignored");
|
|
1769
|
+
} else this.recordEventCase(caseId, "applicable");
|
|
1770
|
+
return canRun;
|
|
1771
|
+
}
|
|
1772
|
+
resolveGeneratedEvent(type, generated, caseId) {
|
|
1773
|
+
const descriptor = this.eventDescriptors.get(caseId);
|
|
1774
|
+
const payload = descriptor !== void 0 && "resolve" in descriptor && descriptor.resolve !== void 0 ? descriptor.resolve({
|
|
1775
|
+
snapshot: this.snapshot,
|
|
1776
|
+
generated
|
|
1777
|
+
}) : generated;
|
|
1778
|
+
if (payload === void 0) return;
|
|
1779
|
+
assertEventPayload(payload, type, caseId);
|
|
1780
|
+
return {
|
|
1781
|
+
...payload,
|
|
1782
|
+
type
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
/** `outcome` commands only apply while the executed actor is running. */
|
|
1786
|
+
canRunOutcome() {
|
|
1787
|
+
return this.canRunCommand(this.execution !== void 0 && this.snapshot.status === "active");
|
|
1788
|
+
}
|
|
1789
|
+
canRunCommand(applicable) {
|
|
1790
|
+
if (!applicable) this.coverage.skipped++;
|
|
1791
|
+
return applicable;
|
|
1792
|
+
}
|
|
1793
|
+
run(event, caseId) {
|
|
1794
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1795
|
+
this.assertStarted();
|
|
1796
|
+
this.recordGeneratedCommand();
|
|
1797
|
+
this.recordEventCase(caseId, "executed");
|
|
1798
|
+
yield* this.executeEvent(event, "generated", "generator", true, true, caseId);
|
|
1799
|
+
}.bind(this)));
|
|
1800
|
+
}
|
|
1801
|
+
runGenerated(type, generated, caseId) {
|
|
1802
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1803
|
+
const event = this.resolveGeneratedEvent(type, generated, caseId);
|
|
1804
|
+
if (event === void 0) throw new Error(`Property event case ${caseId} became inapplicable before execution`);
|
|
1805
|
+
yield* Effect.promise(() => this.run(event, caseId));
|
|
1806
|
+
}.bind(this)));
|
|
1807
|
+
}
|
|
1808
|
+
replay(command) {
|
|
1809
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1810
|
+
this.assertStarted();
|
|
1811
|
+
if (command.type === "event") yield* this.executeEvent(command.event, command.phase, command.origin, command.origin !== "clock", true, command.caseId);
|
|
1812
|
+
else if (command.type === "advance") {
|
|
1813
|
+
if (this.execution !== void 0) {
|
|
1814
|
+
yield* Effect.promise(() => this.advanceExecuted(command.milliseconds));
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
this.coverage.clockAdvances++;
|
|
1818
|
+
this.timeline.push({
|
|
1819
|
+
kind: "command",
|
|
1820
|
+
index: this.timeline.length,
|
|
1821
|
+
command,
|
|
1822
|
+
previousSnapshot: this.snapshot,
|
|
1823
|
+
snapshot: this.snapshot,
|
|
1824
|
+
effects: [],
|
|
1825
|
+
transitionIds: []
|
|
1826
|
+
});
|
|
1827
|
+
} else if (command.type === "outcome") {
|
|
1828
|
+
if (this.execution === void 0) throw new Error(`Property replay fixture contains an \`outcome\` command for "${command.src}" but the replay is running in pure mode: the fixture was recorded in executed mode; pass mode: 'executed'`);
|
|
1829
|
+
yield* Effect.promise(() => this.outcome(command.src, command.outcome));
|
|
1830
|
+
} else if (command.type === "checkpoint") yield* Effect.promise(() => this.checkpoint(command.label));
|
|
1831
|
+
else yield* Effect.promise(() => this.stop());
|
|
1832
|
+
}.bind(this)));
|
|
1833
|
+
}
|
|
1834
|
+
/** Queues the next resolution of the stubbed invoke source `src`. */
|
|
1835
|
+
outcome(src, outcome) {
|
|
1836
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1837
|
+
this.assertStarted();
|
|
1838
|
+
this.recordGeneratedCommand();
|
|
1839
|
+
const execution = this.execution;
|
|
1840
|
+
const executionConfig = this.executionConfig;
|
|
1841
|
+
if (execution === void 0 || executionConfig === void 0) throw new Error("Property `outcome` commands require `mode: 'executed'`");
|
|
1842
|
+
const previousSnapshot = this.snapshot;
|
|
1843
|
+
executionConfig.registry.provide(src, outcome);
|
|
1844
|
+
yield* Effect.promise(() => execution.drain());
|
|
1845
|
+
const drained = execution.consume(this.coverage);
|
|
1846
|
+
this.snapshot = execution.getSnapshot();
|
|
1847
|
+
this.recordSnapshot(this.snapshot);
|
|
1848
|
+
const entry = {
|
|
1849
|
+
kind: "command",
|
|
1850
|
+
index: this.timeline.length,
|
|
1851
|
+
command: {
|
|
1852
|
+
type: "outcome",
|
|
1853
|
+
src,
|
|
1854
|
+
outcome
|
|
1855
|
+
},
|
|
1856
|
+
previousSnapshot,
|
|
1857
|
+
snapshot: this.snapshot,
|
|
1858
|
+
effects: [],
|
|
1859
|
+
transitionIds: []
|
|
1860
|
+
};
|
|
1861
|
+
this.timeline.push(entry);
|
|
1862
|
+
this.markPendingActors(entry);
|
|
1863
|
+
this.pushActorEntries(previousSnapshot, drained);
|
|
1864
|
+
const observation = yield* this.checkStable(void 0, previousSnapshot, this.snapshot, []);
|
|
1865
|
+
setEntryObservation(entry, observation);
|
|
1866
|
+
}.bind(this)));
|
|
1867
|
+
}
|
|
1868
|
+
advance(milliseconds) {
|
|
1869
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1870
|
+
this.assertStarted();
|
|
1871
|
+
this.recordGeneratedCommand();
|
|
1872
|
+
if (this.execution !== void 0) {
|
|
1873
|
+
yield* Effect.promise(() => this.advanceExecuted(milliseconds));
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
const sutSession = this.sutSession;
|
|
1877
|
+
if (sutSession?.advance === void 0) {
|
|
1878
|
+
const advancedFrom = this.snapshot;
|
|
1879
|
+
this.coverage.clockAdvances++;
|
|
1880
|
+
const pureEntry = {
|
|
1881
|
+
kind: "command",
|
|
1882
|
+
index: this.timeline.length,
|
|
1883
|
+
command: {
|
|
1884
|
+
type: "advance",
|
|
1885
|
+
milliseconds,
|
|
1886
|
+
deliveredEvents: []
|
|
1887
|
+
},
|
|
1888
|
+
previousSnapshot: advancedFrom,
|
|
1889
|
+
snapshot: this.snapshot,
|
|
1890
|
+
effects: [],
|
|
1891
|
+
transitionIds: []
|
|
1892
|
+
};
|
|
1893
|
+
this.timeline.push(pureEntry);
|
|
1894
|
+
const pureObservation = yield* this.checkStable(void 0, advancedFrom, this.snapshot, []);
|
|
1895
|
+
setEntryObservation(pureEntry, pureObservation);
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
const previousSnapshot = this.snapshot;
|
|
1899
|
+
const advance = sutSession.advance.bind(sutSession);
|
|
1900
|
+
const events = yield* Effect.promise(() => Promise.resolve(advance(milliseconds)));
|
|
1901
|
+
const command = {
|
|
1902
|
+
type: "advance",
|
|
1903
|
+
milliseconds,
|
|
1904
|
+
deliveredEvents: events.slice()
|
|
1905
|
+
};
|
|
1906
|
+
this.coverage.clockAdvances++;
|
|
1907
|
+
this.timeline.push({
|
|
1908
|
+
kind: "command",
|
|
1909
|
+
index: this.timeline.length,
|
|
1910
|
+
command,
|
|
1911
|
+
previousSnapshot,
|
|
1912
|
+
snapshot: this.snapshot,
|
|
1913
|
+
effects: [],
|
|
1914
|
+
transitionIds: []
|
|
1915
|
+
});
|
|
1916
|
+
for (const [index, event] of events.entries()) yield* this.executeEvent(event, "generated", "clock", false, index === events.length - 1);
|
|
1917
|
+
if (events.length === 0) {
|
|
1918
|
+
const observation = yield* this.compareObservations();
|
|
1919
|
+
this.lastObservation = observation;
|
|
1920
|
+
this.replaceLastObservation(observation);
|
|
1921
|
+
}
|
|
1922
|
+
}.bind(this)));
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Advances the simulated clock the executed actor runs on, then drains
|
|
1926
|
+
* whatever the elapsed delays produced.
|
|
1927
|
+
*/
|
|
1928
|
+
advanceExecuted(milliseconds) {
|
|
1929
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1930
|
+
const execution = this.execution;
|
|
1931
|
+
if (execution === void 0) return;
|
|
1932
|
+
const previousSnapshot = this.snapshot;
|
|
1933
|
+
execution.advance(milliseconds);
|
|
1934
|
+
yield* Effect.promise(() => execution.drain());
|
|
1935
|
+
const drained = execution.consume(this.coverage);
|
|
1936
|
+
this.snapshot = execution.getSnapshot();
|
|
1937
|
+
this.recordSnapshot(this.snapshot);
|
|
1938
|
+
this.coverage.clockAdvances++;
|
|
1939
|
+
const entry = {
|
|
1940
|
+
kind: "command",
|
|
1941
|
+
index: this.timeline.length,
|
|
1942
|
+
command: {
|
|
1943
|
+
type: "advance",
|
|
1944
|
+
milliseconds,
|
|
1945
|
+
deliveredEvents: []
|
|
1946
|
+
},
|
|
1947
|
+
previousSnapshot,
|
|
1948
|
+
snapshot: this.snapshot,
|
|
1949
|
+
effects: [],
|
|
1950
|
+
transitionIds: []
|
|
1951
|
+
};
|
|
1952
|
+
this.timeline.push(entry);
|
|
1953
|
+
this.markPendingActors(entry);
|
|
1954
|
+
this.pushActorEntries(previousSnapshot, drained);
|
|
1955
|
+
const observation = yield* this.checkStable(void 0, previousSnapshot, this.snapshot, []);
|
|
1956
|
+
setEntryObservation(entry, observation);
|
|
1957
|
+
}.bind(this)));
|
|
1958
|
+
}
|
|
1959
|
+
checkpoint(label) {
|
|
1960
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1961
|
+
this.assertStarted();
|
|
1962
|
+
this.recordGeneratedCommand();
|
|
1963
|
+
const entry = {
|
|
1964
|
+
kind: "command",
|
|
1965
|
+
index: this.timeline.length,
|
|
1966
|
+
command: {
|
|
1967
|
+
type: "checkpoint",
|
|
1968
|
+
label
|
|
1969
|
+
},
|
|
1970
|
+
previousSnapshot: this.snapshot,
|
|
1971
|
+
snapshot: this.snapshot,
|
|
1972
|
+
effects: [],
|
|
1973
|
+
transitionIds: []
|
|
1974
|
+
};
|
|
1975
|
+
this.timeline.push(entry);
|
|
1976
|
+
yield* Effect.promise(() => Promise.resolve(this.sutSession?.checkpoint?.(label)));
|
|
1977
|
+
this.coverage.checkpoints++;
|
|
1978
|
+
const observation = yield* this.compareObservations();
|
|
1979
|
+
this.lastObservation = observation;
|
|
1980
|
+
setEntryObservation(entry, observation);
|
|
1981
|
+
}.bind(this)));
|
|
1982
|
+
}
|
|
1983
|
+
stop() {
|
|
1984
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
1985
|
+
this.assertStarted();
|
|
1986
|
+
this.recordGeneratedCommand();
|
|
1987
|
+
const previousSnapshot = this.snapshot;
|
|
1988
|
+
const execution = this.execution;
|
|
1989
|
+
const stopEvent = { type: XSTATE_STOP };
|
|
1990
|
+
const { snapshot, effects, transitionIds, drained } = yield* execution !== void 0 ? stopExecutedStep(execution, this.coverage) : Effect.succeed(pureTransitionStep(this.logic, this.coverage, previousSnapshot, stopEvent));
|
|
1991
|
+
this.snapshot = snapshot;
|
|
1992
|
+
yield* Effect.promise(() => Promise.resolve(this.referenceSession?.stop?.()));
|
|
1993
|
+
yield* Effect.promise(() => Promise.resolve(this.sutSession?.stop?.()));
|
|
1994
|
+
this.coverage.stops++;
|
|
1995
|
+
this.coverage.steps++;
|
|
1996
|
+
this.coverage.generatedSteps++;
|
|
1997
|
+
this.recordSnapshot(snapshot);
|
|
1998
|
+
const entry = {
|
|
1999
|
+
kind: "command",
|
|
2000
|
+
index: this.timeline.length,
|
|
2001
|
+
command: { type: "stop" },
|
|
2002
|
+
previousSnapshot,
|
|
2003
|
+
snapshot,
|
|
2004
|
+
effects,
|
|
2005
|
+
transitionIds
|
|
2006
|
+
};
|
|
2007
|
+
this.timeline.push(entry);
|
|
2008
|
+
this.markPendingActors(entry);
|
|
2009
|
+
this.pushActorEntries(previousSnapshot, drained);
|
|
2010
|
+
const observation = yield* this.checkStable(void 0, previousSnapshot, snapshot, effects);
|
|
2011
|
+
setEntryObservation(entry, observation);
|
|
2012
|
+
}.bind(this)));
|
|
2013
|
+
}
|
|
2014
|
+
finish() {
|
|
2015
|
+
this.assertStarted();
|
|
2016
|
+
if (this.finished) return;
|
|
2017
|
+
for (const state of this.temporal) {
|
|
2018
|
+
if (state.satisfied) continue;
|
|
2019
|
+
const definition = state.definition;
|
|
2020
|
+
if (definition.type === "sometimes") {
|
|
2021
|
+
recordPropertyTemporal(this.coverage, definition.id, "inconclusive");
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
if (definition.type === "respond" && state.pendingSince === void 0) {
|
|
2025
|
+
recordPropertyTemporal(this.coverage, definition.id, "satisfied");
|
|
2026
|
+
continue;
|
|
2027
|
+
}
|
|
2028
|
+
if (definition.type !== "eventually" && definition.type !== "until" && definition.type !== "respond") {
|
|
2029
|
+
recordPropertyTemporal(this.coverage, definition.id, "satisfied");
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
if (definition.within !== void 0) {
|
|
2033
|
+
recordPropertyTemporal(this.coverage, definition.id, "inconclusive");
|
|
2034
|
+
this.inconclusiveTemporalIds.push(definition.id);
|
|
2035
|
+
continue;
|
|
2036
|
+
}
|
|
2037
|
+
this.failTemporal(definition);
|
|
2038
|
+
}
|
|
2039
|
+
this.finished = true;
|
|
2040
|
+
}
|
|
2041
|
+
/** Bounded temporal properties that the run ended before deciding. */
|
|
2042
|
+
getInconclusiveTemporalIds() {
|
|
2043
|
+
return this.inconclusiveTemporalIds.slice();
|
|
2044
|
+
}
|
|
2045
|
+
/** The number of stable steps observed so far. */
|
|
2046
|
+
getStableStep() {
|
|
2047
|
+
return this.stableStep;
|
|
2048
|
+
}
|
|
2049
|
+
dispose() {
|
|
2050
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
2051
|
+
const errors = [];
|
|
2052
|
+
const execution = this.execution;
|
|
2053
|
+
if (execution !== void 0) {
|
|
2054
|
+
this.executionOutcomes = execution.outcomes.slice();
|
|
2055
|
+
const stopExit = yield* Effect.exit(Effect.sync(() => execution.stop()));
|
|
2056
|
+
if (Exit.isFailure(stopExit)) errors.push(Cause.squash(stopExit.cause));
|
|
2057
|
+
this.execution = void 0;
|
|
2058
|
+
}
|
|
2059
|
+
const disposeContext = {
|
|
2060
|
+
passed: this.finished,
|
|
2061
|
+
...this.failure !== void 0 ? { failure: this.failure } : {}
|
|
2062
|
+
};
|
|
2063
|
+
for (const dispose of [this.sutSession?.dispose, this.referenceSession?.dispose]) {
|
|
2064
|
+
if (dispose === void 0) continue;
|
|
2065
|
+
const disposeExit = yield* Effect.exit(Effect.promise(() => Promise.resolve(dispose(disposeContext))));
|
|
2066
|
+
if (Exit.isFailure(disposeExit)) errors.push(Cause.squash(disposeExit.cause));
|
|
2067
|
+
}
|
|
2068
|
+
this.sutSession = void 0;
|
|
2069
|
+
this.referenceSession = void 0;
|
|
2070
|
+
if (errors.length !== 0) throw new AggregateError(errors, "Property scenario disposal failed");
|
|
2071
|
+
}.bind(this)));
|
|
2072
|
+
}
|
|
2073
|
+
getSnapshot() {
|
|
2074
|
+
return this.snapshot;
|
|
2075
|
+
}
|
|
2076
|
+
getTrace() {
|
|
2077
|
+
const steps = this.timeline.filter((entry) => entry.kind === "event").map((entry) => ({
|
|
2078
|
+
...entry,
|
|
2079
|
+
phase: entry.command.phase,
|
|
2080
|
+
event: entry.command.event
|
|
2081
|
+
}));
|
|
2082
|
+
return {
|
|
2083
|
+
start: this.startingSnapshot !== void 0 ? {
|
|
2084
|
+
type: "snapshot",
|
|
2085
|
+
snapshot: this.startingSnapshot
|
|
2086
|
+
} : {
|
|
2087
|
+
type: "input",
|
|
2088
|
+
input: this.input
|
|
2089
|
+
},
|
|
2090
|
+
initialSnapshot: this.initialSnapshot,
|
|
2091
|
+
initialEffects: this.initialEffects,
|
|
2092
|
+
initialTransitionIds: this.initialTransitionIds,
|
|
2093
|
+
timeline: this.timeline.slice(),
|
|
2094
|
+
prefixEvents: steps.filter((step) => step.phase === "prefix").map((step) => step.event),
|
|
2095
|
+
events: steps.filter((step) => step.phase === "generated" && step.command.origin === "generator").map((step) => step.event),
|
|
2096
|
+
commands: this.timeline.filter((entry) => entry.kind === "command").map((entry) => entry.command),
|
|
2097
|
+
steps,
|
|
2098
|
+
finalSnapshot: this.snapshot,
|
|
2099
|
+
finalObservation: this.lastObservation,
|
|
2100
|
+
swarm: this.swarmCaseIds,
|
|
2101
|
+
...this.execution !== void 0 || this.executionOutcomes !== void 0 ? {
|
|
2102
|
+
mode: "executed",
|
|
2103
|
+
outcomes: this.execution?.outcomes.slice() ?? this.executionOutcomes
|
|
2104
|
+
} : {}
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
executeEvent(event, phase, origin, sendToSut, compare = true, caseId) {
|
|
2108
|
+
return Effect.gen(function* () {
|
|
2109
|
+
const previousSnapshot = this.snapshot;
|
|
2110
|
+
const execution = this.execution;
|
|
2111
|
+
const { snapshot, effects, transitionIds, drained } = yield* execution !== void 0 ? sendExecutedStep(execution, this.coverage, event) : Effect.succeed(pureTransitionStep(this.logic, this.coverage, previousSnapshot, event));
|
|
2112
|
+
this.snapshot = snapshot;
|
|
2113
|
+
const referenceSession = this.referenceSession;
|
|
2114
|
+
if (referenceSession !== void 0) yield* Effect.promise(() => Promise.resolve(referenceSession.transition(event)));
|
|
2115
|
+
if (sendToSut) {
|
|
2116
|
+
const parsedCase = caseId !== void 0 && caseId.length > 0 ? parsePropertyEventCaseId(caseId) : void 0;
|
|
2117
|
+
yield* Effect.promise(() => Promise.resolve(this.sutSession?.send(event, {
|
|
2118
|
+
snapshot,
|
|
2119
|
+
...caseId === void 0 ? {} : { caseId },
|
|
2120
|
+
...parsedCase !== void 0 ? { case: parsedCase } : {}
|
|
2121
|
+
})));
|
|
2122
|
+
}
|
|
2123
|
+
this.coverage.steps++;
|
|
2124
|
+
if (phase === "prefix") this.coverage.prefixSteps++;
|
|
2125
|
+
else this.coverage.generatedSteps++;
|
|
2126
|
+
this.recordSnapshot(snapshot);
|
|
2127
|
+
const entry = {
|
|
2128
|
+
kind: "event",
|
|
2129
|
+
index: this.timeline.length,
|
|
2130
|
+
command: {
|
|
2131
|
+
type: "event",
|
|
2132
|
+
event,
|
|
2133
|
+
phase,
|
|
2134
|
+
origin,
|
|
2135
|
+
caseId
|
|
2136
|
+
},
|
|
2137
|
+
previousSnapshot,
|
|
2138
|
+
snapshot,
|
|
2139
|
+
effects,
|
|
2140
|
+
transitionIds,
|
|
2141
|
+
activeStateIds: this.getActiveStateIds(snapshot)
|
|
2142
|
+
};
|
|
2143
|
+
this.timeline.push(entry);
|
|
2144
|
+
this.markPendingActors(entry);
|
|
2145
|
+
this.pushActorEntries(previousSnapshot, drained);
|
|
2146
|
+
const observation = yield* this.checkStable(event, previousSnapshot, snapshot, effects, compare);
|
|
2147
|
+
setEntryObservation(entry, observation);
|
|
2148
|
+
}.bind(this));
|
|
2149
|
+
}
|
|
2150
|
+
checkStable(event, previousSnapshot, snapshot, effects, compare = true) {
|
|
2151
|
+
return Effect.gen(function* () {
|
|
2152
|
+
const step = this.stableStep++;
|
|
2153
|
+
const observation = compare ? yield* this.compareObservations() : void 0;
|
|
2154
|
+
this.lastObservation = observation;
|
|
2155
|
+
yield* this.checkStateAssertions(snapshot, step);
|
|
2156
|
+
const check = this.sutSession?.check;
|
|
2157
|
+
if (check !== void 0) {
|
|
2158
|
+
const checkExit = yield* Effect.exit(Effect.promise(() => Promise.resolve(check())));
|
|
2159
|
+
if (Exit.isFailure(checkExit)) this.fail(`SUT check failed after ${step} step${step === 1 ? "" : "s"}`, Cause.squash(checkExit.cause), step);
|
|
2160
|
+
}
|
|
2161
|
+
const invariant = this.invariant;
|
|
2162
|
+
if (invariant !== void 0) {
|
|
2163
|
+
this.coverage.invariantChecks++;
|
|
2164
|
+
const invariantExit = yield* Effect.exit(Effect.promise(() => Promise.resolve(invariant({
|
|
2165
|
+
initialSnapshot: this.initialSnapshot,
|
|
2166
|
+
previousSnapshot,
|
|
2167
|
+
snapshot,
|
|
2168
|
+
event,
|
|
2169
|
+
effects,
|
|
2170
|
+
step,
|
|
2171
|
+
label: this.label,
|
|
2172
|
+
classify: this.classify,
|
|
2173
|
+
target: this.target
|
|
2174
|
+
}))));
|
|
2175
|
+
if (Exit.isFailure(invariantExit)) this.fail(`Property invariant failed after ${step} step${step === 1 ? "" : "s"}`, Cause.squash(invariantExit.cause), step);
|
|
2176
|
+
}
|
|
2177
|
+
if (this.targetFunction !== void 0) this.target(this.targetFunction({
|
|
2178
|
+
initialSnapshot: this.initialSnapshot,
|
|
2179
|
+
previousSnapshot,
|
|
2180
|
+
snapshot,
|
|
2181
|
+
event,
|
|
2182
|
+
effects,
|
|
2183
|
+
step,
|
|
2184
|
+
label: this.label,
|
|
2185
|
+
classify: this.classify,
|
|
2186
|
+
target: this.target
|
|
2187
|
+
}));
|
|
2188
|
+
yield* this.checkTemporal({
|
|
2189
|
+
initialSnapshot: this.initialSnapshot,
|
|
2190
|
+
previousSnapshot,
|
|
2191
|
+
snapshot,
|
|
2192
|
+
event,
|
|
2193
|
+
effects,
|
|
2194
|
+
step,
|
|
2195
|
+
label: this.label,
|
|
2196
|
+
classify: this.classify,
|
|
2197
|
+
target: this.target
|
|
2198
|
+
});
|
|
2199
|
+
return observation;
|
|
2200
|
+
}.bind(this));
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Runs the per-state assertions and any `meta.test` hooks that apply to
|
|
2204
|
+
* `snapshot`. Both entry points reach this on every stable step.
|
|
2205
|
+
*/
|
|
2206
|
+
checkStateAssertions(snapshot, step) {
|
|
2207
|
+
return Effect.gen(function* () {
|
|
2208
|
+
const states = this.sutSession?.states ?? this.states;
|
|
2209
|
+
const session = this.sutSession;
|
|
2210
|
+
const failed = (cause) => this.fail(`State assertion failed after ${step} step${step === 1 ? "" : "s"}: ${cause instanceof Error ? cause.message : String(cause)}`, cause, step);
|
|
2211
|
+
if (states !== void 0) {
|
|
2212
|
+
const keys = Object.keys(states).filter((stateKey) => stateKey !== "*" && matchesStateKey(snapshot, stateKey));
|
|
2213
|
+
if (keys.length === 0 && "*" in states) keys.push("*");
|
|
2214
|
+
for (const key of keys) {
|
|
2215
|
+
const assertion = states[key];
|
|
2216
|
+
if (assertion === void 0) continue;
|
|
2217
|
+
const assertionExit = yield* Effect.exit(Effect.promise(() => Promise.resolve(assertion(snapshot, session))));
|
|
2218
|
+
if (Exit.isFailure(assertionExit)) failed(Cause.squash(assertionExit.cause));
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
const getMeta = snapshot.getMeta;
|
|
2222
|
+
if (typeof getMeta !== "function") return;
|
|
2223
|
+
for (const meta of Object.values(getMeta.call(snapshot))) {
|
|
2224
|
+
const test = meta?.test;
|
|
2225
|
+
if (typeof test !== "function") continue;
|
|
2226
|
+
const hook = test;
|
|
2227
|
+
const testExit = yield* Effect.exit(Effect.promise(() => Promise.resolve(hook(session, snapshot))));
|
|
2228
|
+
if (Exit.isFailure(testExit)) failed(Cause.squash(testExit.cause));
|
|
2229
|
+
}
|
|
2230
|
+
}.bind(this));
|
|
2231
|
+
}
|
|
2232
|
+
checkTemporal(context) {
|
|
2233
|
+
return Effect.gen(function* () {
|
|
2234
|
+
for (const state of this.temporal) {
|
|
2235
|
+
if (state.satisfied) continue;
|
|
2236
|
+
this.coverage.temporalChecks++;
|
|
2237
|
+
const definition = state.definition;
|
|
2238
|
+
if (definition.type === "always") {
|
|
2239
|
+
if (!(yield* temporalPredicate(definition.predicate, context))) this.failTemporal(definition);
|
|
2240
|
+
continue;
|
|
2241
|
+
}
|
|
2242
|
+
if (definition.type === "never") {
|
|
2243
|
+
if (yield* temporalPredicate(definition.predicate, context)) this.failTemporal(definition);
|
|
2244
|
+
continue;
|
|
2245
|
+
}
|
|
2246
|
+
if (definition.type === "sometimes") {
|
|
2247
|
+
if (yield* temporalPredicate(definition.predicate, context)) {
|
|
2248
|
+
state.satisfied = true;
|
|
2249
|
+
recordPropertyTemporal(this.coverage, definition.id, "satisfied");
|
|
2250
|
+
}
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
if (definition.type === "respond") {
|
|
2254
|
+
const respond = yield* Effect.cached(temporalPredicate(definition.response, context));
|
|
2255
|
+
if (state.pendingSince !== void 0) {
|
|
2256
|
+
if (yield* respond) state.pendingSince = void 0;
|
|
2257
|
+
} else if ((yield* temporalPredicate(definition.trigger, context)) && !(yield* respond)) state.pendingSince = context.step;
|
|
2258
|
+
if (state.pendingSince !== void 0 && definition.within !== void 0 && context.step - state.pendingSince >= definition.within) this.failTemporal(definition);
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
if (definition.type === "eventually") state.satisfied = yield* temporalPredicate(definition.predicate, context);
|
|
2262
|
+
else if (yield* temporalPredicate(definition.until, context)) state.satisfied = true;
|
|
2263
|
+
else if (!(yield* temporalPredicate(definition.hold, context))) this.failTemporal(definition);
|
|
2264
|
+
if (state.satisfied) recordPropertyTemporal(this.coverage, definition.id, "satisfied");
|
|
2265
|
+
if (!state.satisfied && definition.within !== void 0 && context.step >= definition.within) this.failTemporal(definition);
|
|
2266
|
+
}
|
|
2267
|
+
}.bind(this));
|
|
2268
|
+
}
|
|
2269
|
+
failTemporal(definition) {
|
|
2270
|
+
recordPropertyTemporal(this.coverage, definition.id, "failed");
|
|
2271
|
+
const failure = {
|
|
2272
|
+
type: definition.type,
|
|
2273
|
+
id: definition.id,
|
|
2274
|
+
description: definition.description,
|
|
2275
|
+
within: definition.type === "eventually" || definition.type === "until" || definition.type === "respond" ? definition.within : void 0,
|
|
2276
|
+
atStep: this.stableStep - 1
|
|
2277
|
+
};
|
|
2278
|
+
this.fail(`Temporal property "${definition.id}" failed`, failure, failure.atStep, failure);
|
|
2279
|
+
}
|
|
2280
|
+
compareObservations() {
|
|
2281
|
+
return Effect.gen(function* () {
|
|
2282
|
+
const comparableModel = this.sut?.projectModel;
|
|
2283
|
+
const comparableSut = comparableModel !== void 0 && this.sutSession?.read !== void 0 ? this.sut : void 0;
|
|
2284
|
+
const referenceOracle = this.reference;
|
|
2285
|
+
if (referenceOracle === void 0 && comparableSut === void 0) {
|
|
2286
|
+
yield* Effect.promise(() => Promise.resolve(this.sutSession?.settle?.()));
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
yield* Effect.promise(() => Promise.resolve(this.sutSession?.settle?.()));
|
|
2290
|
+
const referenceRaw = yield* Effect.promise(() => Promise.resolve(this.referenceSession?.read()));
|
|
2291
|
+
const sutRaw = yield* Effect.promise(() => Promise.resolve(this.sutSession?.read?.()));
|
|
2292
|
+
const model = referenceOracle !== void 0 ? referenceOracle.projectModel(this.snapshot) : comparableModel?.(this.snapshot);
|
|
2293
|
+
const reference = referenceOracle !== void 0 ? referenceOracle.projectReference !== void 0 ? referenceOracle.projectReference(referenceRaw) : referenceRaw : void 0;
|
|
2294
|
+
const sut = comparableSut !== void 0 ? comparableSut.projectSut !== void 0 ? comparableSut.projectSut(sutRaw) : sutRaw : void 0;
|
|
2295
|
+
const sutModel = comparableSut !== void 0 ? comparableModel?.(this.snapshot) : void 0;
|
|
2296
|
+
const observation = {
|
|
2297
|
+
model,
|
|
2298
|
+
reference: referenceOracle !== void 0 ? {
|
|
2299
|
+
model,
|
|
2300
|
+
observed: reference
|
|
2301
|
+
} : void 0,
|
|
2302
|
+
sut: comparableSut !== void 0 ? {
|
|
2303
|
+
model: sutModel,
|
|
2304
|
+
observed: sut
|
|
2305
|
+
} : void 0
|
|
2306
|
+
};
|
|
2307
|
+
if (referenceOracle !== void 0) this.coverage.oracleComparisons++;
|
|
2308
|
+
if (comparableSut !== void 0) this.coverage.sutComparisons++;
|
|
2309
|
+
const referenceMatches = referenceOracle === void 0 ? true : yield* oracleMatches(referenceOracle.equivalent, model, reference);
|
|
2310
|
+
const sutMatches = comparableSut === void 0 ? true : yield* oracleMatches(comparableSut.equivalent, model, sut);
|
|
2311
|
+
if (!referenceMatches || !sutMatches) {
|
|
2312
|
+
this.lastObservation = observation;
|
|
2313
|
+
this.replaceLastObservation(observation);
|
|
2314
|
+
this.fail("Property observation diverged", {
|
|
2315
|
+
model,
|
|
2316
|
+
reference: observation.reference,
|
|
2317
|
+
sut: observation.sut,
|
|
2318
|
+
referenceMatches,
|
|
2319
|
+
sutMatches
|
|
2320
|
+
}, this.stableStep);
|
|
2321
|
+
}
|
|
2322
|
+
return observation;
|
|
2323
|
+
}.bind(this));
|
|
2324
|
+
}
|
|
2325
|
+
replaceLastObservation(observation) {
|
|
2326
|
+
const last = this.timeline.at(-1);
|
|
2327
|
+
if (last !== void 0) setEntryObservation(last, observation);
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Records, on an executed step's timeline entry, the child actors whose
|
|
2331
|
+
* asynchronous work was still in flight when the step settled.
|
|
2332
|
+
*/
|
|
2333
|
+
markPendingActors(entry) {
|
|
2334
|
+
if (this.execution === void 0) return;
|
|
2335
|
+
const pending = this.execution.getPendingActors();
|
|
2336
|
+
if (pending.length === 0) return;
|
|
2337
|
+
entry.pendingActors = pending;
|
|
2338
|
+
this.coverage.pendingActorSteps++;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Executed mode only: the milliseconds until the tested actor's pending
|
|
2342
|
+
* `xstate.after` timer for `event` is due, or `undefined` when none is
|
|
2343
|
+
* scheduled.
|
|
2344
|
+
*/
|
|
2345
|
+
getAfterTimerRemaining(event) {
|
|
2346
|
+
return this.execution?.getAfterTimerRemaining(event);
|
|
2347
|
+
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Fails the run with `summary`, recording a trace and a replay fixture the
|
|
2350
|
+
* same way an oracle failure does. Used by drivers that detect the run
|
|
2351
|
+
* departed from the sequence they planned.
|
|
2352
|
+
*/
|
|
2353
|
+
diverge(summary, cause) {
|
|
2354
|
+
this.assertStarted();
|
|
2355
|
+
this.fail(summary, cause, Math.max(0, this.stableStep - 1));
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Appends one timeline entry per transition the actor system performed on
|
|
2359
|
+
* its own during an executed step: invoked/spawned actor lifecycle events,
|
|
2360
|
+
* delayed transitions, and relayed sends.
|
|
2361
|
+
*/
|
|
2362
|
+
pushActorEntries(previousSnapshot, drained) {
|
|
2363
|
+
let previous = previousSnapshot;
|
|
2364
|
+
for (const transition of drained) {
|
|
2365
|
+
this.timeline.push({
|
|
2366
|
+
kind: "actorEvent",
|
|
2367
|
+
index: this.timeline.length,
|
|
2368
|
+
source: transition.source,
|
|
2369
|
+
actorId: transition.actorId,
|
|
2370
|
+
event: transition.event,
|
|
2371
|
+
previousSnapshot: previous,
|
|
2372
|
+
snapshot: transition.snapshot,
|
|
2373
|
+
effects: transition.effects,
|
|
2374
|
+
transitionIds: transition.transitionIds
|
|
2375
|
+
});
|
|
2376
|
+
if (transition.source === "root") previous = transition.snapshot;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
recordSnapshot(snapshot) {
|
|
2380
|
+
recordPropertySnapshot(this.coverage, snapshot);
|
|
2381
|
+
}
|
|
2382
|
+
recordGeneratedCommand() {
|
|
2383
|
+
this.generatedCommandCount++;
|
|
2384
|
+
this.coverage.maximumObservedSequenceLength = Math.max(this.coverage.maximumObservedSequenceLength, this.generatedCommandCount);
|
|
2385
|
+
}
|
|
2386
|
+
getActiveStateIds(snapshot) {
|
|
2387
|
+
return (snapshot.nodes ?? snapshot._nodes)?.map((node) => node.id) ?? [];
|
|
2388
|
+
}
|
|
2389
|
+
assertStarted() {
|
|
2390
|
+
if (!this.started) throw new Error("Property scenario runner has not been started");
|
|
2391
|
+
}
|
|
2392
|
+
fail(message, cause, failedAt, temporalFailure) {
|
|
2393
|
+
let fixture;
|
|
2394
|
+
try {
|
|
2395
|
+
fixture = this.getReplayFixture(failedAt, temporalFailure);
|
|
2396
|
+
} catch {
|
|
2397
|
+
fixture = void 0;
|
|
2398
|
+
}
|
|
2399
|
+
this.failure = new ModelTestFailure(message, this.getTrace(), cause, void 0, fixture, void 0, this.formatOptions);
|
|
2400
|
+
throw this.failure;
|
|
2401
|
+
}
|
|
2402
|
+
getReplayFixture(failedAt, temporalFailure) {
|
|
2403
|
+
const identity = this.logic;
|
|
2404
|
+
if (this.startingSnapshot !== void 0 && this.serializeStartingSnapshot === void 0) throw new Error("Property tests starting from a snapshot require serializeSnapshot to create replay fixtures");
|
|
2405
|
+
return {
|
|
2406
|
+
formatVersion: 2,
|
|
2407
|
+
machine: identity.id !== void 0 && identity.id.length > 0 || identity.version !== void 0 && identity.version.length > 0 ? {
|
|
2408
|
+
id: identity.id,
|
|
2409
|
+
version: identity.version
|
|
2410
|
+
} : void 0,
|
|
2411
|
+
start: this.startingSnapshot !== void 0 ? {
|
|
2412
|
+
type: "snapshot",
|
|
2413
|
+
snapshot: this.serializeStartingSnapshot(this.startingSnapshot)
|
|
2414
|
+
} : {
|
|
2415
|
+
type: "input",
|
|
2416
|
+
input: this.input
|
|
2417
|
+
},
|
|
2418
|
+
timeline: this.timeline.filter((entry) => entry.kind !== "actorEvent").map((entry) => ({
|
|
2419
|
+
kind: entry.kind,
|
|
2420
|
+
command: toPortableValue(entry.command)
|
|
2421
|
+
})),
|
|
2422
|
+
failedAt,
|
|
2423
|
+
temporalFailure,
|
|
2424
|
+
...this.swarmCaseIds !== void 0 ? { swarm: this.swarmCaseIds } : {},
|
|
2425
|
+
...this.execution !== void 0 ? {
|
|
2426
|
+
mode: "executed",
|
|
2427
|
+
outcomes: toPortableValue(this.execution.outcomes.slice()),
|
|
2428
|
+
...this.executionConfig?.stubbedSources !== void 0 && this.executionConfig.stubbedSources.length !== 0 ? { stubs: [...this.executionConfig.stubbedSources].sort() } : {}
|
|
2429
|
+
} : {}
|
|
2430
|
+
};
|
|
2431
|
+
}
|
|
2432
|
+
};
|
|
2433
|
+
/**
|
|
2434
|
+
* Replaces every `Error` in `value` with `{ xstate$$error: true, name,
|
|
2435
|
+
* message }`, which JSON can carry: `JSON.stringify(new Error('x'))` is `{}`.
|
|
2436
|
+
* Fixtures are portable data, so this runs on everything they record;
|
|
2437
|
+
* `replayTest()` turns the marked objects back into errors.
|
|
2438
|
+
*/
|
|
2439
|
+
function toPortableValue(value, seen = /* @__PURE__ */ new Map()) {
|
|
2440
|
+
if (value instanceof Error) return {
|
|
2441
|
+
xstate$$error: true,
|
|
2442
|
+
name: value.name,
|
|
2443
|
+
message: value.message
|
|
2444
|
+
};
|
|
2445
|
+
if (value === null || typeof value !== "object") return value;
|
|
2446
|
+
if (seen.has(value)) return seen.get(value);
|
|
2447
|
+
if (Array.isArray(value)) {
|
|
2448
|
+
const copy = [];
|
|
2449
|
+
seen.set(value, copy);
|
|
2450
|
+
for (const item of value) copy.push(toPortableValue(item, seen));
|
|
2451
|
+
return copy;
|
|
2452
|
+
}
|
|
2453
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2454
|
+
if (prototype !== Object.prototype && prototype !== null) return value;
|
|
2455
|
+
const copy = {};
|
|
2456
|
+
seen.set(value, copy);
|
|
2457
|
+
for (const [key, nested] of Object.entries(value)) copy[key] = toPortableValue(nested, seen);
|
|
2458
|
+
return copy;
|
|
2459
|
+
}
|
|
2460
|
+
/** Reverses {@link toPortableValue}'s error encoding. */
|
|
2461
|
+
function fromPortableValue(value) {
|
|
2462
|
+
if (value === null || typeof value !== "object") return value;
|
|
2463
|
+
if (Array.isArray(value)) return value.map(fromPortableValue);
|
|
2464
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2465
|
+
if (prototype !== Object.prototype && prototype !== null) return value;
|
|
2466
|
+
const record = value;
|
|
2467
|
+
if (record["xstate$$error"] === true && typeof record["message"] === "string") {
|
|
2468
|
+
const error = new Error(record["message"]);
|
|
2469
|
+
if (typeof record["name"] === "string") error.name = record["name"];
|
|
2470
|
+
return error;
|
|
2471
|
+
}
|
|
2472
|
+
return Object.fromEntries(Object.entries(record).map(([key, nested]) => [key, fromPortableValue(nested)]));
|
|
2473
|
+
}
|
|
2474
|
+
const DEFAULT_BATCH_RUNS = 25;
|
|
2475
|
+
const DEFAULT_MAX_RUNS = 100;
|
|
2476
|
+
const DEFAULT_MAX_FRONTIERS = 5;
|
|
2477
|
+
const DEFAULT_FRONTIER_SEARCH_LIMIT = 1e3;
|
|
2478
|
+
function getCoverageRatio(dimension) {
|
|
2479
|
+
const considered = dimension.covered.length + dimension.uncovered.length;
|
|
2480
|
+
return considered !== 0 ? dimension.covered.length / considered : 1;
|
|
2481
|
+
}
|
|
2482
|
+
function getEventCaseRatio(coverage) {
|
|
2483
|
+
const cases = Object.values(coverage.eventCases);
|
|
2484
|
+
if (cases.length === 0) return 1;
|
|
2485
|
+
return cases.filter((counts) => counts.executed > 0).length / cases.length;
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Evaluates a {@link TestStopCondition} against aggregated coverage.
|
|
2489
|
+
*
|
|
2490
|
+
* @experimental
|
|
2491
|
+
*/
|
|
2492
|
+
function evaluateTestStopCondition(condition, coverage, elapsedMs) {
|
|
2493
|
+
if (typeof condition === "function") return condition(coverage);
|
|
2494
|
+
const clauses = [];
|
|
2495
|
+
for (const key of [
|
|
2496
|
+
"stateNodes",
|
|
2497
|
+
"transitions",
|
|
2498
|
+
"transitionPairs",
|
|
2499
|
+
"requirements"
|
|
2500
|
+
]) {
|
|
2501
|
+
const threshold = condition[key];
|
|
2502
|
+
if (threshold !== void 0) clauses.push(getCoverageRatio(coverage[key]) >= threshold);
|
|
2503
|
+
}
|
|
2504
|
+
if (condition.eventCases !== void 0) clauses.push(getEventCaseRatio(coverage) >= condition.eventCases);
|
|
2505
|
+
if (condition.runs !== void 0) clauses.push(coverage.exploration.completedRuns >= condition.runs);
|
|
2506
|
+
if (condition.timeMs !== void 0) clauses.push(elapsedMs >= condition.timeMs);
|
|
2507
|
+
if (condition.any !== void 0 && condition.any.length !== 0) clauses.push(condition.any.some((nested) => evaluateTestStopCondition(nested, coverage, elapsedMs)));
|
|
2508
|
+
return clauses.length > 0 && clauses.every(Boolean);
|
|
2509
|
+
}
|
|
2510
|
+
function getSnapshotStateNodeIds(snapshot) {
|
|
2511
|
+
return (snapshot.nodes ?? snapshot._nodes)?.map((node) => node.id) ?? [];
|
|
2512
|
+
}
|
|
2513
|
+
function getTransitionSourceId(id) {
|
|
2514
|
+
try {
|
|
2515
|
+
const parsed = JSON.parse(id);
|
|
2516
|
+
return Array.isArray(parsed) && parsed[0] === "transition" ? String(parsed[1]) : void 0;
|
|
2517
|
+
} catch {
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Picks the shortest paths that reach the state nodes owning the most
|
|
2523
|
+
* uncovered transitions, deduplicated by target configuration.
|
|
2524
|
+
*/
|
|
2525
|
+
function selectUncoveredFrontiers(paths, coverage, maxFrontiers) {
|
|
2526
|
+
const uncoveredBySource = /* @__PURE__ */ new Map();
|
|
2527
|
+
for (const id of coverage.transitions.uncovered) {
|
|
2528
|
+
const source = getTransitionSourceId(id);
|
|
2529
|
+
if (source === void 0) continue;
|
|
2530
|
+
uncoveredBySource.set(source, (uncoveredBySource.get(source) ?? 0) + 1);
|
|
2531
|
+
}
|
|
2532
|
+
const ranked = [...uncoveredBySource].sort(([leftId, left], [rightId, right]) => right - left || leftId.localeCompare(rightId));
|
|
2533
|
+
const selected = [];
|
|
2534
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2535
|
+
for (const [sourceId] of ranked) {
|
|
2536
|
+
let best;
|
|
2537
|
+
for (const path of paths) {
|
|
2538
|
+
if (path.steps.length === 0 || !getSnapshotStateNodeIds(path.state).includes(sourceId)) continue;
|
|
2539
|
+
if (best === void 0 || path.weight < best.weight) best = path;
|
|
2540
|
+
}
|
|
2541
|
+
if (best === void 0) continue;
|
|
2542
|
+
const key = getPropertyConfigurationId(best.state);
|
|
2543
|
+
if (seen.has(key)) continue;
|
|
2544
|
+
seen.add(key);
|
|
2545
|
+
selected.push(best);
|
|
2546
|
+
if (selected.length >= maxFrontiers) break;
|
|
2547
|
+
}
|
|
2548
|
+
return selected;
|
|
2549
|
+
}
|
|
2550
|
+
function getLabelExpectationFailures(expectations, coverage) {
|
|
2551
|
+
const failures = [];
|
|
2552
|
+
for (const [name, expectation] of Object.entries(expectations)) {
|
|
2553
|
+
const label = coverage.labels[name] ?? {
|
|
2554
|
+
count: 0,
|
|
2555
|
+
values: {},
|
|
2556
|
+
share: 0
|
|
2557
|
+
};
|
|
2558
|
+
if (expectation.min !== void 0 && label.share < expectation.min) failures.push(`${name}: share ${label.share.toFixed(3)} is below ${expectation.min}`);
|
|
2559
|
+
if (expectation.minCount !== void 0 && label.count < expectation.minCount) failures.push(`${name}: count ${label.count} is below ${expectation.minCount}`);
|
|
2560
|
+
}
|
|
2561
|
+
return failures;
|
|
2562
|
+
}
|
|
2563
|
+
function getFrontierId(frontier) {
|
|
2564
|
+
return JSON.stringify([
|
|
2565
|
+
"frontier",
|
|
2566
|
+
getPropertyConfigurationId(frontier.state),
|
|
2567
|
+
frontier.steps.map((step) => step.event).filter((event) => event.type !== XSTATE_INIT)
|
|
2568
|
+
]);
|
|
2569
|
+
}
|
|
2570
|
+
/** The id the initial, frontier-less scenario records. */
|
|
2571
|
+
const INITIAL_FRONTIER_ID = JSON.stringify(["frontier", "initial"]);
|
|
2572
|
+
/** A stable key for an event prefix, so equivalent prefixes collapse. */
|
|
2573
|
+
function getEventPrefixKey(events) {
|
|
2574
|
+
return JSON.stringify(events);
|
|
2575
|
+
}
|
|
2576
|
+
/** The swarm-subset PRNG: mulberry32 on a seed offset from the campaign's. */
|
|
2577
|
+
function createSwarmRng(seed) {
|
|
2578
|
+
return createSeededRng(seed ^ 2654435769);
|
|
2579
|
+
}
|
|
2580
|
+
function finalizeExploration(coverage, accumulator) {
|
|
2581
|
+
const maximumSequenceLength = accumulator.maximumSequenceLengthUnknown ? null : accumulator.maximumSequenceLength;
|
|
2582
|
+
if (maximumSequenceLength !== null && coverage.maximumObservedSequenceLength >= maximumSequenceLength) accumulator.truncationReasons.add("maximum sequence length reached");
|
|
2583
|
+
return {
|
|
2584
|
+
strategy: accumulator.strategy,
|
|
2585
|
+
mode: accumulator.mode,
|
|
2586
|
+
configuredRuns: accumulator.configuredRunsOverride ?? (accumulator.configuredRunsUnknown ? null : accumulator.configuredRuns),
|
|
2587
|
+
stoppedBecause: accumulator.stoppedBecause,
|
|
2588
|
+
completedRuns: accumulator.completedRuns,
|
|
2589
|
+
attemptedRuns: coverage.runs,
|
|
2590
|
+
shrinkRuns: coverage.shrinkRuns,
|
|
2591
|
+
maximumSequenceLength,
|
|
2592
|
+
maximumObservedSequenceLength: coverage.maximumObservedSequenceLength,
|
|
2593
|
+
frontiers: accumulator.frontiers.slice(),
|
|
2594
|
+
seeds: accumulator.seeds.slice(),
|
|
2595
|
+
swarm: accumulator.swarmUsed ? {
|
|
2596
|
+
runs: accumulator.swarmRuns,
|
|
2597
|
+
averageEnabled: accumulator.swarmRuns !== 0 ? accumulator.swarmEnabledTotal / accumulator.swarmRuns : 0
|
|
2598
|
+
} : null,
|
|
2599
|
+
target: {
|
|
2600
|
+
best: accumulator.targetBest,
|
|
2601
|
+
label: accumulator.targetLabel,
|
|
2602
|
+
improvements: accumulator.targetImprovements
|
|
2603
|
+
},
|
|
2604
|
+
truncated: accumulator.truncationReasons.size > 0,
|
|
2605
|
+
truncationReasons: [...accumulator.truncationReasons].sort(),
|
|
2606
|
+
pendingActorSteps: coverage.pendingActorSteps
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* Warns about bounded temporal properties whose bound exceeds the longest
|
|
2611
|
+
* sequence the campaign can produce: they can never fail.
|
|
2612
|
+
*/
|
|
2613
|
+
function getVacuityWarnings(temporal, exploration) {
|
|
2614
|
+
if (exploration.maximumSequenceLength === null) return [];
|
|
2615
|
+
const longest = exploration.maximumSequenceLength + Math.max(0, ...exploration.frontiers.map((frontier) => frontier.prefixLength));
|
|
2616
|
+
const warnings = [];
|
|
2617
|
+
for (const definition of temporal) if ((definition.type === "eventually" || definition.type === "until" || definition.type === "respond") && definition.within !== void 0 && definition.within > longest) warnings.push(`${definition.type} "${definition.id}" has within ${definition.within}, but the longest sequence is ${longest} steps, so it can never fail`);
|
|
2618
|
+
return warnings;
|
|
2619
|
+
}
|
|
2620
|
+
/** Matches a `reachable` target against a snapshot. */
|
|
2621
|
+
function matchesReachableTarget(snapshot, target) {
|
|
2622
|
+
if (matchesStateKey(snapshot, target)) return true;
|
|
2623
|
+
const hasTag = snapshot.hasTag;
|
|
2624
|
+
return typeof hasTag === "function" && hasTag.call(snapshot, target);
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* The default {@link TestFailureStore} key: the machine id plus a hash of the
|
|
2628
|
+
* event cases and oracles, so campaigns that test the same machine
|
|
2629
|
+
* differently do not replay each other's fixtures.
|
|
2630
|
+
*/
|
|
2631
|
+
function getDefaultFailureKey(logic, caseIds, temporal, options) {
|
|
2632
|
+
const signature = JSON.stringify({
|
|
2633
|
+
events: [...caseIds].sort(),
|
|
2634
|
+
temporal: temporal.map(({ type, id }) => `${type}:${id}`).sort(),
|
|
2635
|
+
invariant: options.invariant !== void 0,
|
|
2636
|
+
sut: options.sut !== void 0,
|
|
2637
|
+
reference: options.reference !== void 0,
|
|
2638
|
+
states: Object.keys(options.states ?? {}).sort(),
|
|
2639
|
+
mode: options.mode ?? "pure"
|
|
2640
|
+
});
|
|
2641
|
+
const id = logic.id;
|
|
2642
|
+
return `${typeof id === "string" && id.length > 0 ? id : "machine"}-${fnv1a(signature).toString(16).padStart(8, "0")}`;
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* The value a thunk produces, or `undefined` when it throws, for the
|
|
2646
|
+
* synchronous projections that must not mask a failure.
|
|
2647
|
+
*/
|
|
2648
|
+
const tryValue = (thunk) => {
|
|
2649
|
+
try {
|
|
2650
|
+
return thunk();
|
|
2651
|
+
} catch {
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2655
|
+
/** The array a thunk produces, or `[]` when it throws. */
|
|
2656
|
+
const tryArray = (thunk) => {
|
|
2657
|
+
try {
|
|
2658
|
+
return thunk();
|
|
2659
|
+
} catch {
|
|
2660
|
+
return [];
|
|
2661
|
+
}
|
|
2662
|
+
};
|
|
2663
|
+
/** The failure with a save note appended to its extras. */
|
|
2664
|
+
const withFailureNote = (failure, note) => new ModelTestFailure(failure.summary, failure.trace, failure.cause, failure.replay, failure.fixture, failure.coverage, failure.format, {
|
|
2665
|
+
...failure.extras,
|
|
2666
|
+
notes: [...failure.extras?.notes ?? [], note]
|
|
2667
|
+
});
|
|
2668
|
+
/** Reports a campaign's statistics through the package's console logger. */
|
|
2669
|
+
const logTestStatistics = (coverage) => Effect.runSync(Effect.log(formatTestStatistics(coverage)).pipe(Effect.provide(Logger.layer([consoleLineLogger]))));
|
|
2670
|
+
/**
|
|
2671
|
+
* Disposes a runner, recording the run however its disposal settles, so a
|
|
2672
|
+
* throwing disposal still records the run before it propagates.
|
|
2673
|
+
*/
|
|
2674
|
+
const disposeRunnerWithRecord = (dispose, record) => Effect.runPromise(ensuringFinalizerWins(Effect.promise(() => Promise.resolve(dispose())), Effect.sync(record)));
|
|
2675
|
+
/** The campaign behind {@link propertyTest}, as one Effect run per call. */
|
|
2676
|
+
const propertyTestProgram = (source, options) => Effect.gen(function* () {
|
|
2677
|
+
const mode = options.mode ?? "pure";
|
|
2678
|
+
if (mode === "pure" && (options.actors !== void 0 || options.outcomes !== void 0)) throw new Error("Property `actors` and `outcomes` require `mode: 'executed'`");
|
|
2679
|
+
const outcomeRegistry = new PropertyOutcomeRegistry();
|
|
2680
|
+
const providedActors = { ...options.actors };
|
|
2681
|
+
for (const src of Object.keys(options.outcomes ?? {})) providedActors[src] = createOutcomeStub(src, outcomeRegistry);
|
|
2682
|
+
const logic = Object.keys(providedActors).length !== 0 ? provideActors(source, providedActors) : source;
|
|
2683
|
+
const { cases: events, descriptors: eventDescriptors } = normalizeEventDescriptors(options.events ?? {});
|
|
2684
|
+
const commands = [];
|
|
2685
|
+
for (const type of [
|
|
2686
|
+
"advance",
|
|
2687
|
+
"checkpoint",
|
|
2688
|
+
"stop"
|
|
2689
|
+
]) {
|
|
2690
|
+
const configured = options.commands?.[type];
|
|
2691
|
+
if (configured === void 0) continue;
|
|
2692
|
+
const descriptor = isEventDescriptorObject(configured) ? configured : { generate: configured };
|
|
2693
|
+
commands.push({
|
|
2694
|
+
type,
|
|
2695
|
+
generator: descriptor.generate,
|
|
2696
|
+
weight: assertTestWeight(descriptor.weight, `"${type}" command`)
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
for (const [src, configured] of Object.entries(options.outcomes ?? {})) {
|
|
2700
|
+
const descriptor = isEventDescriptorObject(configured) ? configured : { generate: configured };
|
|
2701
|
+
commands.push({
|
|
2702
|
+
type: "outcome",
|
|
2703
|
+
src,
|
|
2704
|
+
generator: descriptor.generate,
|
|
2705
|
+
weight: assertTestWeight(descriptor.weight, `"outcome" command for "${src}"`)
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
if (options.start !== void 0 && typeof options.start.serializeSnapshot !== "function") throw new Error("Property tests starting from a snapshot require a `start.serializeSnapshot` function");
|
|
2709
|
+
const temporal = [...options.temporal ?? [], ...(options.reachable ?? []).map((target) => ({
|
|
2710
|
+
type: "sometimes",
|
|
2711
|
+
id: `reachable:${target}`,
|
|
2712
|
+
predicate: ({ snapshot }) => matchesReachableTarget(snapshot, target)
|
|
2713
|
+
}))];
|
|
2714
|
+
const coverage = createTestCoverage(logic);
|
|
2715
|
+
for (const event of events) declarePropertyEventCase(coverage, event.caseId, event.weight);
|
|
2716
|
+
const exploration = {
|
|
2717
|
+
strategy: "property",
|
|
2718
|
+
mode,
|
|
2719
|
+
configuredRuns: 0,
|
|
2720
|
+
configuredRunsOverride: null,
|
|
2721
|
+
stoppedBecause: "budget",
|
|
2722
|
+
configuredRunsUnknown: false,
|
|
2723
|
+
completedRuns: 0,
|
|
2724
|
+
maximumSequenceLength: 0,
|
|
2725
|
+
maximumSequenceLengthUnknown: false,
|
|
2726
|
+
frontiers: [],
|
|
2727
|
+
seeds: [],
|
|
2728
|
+
swarmRuns: 0,
|
|
2729
|
+
swarmEnabledTotal: 0,
|
|
2730
|
+
swarmUsed: options.swarm !== void 0 && options.swarm !== false,
|
|
2731
|
+
targetBest: -Infinity,
|
|
2732
|
+
targetLabel: void 0,
|
|
2733
|
+
targetImprovements: 0,
|
|
2734
|
+
truncationReasons: /* @__PURE__ */ new Set()
|
|
2735
|
+
};
|
|
2736
|
+
const configuredFrontiers = options.frontiers;
|
|
2737
|
+
const autoFrontierOptions = configuredFrontiers === "auto" ? { strategy: "uncovered" } : configuredFrontiers !== void 0 && !Array.isArray(configuredFrontiers) && configuredFrontiers.strategy === "uncovered" ? configuredFrontiers : null;
|
|
2738
|
+
const targetFrontierOptions = configuredFrontiers !== void 0 && !Array.isArray(configuredFrontiers) && configuredFrontiers.strategy === "target" ? configuredFrontiers : null;
|
|
2739
|
+
const frontierOptions = Array.isArray(configuredFrontiers) ? { paths: configuredFrontiers } : configuredFrontiers !== void 0 && autoFrontierOptions === null && targetFrontierOptions === null ? configuredFrontiers : null;
|
|
2740
|
+
const frontierContexts = (frontierOptions?.paths ?? []).map((frontier, index) => ({
|
|
2741
|
+
frontier,
|
|
2742
|
+
index,
|
|
2743
|
+
id: getFrontierId(frontier)
|
|
2744
|
+
}));
|
|
2745
|
+
for (const context of frontierContexts) declarePropertyFrontier(coverage, context.id);
|
|
2746
|
+
const selectedFrontiers = frontierContexts.filter((context) => frontierOptions?.select?.(context) ?? true);
|
|
2747
|
+
const scenarios = frontierOptions !== null ? selectedFrontiers : [void 0];
|
|
2748
|
+
const swarmOptions = options.swarm !== void 0 && options.swarm !== false ? options.swarm === true ? {} : options.swarm : null;
|
|
2749
|
+
const swarmCaseIds = events.map((event) => event.caseId);
|
|
2750
|
+
const swarmMinimum = Math.max(1, Math.min(swarmCaseIds.length, swarmOptions?.minCases ?? Math.ceil(swarmCaseIds.length / 2)));
|
|
2751
|
+
const swarmSeed = swarmOptions?.seed ?? 0;
|
|
2752
|
+
/** The enabled subset for one run. Deterministic in `swarmSeed + runIndex`. */
|
|
2753
|
+
const selectSwarmCases = (runIndex) => {
|
|
2754
|
+
const rng = createSwarmRng(swarmSeed + runIndex * 625341585);
|
|
2755
|
+
const shuffled = swarmCaseIds.slice();
|
|
2756
|
+
for (let index = shuffled.length - 1; index > 0; index--) {
|
|
2757
|
+
const swapWith = Math.floor(rng() * (index + 1));
|
|
2758
|
+
const current = shuffled[index];
|
|
2759
|
+
const other = shuffled[swapWith];
|
|
2760
|
+
if (current === void 0 || other === void 0) continue;
|
|
2761
|
+
shuffled[index] = other;
|
|
2762
|
+
shuffled[swapWith] = current;
|
|
2763
|
+
}
|
|
2764
|
+
const count = swarmMinimum + Math.floor(rng() * (shuffled.length - swarmMinimum + 1));
|
|
2765
|
+
return shuffled.slice(0, count).sort();
|
|
2766
|
+
};
|
|
2767
|
+
const targetCandidates = [];
|
|
2768
|
+
const targetFrontierLimit = targetFrontierOptions?.maxFrontiers ?? DEFAULT_MAX_FRONTIERS;
|
|
2769
|
+
const failureStore = options.failures;
|
|
2770
|
+
const failureKey = failureStore !== void 0 ? failureStore.key ?? getDefaultFailureKey(logic, events.map((event) => event.caseId), temporal, options) : void 0;
|
|
2771
|
+
const complete = (result) => Effect.ignoreCause(Effect.promise(() => Promise.resolve(options.sut?.complete?.(result))));
|
|
2772
|
+
/** Saves the failure's fixture and adds `Saved: <location>` to its message. */
|
|
2773
|
+
const saveFailure = (failure) => Effect.gen(function* () {
|
|
2774
|
+
const fixture = failure.fixture;
|
|
2775
|
+
if (failureStore === void 0 || failureKey === void 0 || fixture === void 0 || fixture.start.type !== "input") return failure;
|
|
2776
|
+
const attempt = yield* Effect.exit(Effect.promise(() => Promise.resolve(failureStore.onFailure(fixture, failureKey, failure))));
|
|
2777
|
+
if (Exit.isFailure(attempt)) {
|
|
2778
|
+
const cause = Cause.squash(attempt.cause);
|
|
2779
|
+
return withFailureNote(failure, `Not saved: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
2780
|
+
}
|
|
2781
|
+
const location = attempt.value;
|
|
2782
|
+
return location === void 0 ? failure : withFailureNote(failure, `Saved: ${location}`);
|
|
2783
|
+
});
|
|
2784
|
+
const recordRun = (runner, runIndex, enabled, run) => {
|
|
2785
|
+
const passed = runner.isFinished();
|
|
2786
|
+
run?.report({
|
|
2787
|
+
passed,
|
|
2788
|
+
swarm: enabled
|
|
2789
|
+
});
|
|
2790
|
+
const trace = tryValue(() => runner.getTrace());
|
|
2791
|
+
if (trace === void 0) return;
|
|
2792
|
+
for (const observation of runner.getTargetObservations()) {
|
|
2793
|
+
if (observation.value > exploration.targetBest) {
|
|
2794
|
+
exploration.targetBest = observation.value;
|
|
2795
|
+
exploration.targetLabel = observation.label;
|
|
2796
|
+
exploration.targetImprovements++;
|
|
2797
|
+
}
|
|
2798
|
+
if (targetFrontierOptions === null) continue;
|
|
2799
|
+
const prefix = trace.timeline.slice(0, observation.index);
|
|
2800
|
+
const prefixEvents = prefix.filter((entry) => entry.kind === "event").map((entry) => entry.command.event);
|
|
2801
|
+
if (prefixEvents.length === 0) continue;
|
|
2802
|
+
const key = getEventPrefixKey(prefixEvents);
|
|
2803
|
+
if (targetCandidates.some((candidate) => candidate.key === key)) continue;
|
|
2804
|
+
const lastPrefixEntry = prefix[prefix.length - 1];
|
|
2805
|
+
if (prefix.length > 0 && lastPrefixEntry === void 0) throw new TypeError("prefix entry is missing");
|
|
2806
|
+
targetCandidates.push({
|
|
2807
|
+
value: observation.value,
|
|
2808
|
+
key,
|
|
2809
|
+
events: prefixEvents,
|
|
2810
|
+
state: lastPrefixEntry?.snapshot ?? trace.initialSnapshot
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
targetCandidates.sort((left, right) => right.value - left.value || left.events.length - right.events.length || (left.key < right.key ? -1 : 1));
|
|
2814
|
+
targetCandidates.length = Math.min(targetCandidates.length, targetFrontierLimit * 4);
|
|
2815
|
+
options.collect?.(trace, {
|
|
2816
|
+
passed,
|
|
2817
|
+
runIndex
|
|
2818
|
+
});
|
|
2819
|
+
};
|
|
2820
|
+
const runScenario = (frontierContext, runBudget, runOffset) => Effect.gen(function* () {
|
|
2821
|
+
const prefixEvents = frontierContext !== void 0 ? frontierContext.frontier.steps.map((step) => step.event).filter((event) => event.type !== XSTATE_INIT) : [];
|
|
2822
|
+
if (runBudget !== void 0 && (!Number.isInteger(runBudget) || runBudget < 1)) throw new Error("runsPerFrontier must return a positive integer");
|
|
2823
|
+
const attemptedRunsBefore = coverage.runs;
|
|
2824
|
+
const result = yield* Effect.promise(() => options.adapter.run({
|
|
2825
|
+
events,
|
|
2826
|
+
commands,
|
|
2827
|
+
runBudget,
|
|
2828
|
+
runOffset,
|
|
2829
|
+
createEvent: (type, payload) => {
|
|
2830
|
+
assertEventPayload(payload, type);
|
|
2831
|
+
return {
|
|
2832
|
+
...payload,
|
|
2833
|
+
type
|
|
2834
|
+
};
|
|
2835
|
+
},
|
|
2836
|
+
createRunner: (run) => {
|
|
2837
|
+
coverage.runs++;
|
|
2838
|
+
if (run?.shrink === true) coverage.shrinkRuns++;
|
|
2839
|
+
const runsBeforeThisOne = coverage.runs - attemptedRunsBefore - 1;
|
|
2840
|
+
const runIndex = (runOffset ?? 0) + runsBeforeThisOne;
|
|
2841
|
+
const runner = new PropertyScenarioRunner(logic, options.input, options.start?.snapshot, options.start?.serializeSnapshot, prefixEvents, frontierContext?.id, options.sut, options.states, options.reference, options.invariant, temporal, eventDescriptors, coverage, mode === "executed" ? {
|
|
2842
|
+
mode,
|
|
2843
|
+
registry: outcomeRegistry,
|
|
2844
|
+
stubbedSources: Object.keys(options.outcomes ?? {})
|
|
2845
|
+
} : void 0);
|
|
2846
|
+
if (options.target !== void 0) runner.setTargetFunction(options.target);
|
|
2847
|
+
runner.setFormatSnapshot(options.formatSnapshot);
|
|
2848
|
+
if (run?.shrink === true) runner.markShrinkRun();
|
|
2849
|
+
const enabled = swarmOptions !== null ? run?.swarm ?? selectSwarmCases(runIndex) : void 0;
|
|
2850
|
+
if (enabled !== void 0) {
|
|
2851
|
+
runner.setSwarm(enabled);
|
|
2852
|
+
exploration.swarmRuns++;
|
|
2853
|
+
exploration.swarmEnabledTotal += enabled.length;
|
|
2854
|
+
}
|
|
2855
|
+
const dispose = runner.dispose.bind(runner);
|
|
2856
|
+
runner.dispose = () => disposeRunnerWithRecord(dispose, () => {
|
|
2857
|
+
recordRun(runner, runIndex, enabled, run);
|
|
2858
|
+
});
|
|
2859
|
+
return runner;
|
|
2860
|
+
}
|
|
2861
|
+
}));
|
|
2862
|
+
const configuredRuns = result.exploration.configuredRuns;
|
|
2863
|
+
if (configuredRuns === null) exploration.configuredRunsUnknown = true;
|
|
2864
|
+
else exploration.configuredRuns += configuredRuns;
|
|
2865
|
+
exploration.completedRuns += result.runs;
|
|
2866
|
+
const maximumSequenceLength = result.exploration.maximumSequenceLength;
|
|
2867
|
+
if (maximumSequenceLength === null) exploration.maximumSequenceLengthUnknown = true;
|
|
2868
|
+
else exploration.maximumSequenceLength = Math.max(exploration.maximumSequenceLength ?? 0, maximumSequenceLength);
|
|
2869
|
+
const frontierId = frontierContext?.id ?? INITIAL_FRONTIER_ID;
|
|
2870
|
+
exploration.frontiers.push({
|
|
2871
|
+
id: frontierId,
|
|
2872
|
+
prefixLength: prefixEvents.length,
|
|
2873
|
+
runBudget: runBudget ?? null,
|
|
2874
|
+
configuredRuns,
|
|
2875
|
+
completedRuns: result.runs,
|
|
2876
|
+
attemptedRuns: coverage.runs - attemptedRunsBefore
|
|
2877
|
+
});
|
|
2878
|
+
exploration.seeds.push({
|
|
2879
|
+
frontierId,
|
|
2880
|
+
engine: result.exploration.engine,
|
|
2881
|
+
seed: result.exploration.seed,
|
|
2882
|
+
path: result.exploration.path
|
|
2883
|
+
});
|
|
2884
|
+
for (const reason of result.exploration.truncationReasons ?? []) exploration.truncationReasons.add(reason);
|
|
2885
|
+
if (result.exploration.truncated === true && (result.exploration.truncationReasons?.length ?? 0) === 0) exploration.truncationReasons.add("adapter reported truncation");
|
|
2886
|
+
if (result.error !== void 0) {
|
|
2887
|
+
exploration.stoppedBecause = "failure";
|
|
2888
|
+
if (result.error instanceof ModelTestFailure) {
|
|
2889
|
+
const failure = yield* saveFailure(new ModelTestFailure(result.error.summary, result.error.trace, result.error.cause, result.replay, result.error.fixture, snapshotCoverage(), result.error.format, result.report !== void 0 ? {
|
|
2890
|
+
...result.error.extras,
|
|
2891
|
+
report: result.report
|
|
2892
|
+
} : result.error.extras));
|
|
2893
|
+
yield* complete({
|
|
2894
|
+
passed: false,
|
|
2895
|
+
failure
|
|
2896
|
+
});
|
|
2897
|
+
throw failure;
|
|
2898
|
+
}
|
|
2899
|
+
const error = result.error instanceof Error ? result.error : new Error("Property adapter failed", { cause: result.error });
|
|
2900
|
+
yield* complete({
|
|
2901
|
+
passed: false,
|
|
2902
|
+
failure: error
|
|
2903
|
+
});
|
|
2904
|
+
throw error;
|
|
2905
|
+
}
|
|
2906
|
+
});
|
|
2907
|
+
const snapshotCoverage = () => {
|
|
2908
|
+
const bounds = finalizeExploration(coverage, exploration);
|
|
2909
|
+
coverage.temporal.warnings = getVacuityWarnings(temporal, bounds);
|
|
2910
|
+
return finalizeTestCoverage(coverage, bounds);
|
|
2911
|
+
};
|
|
2912
|
+
if (failureStore !== void 0 && failureKey !== void 0 && failureStore.replay !== false) {
|
|
2913
|
+
const store = failureStore;
|
|
2914
|
+
const key = failureKey;
|
|
2915
|
+
const storedFailures = yield* Effect.promise(() => Promise.resolve(store.load(key)));
|
|
2916
|
+
for (const stored of storedFailures) {
|
|
2917
|
+
const replayExit = yield* Effect.exit(Effect.promise(() => replayTest(logic, stored.fixture, {
|
|
2918
|
+
invariant: options.invariant,
|
|
2919
|
+
temporal,
|
|
2920
|
+
reference: options.reference,
|
|
2921
|
+
sut: options.sut,
|
|
2922
|
+
states: options.states,
|
|
2923
|
+
formatSnapshot: options.formatSnapshot,
|
|
2924
|
+
actors: options.actors
|
|
2925
|
+
})));
|
|
2926
|
+
if (Exit.isFailure(replayExit)) {
|
|
2927
|
+
const error = Cause.squash(replayExit.cause);
|
|
2928
|
+
if (error instanceof ReplayNotReproducedError) {
|
|
2929
|
+
yield* Effect.promise(() => Promise.resolve(store.remove?.(stored, key)));
|
|
2930
|
+
continue;
|
|
2931
|
+
}
|
|
2932
|
+
if (error instanceof ModelTestFailure) {
|
|
2933
|
+
const failure = new ModelTestFailure(`${error.summary} (replayed from ${stored.location ?? "the failure store"})`, error.trace, error.cause, error.replay, error.fixture, error.coverage, error.format, error.extras);
|
|
2934
|
+
yield* complete({
|
|
2935
|
+
passed: false,
|
|
2936
|
+
failure
|
|
2937
|
+
});
|
|
2938
|
+
throw failure;
|
|
2939
|
+
}
|
|
2940
|
+
throw error;
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
if (store.replay === "only") {
|
|
2944
|
+
exploration.stoppedBecause = "replay";
|
|
2945
|
+
const replayed = snapshotCoverage();
|
|
2946
|
+
yield* complete({ passed: true });
|
|
2947
|
+
return { coverage: replayed };
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
const getStaticRunBudget = (frontierContext) => frontierContext !== void 0 ? typeof frontierOptions?.runsPerFrontier === "function" ? frontierOptions.runsPerFrontier(frontierContext) : frontierOptions?.runsPerFrontier : void 0;
|
|
2951
|
+
if (options.until === void 0 && autoFrontierOptions === null && targetFrontierOptions === null) for (const frontierContext of scenarios) yield* runScenario(frontierContext, getStaticRunBudget(frontierContext), void 0);
|
|
2952
|
+
else {
|
|
2953
|
+
const maxRuns = options.maxRuns ?? DEFAULT_MAX_RUNS;
|
|
2954
|
+
const batchRuns = Math.max(1, Math.min(options.batchRuns ?? DEFAULT_BATCH_RUNS, maxRuns));
|
|
2955
|
+
exploration.configuredRunsOverride = maxRuns;
|
|
2956
|
+
const startedAt = DateTime.toEpochMillis(DateTime.nowUnsafe());
|
|
2957
|
+
const shortestPaths = yield* Ref.make(null);
|
|
2958
|
+
const getShortestPathsOnce = () => Effect.gen(function* () {
|
|
2959
|
+
const cached = yield* Ref.get(shortestPaths);
|
|
2960
|
+
if (cached !== null) return cached;
|
|
2961
|
+
const paths = tryArray(() => getShortestPaths(logic, {
|
|
2962
|
+
input: options.input,
|
|
2963
|
+
limit: autoFrontierOptions?.limit ?? DEFAULT_FRONTIER_SEARCH_LIMIT
|
|
2964
|
+
}));
|
|
2965
|
+
yield* Ref.set(shortestPaths, paths);
|
|
2966
|
+
return paths;
|
|
2967
|
+
});
|
|
2968
|
+
const nextFrontierIndex = yield* Ref.make(0);
|
|
2969
|
+
const getAutoScenarios = (budget) => Effect.gen(function* () {
|
|
2970
|
+
const paths = selectUncoveredFrontiers(yield* getShortestPathsOnce(), snapshotCoverage(), autoFrontierOptions.maxFrontiers ?? DEFAULT_MAX_FRONTIERS);
|
|
2971
|
+
if (paths.length === 0) return [[void 0, budget]];
|
|
2972
|
+
const perFrontier = autoFrontierOptions.runsPerFrontier ?? Math.max(1, Math.floor(budget / paths.length));
|
|
2973
|
+
const batch = [];
|
|
2974
|
+
for (const frontier of paths) {
|
|
2975
|
+
const id = getFrontierId(frontier);
|
|
2976
|
+
declarePropertyFrontier(coverage, id);
|
|
2977
|
+
const index = yield* Ref.getAndUpdate(nextFrontierIndex, (value) => value + 1);
|
|
2978
|
+
batch.push([{
|
|
2979
|
+
frontier,
|
|
2980
|
+
index,
|
|
2981
|
+
id
|
|
2982
|
+
}, perFrontier]);
|
|
2983
|
+
}
|
|
2984
|
+
return batch;
|
|
2985
|
+
});
|
|
2986
|
+
const nextTargetIndex = yield* Ref.make(0);
|
|
2987
|
+
const getTargetScenarios = (budget) => Effect.gen(function* () {
|
|
2988
|
+
const candidates = targetCandidates.slice(0, targetFrontierLimit);
|
|
2989
|
+
if (candidates.length === 0) return [[void 0, budget]];
|
|
2990
|
+
const perFrontier = targetFrontierOptions.runsPerFrontier ?? Math.max(1, Math.floor(budget / candidates.length));
|
|
2991
|
+
const batch = [];
|
|
2992
|
+
for (const candidate of candidates) {
|
|
2993
|
+
const frontier = {
|
|
2994
|
+
state: candidate.state,
|
|
2995
|
+
steps: candidate.events.map((event) => ({
|
|
2996
|
+
state: candidate.state,
|
|
2997
|
+
event
|
|
2998
|
+
})),
|
|
2999
|
+
weight: candidate.events.length
|
|
3000
|
+
};
|
|
3001
|
+
const id = getFrontierId(frontier);
|
|
3002
|
+
declarePropertyFrontier(coverage, id);
|
|
3003
|
+
const index = yield* Ref.getAndUpdate(nextTargetIndex, (value) => value + 1);
|
|
3004
|
+
batch.push([{
|
|
3005
|
+
frontier,
|
|
3006
|
+
index,
|
|
3007
|
+
id
|
|
3008
|
+
}, perFrontier]);
|
|
3009
|
+
}
|
|
3010
|
+
return batch;
|
|
3011
|
+
});
|
|
3012
|
+
while (exploration.completedRuns < maxRuns) {
|
|
3013
|
+
const runsBeforeBatch = exploration.completedRuns;
|
|
3014
|
+
const budget = Math.min(batchRuns, maxRuns - exploration.completedRuns);
|
|
3015
|
+
const batch = autoFrontierOptions !== null ? yield* getAutoScenarios(budget) : targetFrontierOptions !== null ? yield* getTargetScenarios(budget) : scenarios.map((frontierContext) => [frontierContext, Math.min(getStaticRunBudget(frontierContext) ?? budget, budget)]);
|
|
3016
|
+
for (const [frontierContext, scenarioBudget] of batch) {
|
|
3017
|
+
yield* runScenario(frontierContext, scenarioBudget, exploration.completedRuns);
|
|
3018
|
+
if (exploration.completedRuns >= maxRuns) break;
|
|
3019
|
+
}
|
|
3020
|
+
if (exploration.completedRuns === runsBeforeBatch) {
|
|
3021
|
+
exploration.truncationReasons.add("adapter made no progress");
|
|
3022
|
+
break;
|
|
3023
|
+
}
|
|
3024
|
+
if (options.until !== void 0 && evaluateTestStopCondition(options.until, snapshotCoverage(), DateTime.toEpochMillis(DateTime.nowUnsafe()) - startedAt)) {
|
|
3025
|
+
exploration.stoppedBecause = "until";
|
|
3026
|
+
break;
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
const finalCoverage = snapshotCoverage();
|
|
3031
|
+
const campaignFailures = [];
|
|
3032
|
+
const completedRuns = finalCoverage.exploration.completedRuns;
|
|
3033
|
+
const reachableCount = options.reachable?.length ?? 0;
|
|
3034
|
+
for (const [index, definition] of temporal.entries()) {
|
|
3035
|
+
if (definition.type !== "sometimes" || finalCoverage.temporal.satisfied.includes(definition.id)) continue;
|
|
3036
|
+
coverage.temporal.campaignFailed.add(definition.id);
|
|
3037
|
+
const reachableIndex = index - (temporal.length - reachableCount);
|
|
3038
|
+
campaignFailures.push(reachableIndex >= 0 ? `reachable "${options.reachable[reachableIndex]}" was not entered in ${completedRuns} run(s)` : `sometimes "${definition.id}" did not hold in ${completedRuns} run(s)`);
|
|
3039
|
+
}
|
|
3040
|
+
if (campaignFailures.length !== 0) {
|
|
3041
|
+
const error = new TestCampaignError(campaignFailures, snapshotCoverage());
|
|
3042
|
+
yield* complete({
|
|
3043
|
+
passed: false,
|
|
3044
|
+
failure: error
|
|
3045
|
+
});
|
|
3046
|
+
throw error;
|
|
3047
|
+
}
|
|
3048
|
+
if (options.expectLabels !== void 0) {
|
|
3049
|
+
const failures = getLabelExpectationFailures(options.expectLabels, finalCoverage);
|
|
3050
|
+
if (failures.length !== 0) {
|
|
3051
|
+
const error = /* @__PURE__ */ new Error(`Property label expectations were not met:\n${failures.map((failure) => ` - ${failure}`).join("\n")}`);
|
|
3052
|
+
error.name = "PropertyLabelExpectationError";
|
|
3053
|
+
error.coverage = finalCoverage;
|
|
3054
|
+
yield* complete({
|
|
3055
|
+
passed: false,
|
|
3056
|
+
failure: error
|
|
3057
|
+
});
|
|
3058
|
+
throw error;
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
if (typeof options.statistics === "function") {
|
|
3062
|
+
const deliverReport = options.statistics;
|
|
3063
|
+
const delivery = yield* Effect.promise(() => Promise.resolve().then(() => deliverReport(formatTestStatistics(finalCoverage))).then(() => ({ failed: false }), (error) => ({
|
|
3064
|
+
failed: true,
|
|
3065
|
+
error
|
|
3066
|
+
})));
|
|
3067
|
+
if (delivery.failed) {
|
|
3068
|
+
yield* complete({
|
|
3069
|
+
passed: false,
|
|
3070
|
+
failure: delivery.error
|
|
3071
|
+
});
|
|
3072
|
+
throw delivery.error;
|
|
3073
|
+
}
|
|
3074
|
+
} else if (options.statistics === true) logTestStatistics(finalCoverage);
|
|
3075
|
+
yield* complete({ passed: true });
|
|
3076
|
+
return { coverage: finalCoverage };
|
|
3077
|
+
});
|
|
3078
|
+
/**
|
|
3079
|
+
* Runs generated event and command sequences against the model, and the
|
|
3080
|
+
* system under test when one is given, checking every oracle on every stable
|
|
3081
|
+
* step. Resolves with the campaign's coverage and throws a
|
|
3082
|
+
* {@link ModelTestFailure} on the first failure. Requires an `adapter`;
|
|
3083
|
+
* `@xstate/test` exports a version with fast-check built in.
|
|
3084
|
+
* @experimental
|
|
3085
|
+
*/
|
|
3086
|
+
const propertyTest$1 = dual(2, function propertyTest(source, options) {
|
|
3087
|
+
return Effect.runPromise(propertyTestProgram(source, options));
|
|
3088
|
+
});
|
|
3089
|
+
/**
|
|
3090
|
+
* Replaying an `advance` command in pure mode (no executed actor, no SUT
|
|
3091
|
+
* clock) only records the command: the events the original run's clock
|
|
3092
|
+
* delivered are replayed from the `event` entries that follow it, each marked
|
|
3093
|
+
* `origin: 'clock'`. This asserts the fixture still carries those entries in
|
|
3094
|
+
* order, so a hand-edited or re-serialized fixture cannot silently drop the
|
|
3095
|
+
* delivered events and replay a different timeline.
|
|
3096
|
+
*
|
|
3097
|
+
* Only a prefix check: a run that failed part-way through a delivery batch
|
|
3098
|
+
* legitimately records fewer clock entries than `deliveredEvents`.
|
|
3099
|
+
*/
|
|
3100
|
+
function assertReplayFixtureClockEvents(fixture) {
|
|
3101
|
+
const timeline = normalizeFixtureTimeline(fixture);
|
|
3102
|
+
for (let index = 0; index < timeline.length; index++) {
|
|
3103
|
+
const entry = timeline[index];
|
|
3104
|
+
if (entry === void 0) continue;
|
|
3105
|
+
const command = entry.command;
|
|
3106
|
+
if (command.type !== "advance" || command.deliveredEvents === void 0 || command.deliveredEvents.length === 0) continue;
|
|
3107
|
+
for (let offset = 0; offset < command.deliveredEvents.length; offset++) {
|
|
3108
|
+
const next = timeline[index + 1 + offset]?.command;
|
|
3109
|
+
if (next === void 0) break;
|
|
3110
|
+
if (next.type !== "event" || next.origin !== "clock") throw new Error(`Property replay fixture is inconsistent: the \`advance\` command at entry ${index} recorded ${command.deliveredEvents.length} delivered event(s), but entry ${index + 1 + offset} is not a clock-delivered event`);
|
|
3111
|
+
if (!defaultEquivalent(next.event, command.deliveredEvents[offset])) throw new Error(`Property replay fixture is inconsistent: the \`advance\` command at entry ${index} recorded ${JSON.stringify(command.deliveredEvents[offset])} as delivered event ${offset}, but entry ${index + 1 + offset} replays ${JSON.stringify(next.event)}`);
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
function normalizeFixtureTimeline(fixture) {
|
|
3116
|
+
if (fixture.formatVersion === 2) return fixture.timeline;
|
|
3117
|
+
return [...fixture.prefixEvents.map((event) => ({
|
|
3118
|
+
kind: "event",
|
|
3119
|
+
command: {
|
|
3120
|
+
type: "event",
|
|
3121
|
+
event,
|
|
3122
|
+
phase: "prefix",
|
|
3123
|
+
origin: "frontier"
|
|
3124
|
+
}
|
|
3125
|
+
})), ...fixture.events.map((event) => ({
|
|
3126
|
+
kind: "event",
|
|
3127
|
+
command: {
|
|
3128
|
+
type: "event",
|
|
3129
|
+
event,
|
|
3130
|
+
phase: "generated",
|
|
3131
|
+
origin: "generator"
|
|
3132
|
+
}
|
|
3133
|
+
}))];
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* Thrown by `replayTest()` when a fixture recorded from a failing run
|
|
3137
|
+
* replays without reproducing that failure — the regression is fixed, or the
|
|
3138
|
+
* machine no longer behaves the way the fixture recorded.
|
|
3139
|
+
* @experimental
|
|
3140
|
+
*/
|
|
3141
|
+
const ReplayNotReproducedErrorBase = Data.TaggedError("@systemfsoftware/xstate-test/engine/propertyTest/ReplayNotReproducedError");
|
|
3142
|
+
var ReplayNotReproducedError = class extends ReplayNotReproducedErrorBase {
|
|
3143
|
+
step;
|
|
3144
|
+
name = "ReplayNotReproducedError";
|
|
3145
|
+
constructor(step) {
|
|
3146
|
+
super({ message: `Property replay did not reproduce the recorded failure at step ${step}` });
|
|
3147
|
+
this.step = step;
|
|
3148
|
+
}
|
|
3149
|
+
};
|
|
3150
|
+
/** The replay behind {@link replayTest}, as one Effect run per call. */
|
|
3151
|
+
const replayTestProgram = (source, fixture, options) => Effect.gen(function* () {
|
|
3152
|
+
const mode = options.mode ?? (fixture.formatVersion === 2 ? fixture.mode : void 0) ?? "pure";
|
|
3153
|
+
const recordedOutcomes = fixture.formatVersion === 2 ? fromPortableValue(fixture.outcomes ?? []) : [];
|
|
3154
|
+
const outcomeRegistry = new PropertyOutcomeRegistry();
|
|
3155
|
+
const commandedSrcs = new Set(normalizeFixtureTimeline(fixture).flatMap((entry) => entry.command.type === "outcome" ? [entry.command.src] : []));
|
|
3156
|
+
const seededOutcomes = recordedOutcomes.filter((record) => !commandedSrcs.has(record.src));
|
|
3157
|
+
const providedActors = { ...options.actors };
|
|
3158
|
+
const stubbedSources = [.../* @__PURE__ */ new Set([...fixture.formatVersion === 2 ? fixture.stubs ?? [] : [], ...recordedOutcomes.map((record) => record.src)])];
|
|
3159
|
+
if (mode === "executed") for (const src of stubbedSources) providedActors[src] ??= createOutcomeStub(src, outcomeRegistry);
|
|
3160
|
+
const logic = Object.keys(providedActors).length !== 0 ? provideActors(source, providedActors) : source;
|
|
3161
|
+
const identity = logic;
|
|
3162
|
+
if (fixture.machine?.id !== void 0 && fixture.machine.id.length > 0 && fixture.machine.id !== identity.id) throw new Error(`Property replay fixture targets machine "${fixture.machine.id}", received "${identity.id ?? "(anonymous)"}"`);
|
|
3163
|
+
if (fixture.machine?.version !== void 0 && fixture.machine.version.length > 0 && fixture.machine.version !== identity.version) throw new Error(`Property replay fixture targets machine version "${fixture.machine.version}", received "${identity.version ?? "(unversioned)"}"`);
|
|
3164
|
+
const startingSnapshot = fixture.start.type === "snapshot" ? options.restoreSnapshot?.(fixture.start.snapshot) : void 0;
|
|
3165
|
+
if (fixture.start.type === "snapshot" && startingSnapshot === void 0) throw new Error("Property replay fixture contains a snapshot but no restoreSnapshot function was provided");
|
|
3166
|
+
const coverage = createTestCoverage(logic);
|
|
3167
|
+
coverage.runs = 1;
|
|
3168
|
+
const serializedStartingSnapshot = fixture.start.type === "snapshot" ? fixture.start.snapshot : void 0;
|
|
3169
|
+
const runner = new PropertyScenarioRunner(logic, fixture.start.type === "input" ? fixture.start.input : void 0, startingSnapshot, fixture.start.type === "snapshot" ? () => serializedStartingSnapshot : void 0, [], void 0, options.sut, options.states, options.reference, options.invariant, options.temporal ?? [], /* @__PURE__ */ new Map(), coverage, mode === "executed" ? {
|
|
3170
|
+
mode,
|
|
3171
|
+
registry: outcomeRegistry,
|
|
3172
|
+
seededOutcomes,
|
|
3173
|
+
stubbedSources
|
|
3174
|
+
} : void 0);
|
|
3175
|
+
runner.setFormatSnapshot(options.formatSnapshot);
|
|
3176
|
+
const failedAt = fixture.failedAt;
|
|
3177
|
+
const timeline = Effect.gen(function* () {
|
|
3178
|
+
yield* Effect.promise(() => runner.start());
|
|
3179
|
+
assertReplayFixtureClockEvents(fixture);
|
|
3180
|
+
for (const entry of normalizeFixtureTimeline(fixture)) {
|
|
3181
|
+
const command = fromPortableValue(entry.command);
|
|
3182
|
+
yield* Effect.promise(() => runner.replay(command));
|
|
3183
|
+
if (failedAt !== void 0 && runner.getStableStep() > failedAt) break;
|
|
3184
|
+
}
|
|
3185
|
+
runner.finish();
|
|
3186
|
+
if (options.expect === "pass") return runner.getTrace();
|
|
3187
|
+
throw new ReplayNotReproducedError(failedAt ?? runner.getStableStep());
|
|
3188
|
+
});
|
|
3189
|
+
return yield* ensuringFinalizerWins(timeline, Effect.promise(() => runner.dispose()));
|
|
3190
|
+
});
|
|
3191
|
+
/**
|
|
3192
|
+
* Replays a {@link TestFixture} without a generator. Resolves with the
|
|
3193
|
+
* replayed trace; see the `expect` option for how failures are reported.
|
|
3194
|
+
* @experimental
|
|
3195
|
+
*/
|
|
3196
|
+
const replayTest = dual(3, function replayTest(source, fixture, options) {
|
|
3197
|
+
return Effect.runPromise(replayTestProgram(source, fixture, options));
|
|
3198
|
+
});
|
|
3199
|
+
function serializeSnapshot$1(snapshot) {
|
|
3200
|
+
return "toJSON" in snapshot && typeof snapshot.toJSON === "function" ? snapshot.toJSON() : snapshot;
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* Converts a trace to JSON-safe data, calling `toJSON()` on snapshots.
|
|
3204
|
+
*
|
|
3205
|
+
* @experimental
|
|
3206
|
+
*/
|
|
3207
|
+
function serializeTestTrace(trace) {
|
|
3208
|
+
return {
|
|
3209
|
+
start: trace.start.type === "snapshot" ? {
|
|
3210
|
+
type: "snapshot",
|
|
3211
|
+
snapshot: serializeSnapshot$1(trace.start.snapshot)
|
|
3212
|
+
} : trace.start,
|
|
3213
|
+
initialSnapshot: serializeSnapshot$1(trace.initialSnapshot),
|
|
3214
|
+
initialTransitionIds: trace.initialTransitionIds,
|
|
3215
|
+
timeline: trace.timeline.map((entry) => ({
|
|
3216
|
+
kind: entry.kind,
|
|
3217
|
+
index: entry.index,
|
|
3218
|
+
previousSnapshot: serializeSnapshot$1(entry.previousSnapshot),
|
|
3219
|
+
snapshot: serializeSnapshot$1(entry.snapshot),
|
|
3220
|
+
effects: entry.effects,
|
|
3221
|
+
transitionIds: entry.transitionIds,
|
|
3222
|
+
...entry.kind === "actorEvent" ? {
|
|
3223
|
+
source: entry.source,
|
|
3224
|
+
actorId: entry.actorId,
|
|
3225
|
+
event: entry.event
|
|
3226
|
+
} : {
|
|
3227
|
+
command: entry.command,
|
|
3228
|
+
observation: entry.observation,
|
|
3229
|
+
...entry.kind === "event" ? { activeStateIds: entry.activeStateIds } : {}
|
|
3230
|
+
}
|
|
3231
|
+
})),
|
|
3232
|
+
finalSnapshot: serializeSnapshot$1(trace.finalSnapshot),
|
|
3233
|
+
finalObservation: trace.finalObservation
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
/**
|
|
3237
|
+
* The default failure-trace projection: `{ value, context }` for machine
|
|
3238
|
+
* snapshots, plus `status`, `output`, `error`, and `tags` when they carry
|
|
3239
|
+
* information. Other snapshots are printed without their runtime bookkeeping.
|
|
3240
|
+
* @experimental
|
|
3241
|
+
*/
|
|
3242
|
+
function defaultFormatSnapshot(snapshot) {
|
|
3243
|
+
const json = serializeSnapshot$1(snapshot);
|
|
3244
|
+
if (json === void 0 || typeof json !== "object") return json;
|
|
3245
|
+
const { value, context, status, output, error, tags, _nextTimerId: _timerId, timers: _timers, children: _children, historyValue: _historyValue, sessionId: _sessionId, ...rest } = json;
|
|
3246
|
+
const hasValue = "value" in json;
|
|
3247
|
+
const projected = hasValue ? {
|
|
3248
|
+
value,
|
|
3249
|
+
context
|
|
3250
|
+
} : {
|
|
3251
|
+
...rest,
|
|
3252
|
+
...context === void 0 ? {} : { context }
|
|
3253
|
+
};
|
|
3254
|
+
if (hasValue) {
|
|
3255
|
+
if (status !== void 0 && status !== "active") projected["status"] = status;
|
|
3256
|
+
} else if (status !== void 0) projected["status"] = status;
|
|
3257
|
+
if (output !== void 0) projected["output"] = output;
|
|
3258
|
+
if (error !== void 0) projected["error"] = error;
|
|
3259
|
+
if (Array.isArray(tags) ? tags.length > 0 : tags !== void 0) projected["tags"] = tags;
|
|
3260
|
+
return projected;
|
|
3261
|
+
}
|
|
3262
|
+
function stringifyForTrace(value) {
|
|
3263
|
+
try {
|
|
3264
|
+
const json = JSON.stringify(value, (_key, nested) => nested instanceof Error ? {
|
|
3265
|
+
name: nested.name,
|
|
3266
|
+
message: nested.message
|
|
3267
|
+
} : typeof nested === "bigint" ? `${nested}n` : nested);
|
|
3268
|
+
return json === void 0 ? String(value) : json;
|
|
3269
|
+
} catch {
|
|
3270
|
+
return String(value);
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
function formatEventForTrace(event) {
|
|
3274
|
+
const { type, sessionId: _sessionId, ...payload } = event;
|
|
3275
|
+
if (type === "xstate.timer" && typeof payload["id"] === "string") return payload["id"];
|
|
3276
|
+
return Object.keys(payload).length !== 0 ? `${type} ${stringifyForTrace(payload)}` : type;
|
|
3277
|
+
}
|
|
3278
|
+
function getEventOrigin(event, fallback) {
|
|
3279
|
+
if (event.type === "xstate.after" || event.type === "xstate.timer" || event.type.startsWith("xstate.after.")) return "timer";
|
|
3280
|
+
if (event.type === "xstate.done.actor" || event.type === "xstate.error.actor" || event.type.startsWith("xstate.done.actor.") || event.type.startsWith("xstate.error.actor.")) return "outcome";
|
|
3281
|
+
return fallback;
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* Renders a trace as the human-readable text used in failure messages: one
|
|
3285
|
+
* line per step, `N. <origin> <event> -> <state>`, where `<state>` is the
|
|
3286
|
+
* snapshot projected through `formatSnapshot`. In executed mode, the events
|
|
3287
|
+
* the tested actor then processed on its own follow their step as `↳` lines;
|
|
3288
|
+
* child actors' own transitions are left out.
|
|
3289
|
+
* @experimental
|
|
3290
|
+
*/
|
|
3291
|
+
const formatTestTrace = dual((args) => args.length >= 2 || typeof args[0] === "object" && args[0] !== null && "timeline" in args[0], function formatTestTrace(trace, options = {}) {
|
|
3292
|
+
const format = (snapshot) => stringifyForTrace((options.formatSnapshot ?? defaultFormatSnapshot)(snapshot));
|
|
3293
|
+
const lines = [`start ${format(trace.initialSnapshot)}`];
|
|
3294
|
+
const pushObservation = (observation) => {
|
|
3295
|
+
for (const [name, compared] of [["reference", observation?.reference], ["sut", observation?.sut]]) {
|
|
3296
|
+
if (compared === void 0 || defaultEquivalent(compared.model, compared.observed)) continue;
|
|
3297
|
+
lines.push(` ${name} diverged`, ` model: ${stringifyForTrace(compared.model)}`, ` observed: ${stringifyForTrace(compared.observed)}`);
|
|
3298
|
+
}
|
|
3299
|
+
};
|
|
3300
|
+
let step = 0;
|
|
3301
|
+
for (const entry of trace.timeline) {
|
|
3302
|
+
if (entry.kind === "actorEvent") {
|
|
3303
|
+
if (entry.source === "root") lines.push(` ↳ ${getEventOrigin(entry.event, "actor")} ${formatEventForTrace(entry.event)} -> ${format(entry.snapshot)}`);
|
|
3304
|
+
continue;
|
|
3305
|
+
}
|
|
3306
|
+
step++;
|
|
3307
|
+
if (entry.kind === "event") {
|
|
3308
|
+
const { command } = entry;
|
|
3309
|
+
const origin = getEventOrigin(command.event, command.origin === "frontier" ? "prefix" : command.origin);
|
|
3310
|
+
lines.push(`${step}. ${origin} ${formatEventForTrace(command.event)} -> ${format(entry.snapshot)}`);
|
|
3311
|
+
} else {
|
|
3312
|
+
const { command } = entry;
|
|
3313
|
+
switch (command.type) {
|
|
3314
|
+
case "advance":
|
|
3315
|
+
lines.push(`${step}. timer advance ${command.milliseconds}ms -> ${format(entry.snapshot)}`);
|
|
3316
|
+
break;
|
|
3317
|
+
case "outcome":
|
|
3318
|
+
lines.push(`${step}. outcome ${command.src} ${stringifyForTrace(command.outcome)} -> ${format(entry.snapshot)}`);
|
|
3319
|
+
break;
|
|
3320
|
+
case "checkpoint":
|
|
3321
|
+
lines.push(`${step}. checkpoint${command.label === void 0 ? "" : ` ${command.label}`}`);
|
|
3322
|
+
break;
|
|
3323
|
+
default: lines.push(`${step}. stop -> ${format(entry.snapshot)}`);
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
if (entry.pendingActors !== void 0 && entry.pendingActors.length !== 0) lines.push(` pending actors: ${entry.pendingActors.join(", ")}`);
|
|
3327
|
+
pushObservation(entry.observation);
|
|
3328
|
+
}
|
|
3329
|
+
return lines.join("\n");
|
|
3330
|
+
});
|
|
3331
|
+
/**
|
|
3332
|
+
* The `stateMatcher` for models built from bare logic: `'*'` matches every
|
|
3333
|
+
* snapshot; on a state machine snapshot, `'#id'` matches when the state node
|
|
3334
|
+
* with that id is active and any other key matches through
|
|
3335
|
+
* `snapshot.matches(key)`. Node ids are compared rather than node objects, so
|
|
3336
|
+
* snapshots of a `machine.provide()`d copy match too.
|
|
3337
|
+
*/
|
|
3338
|
+
function matchesStateKey(snapshot, stateKey) {
|
|
3339
|
+
if (stateKey === "*") return true;
|
|
3340
|
+
const machineSnapshot = snapshot;
|
|
3341
|
+
if (stateKey.startsWith("#")) {
|
|
3342
|
+
const id = stateKey.slice(1);
|
|
3343
|
+
return machineSnapshot.nodes?.some((node) => node.id === id) ?? false;
|
|
3344
|
+
}
|
|
3345
|
+
if (typeof machineSnapshot.matches !== "function") return false;
|
|
3346
|
+
try {
|
|
3347
|
+
return machineSnapshot.matches(stateKey);
|
|
3348
|
+
} catch {
|
|
3349
|
+
return false;
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
// src/engine/suite.ts
|
|
3353
|
+
/** The transition and state node ids a single trace exercised. */
|
|
3354
|
+
function getTraceElements(trace) {
|
|
3355
|
+
const elements = /* @__PURE__ */ new Set();
|
|
3356
|
+
for (const id of trace.initialTransitionIds) elements.add(`transition:${id}`);
|
|
3357
|
+
for (const entry of trace.timeline) {
|
|
3358
|
+
for (const id of entry.transitionIds) elements.add(`transition:${id}`);
|
|
3359
|
+
if (entry.kind === "event") for (const id of entry.activeStateIds) elements.add(`stateNode:${id}`);
|
|
3360
|
+
}
|
|
3361
|
+
return [...elements].sort();
|
|
3362
|
+
}
|
|
3363
|
+
/** A stable identity for a fixture's start and timeline, used to drop duplicates. */
|
|
3364
|
+
function fixtureKey(fixture) {
|
|
3365
|
+
return JSON.stringify([fixture.start, fixture.timeline]);
|
|
3366
|
+
}
|
|
3367
|
+
function toSuiteFixture(trace, machine, serializeStartingSnapshot) {
|
|
3368
|
+
if (trace.start.type === "snapshot" && serializeStartingSnapshot === void 0) throw new Error("Property suites starting from a snapshot require start.serializeSnapshot");
|
|
3369
|
+
return {
|
|
3370
|
+
formatVersion: 2,
|
|
3371
|
+
machine,
|
|
3372
|
+
start: trace.start.type === "snapshot" ? {
|
|
3373
|
+
type: "snapshot",
|
|
3374
|
+
snapshot: serializeStartingSnapshot(trace.start.snapshot)
|
|
3375
|
+
} : trace.start,
|
|
3376
|
+
timeline: trace.timeline.flatMap((entry) => entry.kind === "event" || entry.kind === "command" ? [{
|
|
3377
|
+
kind: entry.kind,
|
|
3378
|
+
command: entry.command
|
|
3379
|
+
}] : []),
|
|
3380
|
+
...trace.swarm !== void 0 ? { swarm: trace.swarm } : {},
|
|
3381
|
+
...trace.mode !== void 0 ? { mode: trace.mode } : {},
|
|
3382
|
+
...trace.outcomes !== void 0 ? { outcomes: trace.outcomes } : {}
|
|
3383
|
+
};
|
|
3384
|
+
}
|
|
3385
|
+
/** Greedy maximum-coverage selection with deterministic tie-breaking. */
|
|
3386
|
+
function selectFixtures(candidates, maxFixtures) {
|
|
3387
|
+
const covered = /* @__PURE__ */ new Set();
|
|
3388
|
+
const remaining = candidates.slice();
|
|
3389
|
+
const selected = [];
|
|
3390
|
+
const limit = maxFixtures ?? Infinity;
|
|
3391
|
+
while (remaining.length !== 0 && selected.length < limit) {
|
|
3392
|
+
let bestIndex = -1;
|
|
3393
|
+
let bestGain = 0;
|
|
3394
|
+
for (let index = 0; index < remaining.length; index++) {
|
|
3395
|
+
const candidate = remaining[index];
|
|
3396
|
+
if (candidate === void 0) continue;
|
|
3397
|
+
const gain = candidate.elements.filter((element) => !covered.has(element)).length;
|
|
3398
|
+
if (gain > bestGain) {
|
|
3399
|
+
bestGain = gain;
|
|
3400
|
+
bestIndex = index;
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
if (bestIndex === -1) break;
|
|
3404
|
+
const [candidate] = remaining.splice(bestIndex, 1);
|
|
3405
|
+
if (candidate === void 0) break;
|
|
3406
|
+
selected.push(candidate);
|
|
3407
|
+
for (const element of candidate.elements) covered.add(element);
|
|
3408
|
+
}
|
|
3409
|
+
return selected.sort((left, right) => left.key < right.key ? -1 : 1).map((candidate) => candidate.fixture);
|
|
3410
|
+
}
|
|
3411
|
+
/**
|
|
3412
|
+
* Runs a `propertyTest()` campaign and exports the traces it explored as an
|
|
3413
|
+
* offline suite.
|
|
3414
|
+
*
|
|
3415
|
+
* Only passing runs are recorded: a campaign that finds a counterexample
|
|
3416
|
+
* throws, as `propertyTest()` does.
|
|
3417
|
+
* @experimental
|
|
3418
|
+
*/
|
|
3419
|
+
const generateTestSuite$1 = dual(2, function generateTestSuite(source, options) {
|
|
3420
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
3421
|
+
const traces = [];
|
|
3422
|
+
const { select, maxFixtures, generatedAt, collect, ...rest } = options;
|
|
3423
|
+
const { coverage } = yield* Effect.promise(() => propertyTest$1(source, {
|
|
3424
|
+
...rest,
|
|
3425
|
+
collect: (trace, info) => {
|
|
3426
|
+
collect?.(trace, info);
|
|
3427
|
+
if (info.passed) traces.push(trace);
|
|
3428
|
+
}
|
|
3429
|
+
}));
|
|
3430
|
+
const logic = source;
|
|
3431
|
+
const machine = logic.id !== void 0 && logic.id.length > 0 || logic.version !== void 0 && logic.version.length > 0 ? {
|
|
3432
|
+
id: logic.id,
|
|
3433
|
+
version: logic.version
|
|
3434
|
+
} : void 0;
|
|
3435
|
+
const serializeStartingSnapshot = options.start?.serializeSnapshot;
|
|
3436
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3437
|
+
const candidates = [];
|
|
3438
|
+
for (const trace of traces) {
|
|
3439
|
+
const fixture = toSuiteFixture(trace, machine, serializeStartingSnapshot);
|
|
3440
|
+
const key = fixtureKey(fixture);
|
|
3441
|
+
if (seen.has(key)) continue;
|
|
3442
|
+
seen.add(key);
|
|
3443
|
+
candidates.push({
|
|
3444
|
+
fixture,
|
|
3445
|
+
elements: getTraceElements(trace),
|
|
3446
|
+
key,
|
|
3447
|
+
length: fixture.timeline.length
|
|
3448
|
+
});
|
|
3449
|
+
}
|
|
3450
|
+
candidates.sort((left, right) => left.length - right.length || (left.key < right.key ? -1 : 1));
|
|
3451
|
+
const fixtures = (select ?? "minimal") === "all" ? candidates.slice(0, maxFixtures ?? candidates.length).map((candidate) => candidate.fixture) : selectFixtures(candidates, maxFixtures);
|
|
3452
|
+
return {
|
|
3453
|
+
formatVersion: 1,
|
|
3454
|
+
machineId: machine?.id,
|
|
3455
|
+
machineVersion: machine?.version,
|
|
3456
|
+
generatedAt,
|
|
3457
|
+
fixtures,
|
|
3458
|
+
coverage: testCoverageToJSON(coverage)
|
|
3459
|
+
};
|
|
3460
|
+
}));
|
|
3461
|
+
});
|
|
3462
|
+
/**
|
|
3463
|
+
* Replays one suite fixture. Resolves when the fixture still passes and
|
|
3464
|
+
* rejects with the underlying failure when it does not.
|
|
3465
|
+
* @experimental
|
|
3466
|
+
*/
|
|
3467
|
+
const replayTestSuiteFixture = dual(3, function replayTestSuiteFixture(source, fixture, options) {
|
|
3468
|
+
return Effect.runPromise(Effect.promise(() => replayTest(source, fixture, {
|
|
3469
|
+
...options,
|
|
3470
|
+
expect: "pass"
|
|
3471
|
+
})).pipe(Effect.asVoid));
|
|
3472
|
+
});
|
|
3473
|
+
/**
|
|
3474
|
+
* Replays every fixture in a suite. Each fixture is expected to pass.
|
|
3475
|
+
*
|
|
3476
|
+
* @experimental
|
|
3477
|
+
*/
|
|
3478
|
+
const replayTestSuite = dual(3, function replayTestSuite(source, suite, options) {
|
|
3479
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
3480
|
+
const passed = yield* Ref.make(0);
|
|
3481
|
+
const failed = [];
|
|
3482
|
+
for (const [index, fixture] of suite.fixtures.entries()) {
|
|
3483
|
+
const exit = yield* Effect.exit(Effect.promise(() => replayTestSuiteFixture(source, fixture, options)));
|
|
3484
|
+
if (Exit.isFailure(exit)) failed.push({
|
|
3485
|
+
fixture,
|
|
3486
|
+
index,
|
|
3487
|
+
title: formatTestSuiteFixtureTitle(fixture, index),
|
|
3488
|
+
error: Cause.squash(exit.cause)
|
|
3489
|
+
});
|
|
3490
|
+
else yield* Ref.update(passed, (value) => value + 1);
|
|
3491
|
+
}
|
|
3492
|
+
return {
|
|
3493
|
+
passed: yield* Ref.get(passed),
|
|
3494
|
+
failed
|
|
3495
|
+
};
|
|
3496
|
+
}));
|
|
3497
|
+
});
|
|
3498
|
+
/**
|
|
3499
|
+
* A stable, human-readable one-line title for a fixture.
|
|
3500
|
+
*
|
|
3501
|
+
* @experimental
|
|
3502
|
+
*/
|
|
3503
|
+
const formatTestSuiteFixtureTitle = dual(2, function formatTestSuiteFixtureTitle(fixture, index) {
|
|
3504
|
+
const steps = fixture.timeline.map((entry) => {
|
|
3505
|
+
const command = entry.command;
|
|
3506
|
+
switch (command.type) {
|
|
3507
|
+
case "event": return command.event.type;
|
|
3508
|
+
case "advance": return `@advance(${command.milliseconds})`;
|
|
3509
|
+
case "checkpoint": return "@checkpoint";
|
|
3510
|
+
case "outcome": return `@outcome(${command.src})`;
|
|
3511
|
+
default: return "@stop";
|
|
3512
|
+
}
|
|
3513
|
+
});
|
|
3514
|
+
return `fixture ${index + 1}: ${steps.join(" -> ") || "(no events)"}`;
|
|
3515
|
+
});
|
|
3516
|
+
/**
|
|
3517
|
+
* Registers one test per suite fixture with a vitest/jest-compatible
|
|
3518
|
+
* `it`/`describe` pair.
|
|
3519
|
+
* @experimental
|
|
3520
|
+
*/
|
|
3521
|
+
const describeTestSuite = dual(3, function describeTestSuite(suite, source, options) {
|
|
3522
|
+
const globals = globalThis;
|
|
3523
|
+
const it = options.it ?? globals.it;
|
|
3524
|
+
if (it === void 0) throw new Error("describeTestSuite() requires an `it` function when none is global");
|
|
3525
|
+
const register = () => {
|
|
3526
|
+
suite.fixtures.forEach((fixture, index) => {
|
|
3527
|
+
it(formatTestSuiteFixtureTitle(fixture, index), () => replayTestSuiteFixture(source, fixture, options));
|
|
3528
|
+
});
|
|
3529
|
+
};
|
|
3530
|
+
const describe = options.describe ?? globals.describe;
|
|
3531
|
+
const name = options.name ?? `property suite${suite.machineId !== void 0 && suite.machineId.length > 0 ? ` (${suite.machineId})` : ""}`;
|
|
3532
|
+
if (describe !== void 0) {
|
|
3533
|
+
describe(name, register);
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
register();
|
|
3537
|
+
});
|
|
3538
|
+
/**
|
|
3539
|
+
* Serializes a suite with stable key ordering.
|
|
3540
|
+
*
|
|
3541
|
+
* @experimental
|
|
3542
|
+
*/
|
|
3543
|
+
function serializeTestSuite(suite) {
|
|
3544
|
+
return JSON.stringify({
|
|
3545
|
+
formatVersion: suite.formatVersion,
|
|
3546
|
+
machineId: suite.machineId,
|
|
3547
|
+
machineVersion: suite.machineVersion,
|
|
3548
|
+
generatedAt: suite.generatedAt,
|
|
3549
|
+
fixtures: suite.fixtures,
|
|
3550
|
+
coverage: suite.coverage
|
|
3551
|
+
}, null, 2);
|
|
3552
|
+
}
|
|
3553
|
+
/**
|
|
3554
|
+
* Parses a serialized suite, rejecting unknown format versions.
|
|
3555
|
+
*
|
|
3556
|
+
* @experimental
|
|
3557
|
+
*/
|
|
3558
|
+
function parseTestSuite(json) {
|
|
3559
|
+
const parsed = JSON.parse(json);
|
|
3560
|
+
if (parsed === null || typeof parsed !== "object") throw new Error("Property suite JSON must be an object");
|
|
3561
|
+
if (parsed.formatVersion !== 1) throw new Error(`Unsupported property suite format version: ${String(parsed.formatVersion)}`);
|
|
3562
|
+
if (!Array.isArray(parsed.fixtures)) throw new Error("Property suite JSON must contain a `fixtures` array");
|
|
3563
|
+
return parsed;
|
|
3564
|
+
}
|
|
3565
|
+
// src/engine/deduplicatePaths.ts
|
|
3566
|
+
/**
|
|
3567
|
+
* Deduplicates your paths so that A -> B is not executed separately to A -> B
|
|
3568
|
+
* -> C
|
|
3569
|
+
*
|
|
3570
|
+
* Paths are returned longest first (stable for equal lengths). A path is
|
|
3571
|
+
* dropped when its event sequence is a prefix of, or equal to, the event
|
|
3572
|
+
* sequence of a path already kept.
|
|
3573
|
+
*/
|
|
3574
|
+
const deduplicatePaths = dual((args) => args.length >= 2 || Array.isArray(args[0]), (paths, serializeEvent = simpleStringify) => {
|
|
3575
|
+
const pathsWithEventSequence = paths.map((path) => ({
|
|
3576
|
+
path,
|
|
3577
|
+
eventSequence: path.steps.map((step) => serializeEvent(step.event))
|
|
3578
|
+
}));
|
|
3579
|
+
pathsWithEventSequence.sort((a, z) => z.path.steps.length - a.path.steps.length);
|
|
3580
|
+
const root = { children: /* @__PURE__ */ new Map() };
|
|
3581
|
+
const kept = [];
|
|
3582
|
+
for (const { path, eventSequence } of pathsWithEventSequence) {
|
|
3583
|
+
let node = root;
|
|
3584
|
+
for (const event of eventSequence) {
|
|
3585
|
+
node = node.children.get(event);
|
|
3586
|
+
if (node === void 0) break;
|
|
3587
|
+
}
|
|
3588
|
+
if (node !== void 0 && kept.length > 0) continue;
|
|
3589
|
+
let insertAt = root;
|
|
3590
|
+
for (const event of eventSequence) {
|
|
3591
|
+
let child = insertAt.children.get(event);
|
|
3592
|
+
if (child === void 0) {
|
|
3593
|
+
child = { children: /* @__PURE__ */ new Map() };
|
|
3594
|
+
insertAt.children.set(event, child);
|
|
3595
|
+
}
|
|
3596
|
+
insertAt = child;
|
|
3597
|
+
}
|
|
3598
|
+
kept.push(path);
|
|
3599
|
+
}
|
|
3600
|
+
return kept;
|
|
3601
|
+
});
|
|
3602
|
+
// src/engine/testPaths.ts
|
|
3603
|
+
const DEFAULT_SAMPLES = 3;
|
|
3604
|
+
/** Default traversal bound; see {@link PathOptions.limit}. */
|
|
3605
|
+
const DEFAULT_TRAVERSAL_LIMIT = 1e4;
|
|
3606
|
+
/** Lifts a possibly-async value into an Effect, preserving the rejection's identity. */
|
|
3607
|
+
const awaited$3 = (value) => Effect.promise(() => Promise.resolve(value));
|
|
3608
|
+
/** Internal event types path generation drives explicitly. */
|
|
3609
|
+
const DONE_ACTOR_EVENT = "xstate.done.actor";
|
|
3610
|
+
const ERROR_ACTOR_EVENT = "xstate.error.actor";
|
|
3611
|
+
const AFTER_EVENT = "xstate.after";
|
|
3612
|
+
const DONE_STATE_EVENT = "xstate.done.state";
|
|
3613
|
+
/**
|
|
3614
|
+
* The internal events traversal offers. The other internal events (state and
|
|
3615
|
+
* invoke timeouts, actor snapshots) are left out: nothing here can drive them
|
|
3616
|
+
* in executed mode, and a path that assumed one would desynchronize.
|
|
3617
|
+
*/
|
|
3618
|
+
const TRAVERSED_INTERNAL_EVENTS = /* @__PURE__ */ new Set([
|
|
3619
|
+
DONE_ACTOR_EVENT,
|
|
3620
|
+
ERROR_ACTOR_EVENT,
|
|
3621
|
+
AFTER_EVENT
|
|
3622
|
+
]);
|
|
3623
|
+
/** Internal events are the machine's own; only user events have declared cases. */
|
|
3624
|
+
function isInternalEventType(type) {
|
|
3625
|
+
return type.startsWith("xstate.");
|
|
3626
|
+
}
|
|
3627
|
+
function assertGeneratedOutcome(value, src) {
|
|
3628
|
+
if (value === void 0 || value === null || typeof value !== "object" || typeof value.ok !== "boolean") throw new Error(`The \`outcomes\` generator for "${src}" produced ${JSON.stringify(value)} instead of an actor outcome. Generate \`{ ok: true, output }\` or \`{ ok: false, error }\`.`);
|
|
3629
|
+
}
|
|
3630
|
+
/**
|
|
3631
|
+
* Samples concrete actor outcomes from `outcomes`, the same way event payloads
|
|
3632
|
+
* are sampled from `events`, so traversal can label each `xstate.done.actor` /
|
|
3633
|
+
* `xstate.error.actor` step with the outcome that produced it.
|
|
3634
|
+
*/
|
|
3635
|
+
function sampleTestOutcomes(outcomes, samples, seed) {
|
|
3636
|
+
const sampled = /* @__PURE__ */ new Map();
|
|
3637
|
+
for (const [src, configured] of Object.entries(outcomes ?? {})) {
|
|
3638
|
+
const generator = isEventDescriptorObject(configured) ? configured.generate : configured;
|
|
3639
|
+
if (generator === void 0) continue;
|
|
3640
|
+
const rng = createSeededRng(deriveCaseSeed(seed, `outcome:${src}`));
|
|
3641
|
+
const values = sampleGenerator(generator, rng, samples);
|
|
3642
|
+
for (const value of values) assertGeneratedOutcome(value, src);
|
|
3643
|
+
sampled.set(src, values);
|
|
3644
|
+
}
|
|
3645
|
+
return sampled;
|
|
3646
|
+
}
|
|
3647
|
+
const LEGACY_EVENT_EXECUTOR_MESSAGE = "A pre-2.0 event executor was passed as an event generator. `events` now declares how event *payloads* are generated; the functions that drive the system under test belong in `sut`. Write the `sut` directly.";
|
|
3648
|
+
/**
|
|
3649
|
+
* A `generate` value without a `resolve` must produce the event payload, so a
|
|
3650
|
+
* sample that is not a plain object is a pre-2.0 executor called with the PRNG
|
|
3651
|
+
* rather than a generator.
|
|
3652
|
+
*/
|
|
3653
|
+
function assertGeneratedPayload(value, type) {
|
|
3654
|
+
if (value === void 0) return;
|
|
3655
|
+
if (!(typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.then !== "function")) throw new Error(`Event "${type}" generated ${typeof value === "object" ? "a non-payload value" : `a ${typeof value}`} instead of an event payload object. ${LEGACY_EVENT_EXECUTOR_MESSAGE}`);
|
|
3656
|
+
}
|
|
3657
|
+
/**
|
|
3658
|
+
* Whether the options have the pre-2.0 `TestParam` shape: `events` and
|
|
3659
|
+
* `states` both maps of functions, and no `sut`. The unified API allows that
|
|
3660
|
+
* shape too (bare `(rng) => payload` generators plus top-level `states`), so
|
|
3661
|
+
* it only decides how a generator that does not produce a payload is
|
|
3662
|
+
* reported, never whether the options are rejected up front.
|
|
3663
|
+
*/
|
|
3664
|
+
function hasTestParamShape(options) {
|
|
3665
|
+
const isFunctionMap = (value) => {
|
|
3666
|
+
if (value === null || typeof value !== "object") return false;
|
|
3667
|
+
const entries = Object.values(value);
|
|
3668
|
+
return entries.length > 0 && entries.every((entry) => typeof entry === "function");
|
|
3669
|
+
};
|
|
3670
|
+
return options.sut === void 0 && isFunctionMap(options.events) && isFunctionMap(options.states);
|
|
3671
|
+
}
|
|
3672
|
+
/**
|
|
3673
|
+
* Expands an event type into the concrete events traversal should offer,
|
|
3674
|
+
* applying each declared case's `generate`, `resolve`, and `when`.
|
|
3675
|
+
*/
|
|
3676
|
+
function createEventExpander(cases, samples, seed, caseIds, testParamShaped) {
|
|
3677
|
+
const drawn = /* @__PURE__ */ new Map();
|
|
3678
|
+
const byType = /* @__PURE__ */ new Map();
|
|
3679
|
+
for (const eventCase of cases) {
|
|
3680
|
+
if (eventCase.generator !== void 0) {
|
|
3681
|
+
const rng = createSeededRng(deriveCaseSeed(seed, eventCase.caseId));
|
|
3682
|
+
const legacyCandidate = testParamShaped && typeof eventCase.generator === "function" && eventCase.descriptor.resolve === void 0;
|
|
3683
|
+
let values;
|
|
3684
|
+
try {
|
|
3685
|
+
values = sampleGenerator(eventCase.generator, rng, samples);
|
|
3686
|
+
} catch (cause) {
|
|
3687
|
+
if (legacyCandidate) throw new Error(`Event "${eventCase.type}" threw when called as a payload generator. ${LEGACY_EVENT_EXECUTOR_MESSAGE}`, { cause });
|
|
3688
|
+
throw cause;
|
|
3689
|
+
}
|
|
3690
|
+
if (eventCase.descriptor.resolve === void 0) for (const value of values) {
|
|
3691
|
+
if (legacyCandidate && typeof value?.then === "function") value.then(void 0, () => {});
|
|
3692
|
+
if (legacyCandidate && value === void 0) throw new Error(`Event "${eventCase.type}" generated \`undefined\` instead of an event payload object. ${LEGACY_EVENT_EXECUTOR_MESSAGE}`);
|
|
3693
|
+
assertGeneratedPayload(value, eventCase.type);
|
|
3694
|
+
}
|
|
3695
|
+
drawn.set(eventCase.caseId, values);
|
|
3696
|
+
}
|
|
3697
|
+
const list = byType.get(eventCase.type) ?? [];
|
|
3698
|
+
list.push(eventCase);
|
|
3699
|
+
byType.set(eventCase.type, list);
|
|
3700
|
+
}
|
|
3701
|
+
return {
|
|
3702
|
+
/** The event types `events` declares, whether or not the machine owns them. */
|
|
3703
|
+
declaredTypes: [...byType.keys()],
|
|
3704
|
+
expand(snapshot, template) {
|
|
3705
|
+
const configured = byType.get(template.type);
|
|
3706
|
+
if (configured === void 0) return [template];
|
|
3707
|
+
const expanded = [];
|
|
3708
|
+
for (const eventCase of configured) {
|
|
3709
|
+
const descriptor = eventCase.descriptor;
|
|
3710
|
+
const generated = drawn.get(eventCase.caseId) ?? [void 0];
|
|
3711
|
+
for (const value of generated) {
|
|
3712
|
+
const payload = descriptor.resolve !== void 0 ? descriptor.resolve({
|
|
3713
|
+
snapshot,
|
|
3714
|
+
generated: value
|
|
3715
|
+
}) : value;
|
|
3716
|
+
if (descriptor.resolve !== void 0 && payload === void 0) continue;
|
|
3717
|
+
const event = {
|
|
3718
|
+
...template,
|
|
3719
|
+
...payload ?? {},
|
|
3720
|
+
type: template.type
|
|
3721
|
+
};
|
|
3722
|
+
if (descriptor.when !== void 0 && !descriptor.when({
|
|
3723
|
+
snapshot,
|
|
3724
|
+
event
|
|
3725
|
+
})) continue;
|
|
3726
|
+
caseIds.set(event, eventCase.caseId);
|
|
3727
|
+
expanded.push(event);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
return expanded;
|
|
3731
|
+
}
|
|
3732
|
+
};
|
|
3733
|
+
}
|
|
3734
|
+
function formatEventForPath(event) {
|
|
3735
|
+
const { type, ...payload } = event;
|
|
3736
|
+
return Object.keys(payload).length !== 0 ? `${type} ${JSON.stringify(payload)}` : type;
|
|
3737
|
+
}
|
|
3738
|
+
/** A short, human-readable name for a path: its event types, in order. */
|
|
3739
|
+
function describePath(path) {
|
|
3740
|
+
const described = path.description;
|
|
3741
|
+
if (typeof described === "string" && described.length > 0) return described;
|
|
3742
|
+
const events = path.steps.map((step) => step.event.type).filter((type) => type !== XSTATE_INIT);
|
|
3743
|
+
return events.length !== 0 ? events.join(" → ") : "initial state";
|
|
3744
|
+
}
|
|
3745
|
+
/**
|
|
3746
|
+
* An adapter that executes a fixed list of paths instead of generating
|
|
3747
|
+
* command sequences. One path is one run; there is no shrinking.
|
|
3748
|
+
*
|
|
3749
|
+
* After every step the run's snapshot is compared with the snapshot the
|
|
3750
|
+
* traversal planned for that step, so a run that departs from its path fails
|
|
3751
|
+
* instead of passing on a different path.
|
|
3752
|
+
*/
|
|
3753
|
+
function createPathAdapter(paths, caseIds, results, plan) {
|
|
3754
|
+
return { run(request) {
|
|
3755
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
3756
|
+
const runs = yield* Ref.make(0);
|
|
3757
|
+
const error = yield* Ref.make(void 0);
|
|
3758
|
+
const maximumSequenceLength = yield* Ref.make(0);
|
|
3759
|
+
for (const path of paths) {
|
|
3760
|
+
const steps = path.steps.filter((step) => step.event.type !== XSTATE_INIT);
|
|
3761
|
+
yield* Ref.update(maximumSequenceLength, (value) => Math.max(value, steps.length));
|
|
3762
|
+
const runner = request.createRunner();
|
|
3763
|
+
const pathError = yield* Ref.make(void 0);
|
|
3764
|
+
const runPath = Effect.gen(function* () {
|
|
3765
|
+
yield* awaited$3(runner.start());
|
|
3766
|
+
for (const [index, step] of steps.entries()) {
|
|
3767
|
+
const event = step.event;
|
|
3768
|
+
const diverge = (reason) => runner.diverge(`Path diverged at step ${index + 1}: ${reason}`, {
|
|
3769
|
+
expected: step.state,
|
|
3770
|
+
actual: runner.getSnapshot()
|
|
3771
|
+
});
|
|
3772
|
+
const describeStatus = () => `the model is ${plan.format(runner.getSnapshot())}`;
|
|
3773
|
+
if (plan.mode === "executed") {
|
|
3774
|
+
const resolved = plan.outcomeByEvent.get(event);
|
|
3775
|
+
if (resolved !== void 0) {
|
|
3776
|
+
if (!runner.canRunOutcome()) diverge(`${formatEventForPath(event)} could not be resolved: ${describeStatus()}`);
|
|
3777
|
+
yield* awaited$3(runner.outcome(resolved.src, resolved.outcome));
|
|
3778
|
+
} else if (event.type === AFTER_EVENT) yield* awaited$3(runner.advance(runner.getAfterTimerRemaining(event) ?? diverge(`no timer is pending for ${formatEventForPath(event)}: ${describeStatus()}`)));
|
|
3779
|
+
else if (event.type === DONE_STATE_EVENT) continue;
|
|
3780
|
+
if (resolved !== void 0 || event.type === AFTER_EVENT) {
|
|
3781
|
+
if (plan.serialize(runner.getSnapshot()) !== plan.serialize(step.state)) diverge(`expected ${plan.format(step.state)}, got ${plan.format(runner.getSnapshot())}`);
|
|
3782
|
+
continue;
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
const caseId = caseIds.get(event) ?? getPropertyEventCaseId(event.type, "default");
|
|
3786
|
+
if (!runner.canRun(event, caseId)) diverge(`${formatEventForPath(event)} could not be sent: ${describeStatus()}`);
|
|
3787
|
+
yield* awaited$3(runner.run(event, caseId));
|
|
3788
|
+
if (plan.serialize(runner.getSnapshot()) !== plan.serialize(step.state)) diverge(`expected ${plan.format(step.state)}, got ${plan.format(runner.getSnapshot())}`);
|
|
3789
|
+
}
|
|
3790
|
+
runner.finish();
|
|
3791
|
+
});
|
|
3792
|
+
const pathExit = yield* Effect.exit(runPath);
|
|
3793
|
+
if (Exit.isFailure(pathExit)) yield* Ref.set(pathError, squash(pathExit.cause));
|
|
3794
|
+
const disposeExit = yield* Effect.exit(awaited$3(runner.dispose()));
|
|
3795
|
+
const bodyError = yield* Ref.get(pathError);
|
|
3796
|
+
if (Exit.isFailure(disposeExit) && (bodyError === void 0 || bodyError === null)) yield* Ref.set(pathError, squash(disposeExit.cause));
|
|
3797
|
+
yield* Ref.update(runs, (value) => value + 1);
|
|
3798
|
+
const failure = yield* Ref.get(pathError);
|
|
3799
|
+
results.push({
|
|
3800
|
+
path,
|
|
3801
|
+
passed: failure === void 0 || failure === null,
|
|
3802
|
+
error: failure
|
|
3803
|
+
});
|
|
3804
|
+
if (failure !== void 0 && failure !== null) {
|
|
3805
|
+
yield* Ref.set(error, failure);
|
|
3806
|
+
break;
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
const runCount = yield* Ref.get(runs);
|
|
3810
|
+
const runError = yield* Ref.get(error);
|
|
3811
|
+
const truncated = runError !== void 0 && runCount < paths.length;
|
|
3812
|
+
const maxSequenceLength = yield* Ref.get(maximumSequenceLength);
|
|
3813
|
+
return {
|
|
3814
|
+
runs: runCount,
|
|
3815
|
+
exploration: {
|
|
3816
|
+
configuredRuns: paths.length,
|
|
3817
|
+
maximumSequenceLength: maxSequenceLength,
|
|
3818
|
+
engine: "paths",
|
|
3819
|
+
truncated,
|
|
3820
|
+
truncationReasons: truncated ? ["counterexample found before every path ran"] : []
|
|
3821
|
+
},
|
|
3822
|
+
...runError === void 0 ? {} : { error: runError }
|
|
3823
|
+
};
|
|
3824
|
+
}));
|
|
3825
|
+
} };
|
|
3826
|
+
}
|
|
3827
|
+
function withPathExploration(coverage, pathCount, pathGenerator) {
|
|
3828
|
+
const truncationReasons = coverage.exploration.truncationReasons.filter((reason) => reason !== "maximum sequence length reached");
|
|
3829
|
+
return {
|
|
3830
|
+
...coverage,
|
|
3831
|
+
exploration: {
|
|
3832
|
+
...coverage.exploration,
|
|
3833
|
+
strategy: "paths",
|
|
3834
|
+
pathCount,
|
|
3835
|
+
pathGenerator,
|
|
3836
|
+
stoppedBecause: coverage.exploration.stoppedBecause === "failure" || coverage.exploration.stoppedBecause === "replay" ? coverage.exploration.stoppedBecause : "paths",
|
|
3837
|
+
truncated: truncationReasons.length > 0,
|
|
3838
|
+
truncationReasons
|
|
3839
|
+
}
|
|
3840
|
+
};
|
|
3841
|
+
}
|
|
3842
|
+
const AFTER_TIMER_PREFIX = "xstate.after.";
|
|
3843
|
+
/**
|
|
3844
|
+
* Executes model paths against a system under test.
|
|
3845
|
+
*
|
|
3846
|
+
* `testPaths()` and `propertyTest()` share their `events`, `sut`, `states`,
|
|
3847
|
+
* `invariant`, `temporal`, and `reference` options and produce the same
|
|
3848
|
+
* coverage object and the same failure type. They differ only in how the event
|
|
3849
|
+
* sequences are produced: traversal of the model's state graph here, generated
|
|
3850
|
+
* command sequences there.
|
|
3851
|
+
* @experimental
|
|
3852
|
+
*/
|
|
3853
|
+
const testPaths$1 = dual((args) => args.length >= 2 || typeof args[0] === "object" && args[0] !== null && "transition" in args[0], function testPaths(source, options = {}) {
|
|
3854
|
+
return Effect.runPromise(Effect.suspend(() => {
|
|
3855
|
+
if (options["commands"] !== void 0) throw new Error("`commands` is not supported by path generation; use `propertyTest()`. Paths decide their own `advance` and `outcome` commands from the internal events the traversal took.");
|
|
3856
|
+
const samples = options.samples ?? DEFAULT_SAMPLES;
|
|
3857
|
+
if (!Number.isInteger(samples) || samples < 1) throw new Error(`\`samples\` must be an integer of at least 1 (received ${String(options.samples)}).`);
|
|
3858
|
+
const baseLogic = source;
|
|
3859
|
+
const outcomeSources = Object.keys(options.outcomes ?? {});
|
|
3860
|
+
const testLogic = outcomeSources.length !== 0 ? provideActors(baseLogic, Object.fromEntries(outcomeSources.map((src) => [src, createOutcomeStub(src, void 0)]))) : baseLogic;
|
|
3861
|
+
const { cases } = normalizeEventDescriptors(options.events ?? {});
|
|
3862
|
+
const caseIds = /* @__PURE__ */ new WeakMap();
|
|
3863
|
+
const expander = createEventExpander(cases, samples, options.seed ?? 0, caseIds, hasTestParamShape(options));
|
|
3864
|
+
const mode = options.mode ?? "pure";
|
|
3865
|
+
const sampledOutcomes = sampleTestOutcomes(options.outcomes, samples, options.seed ?? 0);
|
|
3866
|
+
/** Invoke `id` to invoke `src`, collected from the state nodes as seen. */
|
|
3867
|
+
const invokeSrcById = /* @__PURE__ */ new Map();
|
|
3868
|
+
const outcomeByEvent = /* @__PURE__ */ new WeakMap();
|
|
3869
|
+
/**
|
|
3870
|
+
* Virtual time. Timers fire in due order, so a state offers only its
|
|
3871
|
+
* earliest-due `after` transition, where "due" counts the time already
|
|
3872
|
+
* spent in enclosing states along the path that reached it. Timer objects
|
|
3873
|
+
* keep their identity across transitions that do not reschedule them.
|
|
3874
|
+
*/
|
|
3875
|
+
const virtualNow = /* @__PURE__ */ new WeakMap();
|
|
3876
|
+
const timerScheduledAt = /* @__PURE__ */ new WeakMap();
|
|
3877
|
+
const afterFiresAt = /* @__PURE__ */ new WeakMap();
|
|
3878
|
+
/**
|
|
3879
|
+
* Turns one synthesized internal event into the concrete events traversal
|
|
3880
|
+
* should offer, and remembers what executed mode has to do to reach it.
|
|
3881
|
+
*/
|
|
3882
|
+
const expandInternalEvent = (event) => {
|
|
3883
|
+
if (event.type === AFTER_EVENT) return [{ ...event }];
|
|
3884
|
+
const ok = event.type === DONE_ACTOR_EVENT;
|
|
3885
|
+
const src = invokeSrcById.get(event.actorId);
|
|
3886
|
+
const matching = (src === void 0 ? void 0 : sampledOutcomes.get(src))?.filter((outcome) => outcome.ok === ok) ?? [];
|
|
3887
|
+
return (matching.length !== 0 ? matching : [ok ? {
|
|
3888
|
+
ok: true,
|
|
3889
|
+
output: void 0
|
|
3890
|
+
} : {
|
|
3891
|
+
ok: false,
|
|
3892
|
+
error: /* @__PURE__ */ new Error("generated failure")
|
|
3893
|
+
}]).map((outcome) => {
|
|
3894
|
+
const { sessionId: _sessionId, ...rest } = event;
|
|
3895
|
+
const offered = {
|
|
3896
|
+
...rest,
|
|
3897
|
+
...outcome.ok ? { output: outcome.output } : { error: outcome.error }
|
|
3898
|
+
};
|
|
3899
|
+
if (src !== void 0) outcomeByEvent.set(offered, {
|
|
3900
|
+
src,
|
|
3901
|
+
outcome
|
|
3902
|
+
});
|
|
3903
|
+
return offered;
|
|
3904
|
+
});
|
|
3905
|
+
};
|
|
3906
|
+
/** Keeps only the earliest-due `after` events, recording their due time. */
|
|
3907
|
+
const selectDueAfterEvents = (snapshot, afterEvents) => {
|
|
3908
|
+
if (afterEvents.length === 0) return [];
|
|
3909
|
+
const now = virtualNow.get(snapshot) ?? 0;
|
|
3910
|
+
const timers = snapshot.timers ?? {};
|
|
3911
|
+
for (const timer of Object.values(timers)) if (timer !== void 0 && !timerScheduledAt.has(timer)) timerScheduledAt.set(timer, now);
|
|
3912
|
+
const due = afterEvents.map((event) => {
|
|
3913
|
+
const { delay, stateId } = event;
|
|
3914
|
+
const timer = timers[`${AFTER_TIMER_PREFIX}${delay}.${stateId}`];
|
|
3915
|
+
const milliseconds = typeof timer?.delay === "number" ? timer.delay : typeof delay === "number" ? delay : Infinity;
|
|
3916
|
+
return (timer !== void 0 ? timerScheduledAt.get(timer) : now) + milliseconds;
|
|
3917
|
+
});
|
|
3918
|
+
const earliest = Math.min(...due);
|
|
3919
|
+
return afterEvents.filter((event, index) => {
|
|
3920
|
+
if (due[index] !== earliest) return false;
|
|
3921
|
+
afterFiresAt.set(event, earliest);
|
|
3922
|
+
return true;
|
|
3923
|
+
});
|
|
3924
|
+
};
|
|
3925
|
+
const traversalEvents = (snapshot) => {
|
|
3926
|
+
const types = new Set(expander.declaredTypes);
|
|
3927
|
+
const internal = [];
|
|
3928
|
+
const afterEvents = [];
|
|
3929
|
+
if (typeof snapshot.nodes === "object") {
|
|
3930
|
+
for (const stateNode of snapshot.nodes) for (const invokeDef of stateNode.invoke ?? []) invokeSrcById.set(invokeDef.id, invokeDef.src);
|
|
3931
|
+
for (const event of getAllOwnEvents(snapshot)) {
|
|
3932
|
+
const { type } = event;
|
|
3933
|
+
if (!isInternalEventType(type)) types.add(type);
|
|
3934
|
+
else if (TRAVERSED_INTERNAL_EVENTS.has(type)) (type === AFTER_EVENT ? afterEvents : internal).push(...expandInternalEvent(event));
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
return [
|
|
3938
|
+
...[...types].map((type) => ({ type })).flatMap((template) => expander.expand(snapshot, template)),
|
|
3939
|
+
...internal,
|
|
3940
|
+
...selectDueAfterEvents(snapshot, afterEvents)
|
|
3941
|
+
];
|
|
3942
|
+
};
|
|
3943
|
+
const limit = options.limit ?? DEFAULT_TRAVERSAL_LIMIT;
|
|
3944
|
+
const identity = options.serializeState ?? (isMachineLogic(testLogic) ? serializeSnapshot : simpleStringify);
|
|
3945
|
+
const traversalOptions = {
|
|
3946
|
+
events: traversalEvents,
|
|
3947
|
+
input: options.input,
|
|
3948
|
+
limit,
|
|
3949
|
+
...options.toState === void 0 ? {} : { toState: options.toState },
|
|
3950
|
+
...options.fromState === void 0 ? {} : { fromState: options.fromState },
|
|
3951
|
+
...options.stopWhen === void 0 ? {} : { stopWhen: options.stopWhen },
|
|
3952
|
+
serializeState: (snapshot, event, previousSnapshot) => {
|
|
3953
|
+
if (!virtualNow.has(snapshot)) {
|
|
3954
|
+
const previousNow = previousSnapshot !== void 0 && previousSnapshot !== null ? virtualNow.get(previousSnapshot) ?? 0 : 0;
|
|
3955
|
+
virtualNow.set(snapshot, event !== void 0 && event !== null ? afterFiresAt.get(event) ?? previousNow : previousNow);
|
|
3956
|
+
}
|
|
3957
|
+
return identity(snapshot, event, previousSnapshot);
|
|
3958
|
+
},
|
|
3959
|
+
...options.serializeEvent === void 0 ? {} : { serializeEvent: options.serializeEvent }
|
|
3960
|
+
};
|
|
3961
|
+
function generatePaths(generate) {
|
|
3962
|
+
try {
|
|
3963
|
+
return generate();
|
|
3964
|
+
} catch (error) {
|
|
3965
|
+
if (error instanceof Error && error.message === "Traversal limit exceeded") throw new Error(`Path generation exceeded \`limit\` (${limit} traversal steps) before it exhausted the model. A machine whose context grows without bound never runs out of states, because every distinct context is a distinct state. Pass \`serializeState\` to merge equivalent states, prune traversal with \`stopWhen\` or \`toState\`, or raise \`limit\`.`, { cause: error });
|
|
3966
|
+
throw error;
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
let pathGeneratorKind = "shortest";
|
|
3970
|
+
let paths;
|
|
3971
|
+
if (options.paths !== void 0) {
|
|
3972
|
+
pathGeneratorKind = "custom";
|
|
3973
|
+
paths = options.paths;
|
|
3974
|
+
} else if (options.fromEvents !== void 0) {
|
|
3975
|
+
pathGeneratorKind = "events";
|
|
3976
|
+
const { events: _traversalEvents, ...fromEventsOptions } = traversalOptions;
|
|
3977
|
+
paths = generatePaths(() => getPathsFromEvents(testLogic, options.fromEvents, fromEventsOptions));
|
|
3978
|
+
} else {
|
|
3979
|
+
let generated;
|
|
3980
|
+
if (typeof options.pathGenerator === "function") {
|
|
3981
|
+
pathGeneratorKind = "custom";
|
|
3982
|
+
const pathGenerator = options.pathGenerator;
|
|
3983
|
+
generated = generatePaths(() => pathGenerator(testLogic, traversalOptions));
|
|
3984
|
+
} else {
|
|
3985
|
+
pathGeneratorKind = options.pathGenerator ?? "shortest";
|
|
3986
|
+
generated = generatePaths(() => pathGeneratorKind === "simple" ? getSimplePaths(testLogic, traversalOptions) : getShortestPaths(testLogic, traversalOptions));
|
|
3987
|
+
}
|
|
3988
|
+
paths = options.allowDuplicatePaths !== void 0 ? generated : deduplicatePaths(generated);
|
|
3989
|
+
}
|
|
3990
|
+
const declaredTypes = new Set(cases.map((eventCase) => eventCase.type));
|
|
3991
|
+
const extraEvents = {};
|
|
3992
|
+
/** Invoke sources a path resolves, so executed mode can stub them. */
|
|
3993
|
+
const stubbedSources = /* @__PURE__ */ new Set();
|
|
3994
|
+
for (const path of paths) for (const step of path.steps) {
|
|
3995
|
+
const { type } = step.event;
|
|
3996
|
+
if (type === "@xstate.init" || declaredTypes.has(type) || caseIds.has(step.event)) continue;
|
|
3997
|
+
if (isInternalEventType(type)) {
|
|
3998
|
+
const resolved = outcomeByEvent.get(step.event);
|
|
3999
|
+
if (resolved !== void 0) stubbedSources.add(resolved.src);
|
|
4000
|
+
if (mode === "executed") continue;
|
|
4001
|
+
}
|
|
4002
|
+
extraEvents[type] = { generate: void 0 };
|
|
4003
|
+
}
|
|
4004
|
+
const formatSnapshot = options.formatSnapshot ?? defaultFormatSnapshot;
|
|
4005
|
+
const results = [];
|
|
4006
|
+
const adapter = createPathAdapter(paths, caseIds, results, {
|
|
4007
|
+
mode,
|
|
4008
|
+
outcomeByEvent,
|
|
4009
|
+
serialize: (snapshot) => identity(snapshot, void 0, void 0),
|
|
4010
|
+
format: (snapshot) => {
|
|
4011
|
+
try {
|
|
4012
|
+
return JSON.stringify(formatSnapshot(snapshot)) ?? String(snapshot);
|
|
4013
|
+
} catch {
|
|
4014
|
+
return String(snapshot);
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
});
|
|
4018
|
+
const { paths: _paths, pathGenerator: _pathGenerator, fromEvents: _fromEvents, limit: _limit, toState: _toState, fromState: _fromState, stopWhen: _stopWhen, samples: _samples, seed: _seed, serializeState: _serializeState, serializeEvent: _serializeEvent, allowDuplicatePaths: _allowDuplicatePaths, events: configuredEvents, outcomes: configuredOutcomes, ...shared } = options;
|
|
4019
|
+
const stubs = {};
|
|
4020
|
+
if (mode === "executed") {
|
|
4021
|
+
for (const src of stubbedSources) stubs[src] = { generate: void 0 };
|
|
4022
|
+
Object.assign(stubs, configuredOutcomes ?? {});
|
|
4023
|
+
}
|
|
4024
|
+
const runSource = mode === "pure" && outcomeSources.length !== 0 ? testLogic : source;
|
|
4025
|
+
return Effect.gen(function* () {
|
|
4026
|
+
const attempt = yield* Effect.exit(awaited$3(propertyTest$1(runSource, {
|
|
4027
|
+
...shared,
|
|
4028
|
+
adapter,
|
|
4029
|
+
...Object.keys(stubs).length !== 0 ? { outcomes: stubs } : {},
|
|
4030
|
+
events: {
|
|
4031
|
+
...configuredEvents ?? {},
|
|
4032
|
+
...extraEvents
|
|
4033
|
+
}
|
|
4034
|
+
})));
|
|
4035
|
+
if (Exit.isFailure(attempt)) {
|
|
4036
|
+
const error = squash(attempt.cause);
|
|
4037
|
+
if (error instanceof ModelTestFailure && error.coverage !== void 0) {
|
|
4038
|
+
const failedIndex = results.length - 1;
|
|
4039
|
+
const failedPath = results[failedIndex]?.path;
|
|
4040
|
+
const detail = error.summary.replace(/^Property /, "");
|
|
4041
|
+
const remapped = new ModelTestFailure(failedPath !== void 0 ? `Path ${failedIndex + 1} (${describePath(failedPath)}) failed: ${detail.charAt(0).toLowerCase() + detail.slice(1)}` : error.summary, error.trace, error.cause, error.replay, error.fixture, withPathExploration(error.coverage, paths.length, pathGeneratorKind), error.format, error.extras);
|
|
4042
|
+
return yield* Effect.die(remapped);
|
|
4043
|
+
}
|
|
4044
|
+
return yield* Effect.failCause(attempt.cause);
|
|
4045
|
+
}
|
|
4046
|
+
const { coverage } = attempt.value;
|
|
4047
|
+
return {
|
|
4048
|
+
coverage: withPathExploration(coverage, paths.length, pathGeneratorKind),
|
|
4049
|
+
results
|
|
4050
|
+
};
|
|
4051
|
+
});
|
|
4052
|
+
}));
|
|
4053
|
+
});
|
|
4054
|
+
/** Whether `logic` is a state machine (duck-typed, as `xstate/graph` does). */
|
|
4055
|
+
function isMachineLogic(logic) {
|
|
4056
|
+
const machine = logic;
|
|
4057
|
+
return typeof machine["getStateNodeById"] === "function" && typeof machine["resolveState"] === "function" && typeof machine["getTransitionData"] === "function";
|
|
4058
|
+
}
|
|
4059
|
+
// src/scheduler.ts
|
|
4060
|
+
let schedulerStorage;
|
|
4061
|
+
let schedulerStorageLoading;
|
|
4062
|
+
function loadSchedulerStorage() {
|
|
4063
|
+
schedulerStorageLoading ??= import("node:async_hooks").then((hooks) => {
|
|
4064
|
+
schedulerStorage = new hooks.AsyncLocalStorage();
|
|
4065
|
+
return schedulerStorage;
|
|
4066
|
+
});
|
|
4067
|
+
return schedulerStorageLoading;
|
|
4068
|
+
}
|
|
4069
|
+
/**
|
|
4070
|
+
* Awaits `value` as an Effect. A value and a thenable are both accepted, and a
|
|
4071
|
+
* rejection stays the same error object when the effect is run.
|
|
4072
|
+
*/
|
|
4073
|
+
const awaited$2 = (value) => Effect.promise(() => Promise.resolve(value));
|
|
4074
|
+
/**
|
|
4075
|
+
* The scheduler fast-check generated for the run currently in flight, or
|
|
4076
|
+
* `undefined` when the adapter was not configured with `scheduler`.
|
|
4077
|
+
*
|
|
4078
|
+
* Only defined while a scheduled run is executing, which is exactly when a
|
|
4079
|
+
* system under test is created and driven.
|
|
4080
|
+
*
|
|
4081
|
+
* @experimental
|
|
4082
|
+
*/
|
|
4083
|
+
function getCurrentScheduler() {
|
|
4084
|
+
return schedulerStorage?.getStore();
|
|
4085
|
+
}
|
|
4086
|
+
/** @internal */
|
|
4087
|
+
const withCurrentScheduler = dual(2, function withCurrentScheduler(scheduler, run) {
|
|
4088
|
+
return loadSchedulerStorage().then((storage) => storage.run(scheduler, run));
|
|
4089
|
+
});
|
|
4090
|
+
function scheduleMethod(scheduler, method) {
|
|
4091
|
+
if (method === void 0) return;
|
|
4092
|
+
return scheduler.scheduleFunction((...args) => Effect.runPromise(awaited$2(method(...args))));
|
|
4093
|
+
}
|
|
4094
|
+
/**
|
|
4095
|
+
* Wraps a {@link TestSut} so its asynchronous boundaries (`send`, `read`,
|
|
4096
|
+
* `settle`, `advance`) resolve in an order chosen by the run's fast-check
|
|
4097
|
+
* scheduler instead of in plain microtask order.
|
|
4098
|
+
*
|
|
4099
|
+
* The wrapper binds to {@link getCurrentScheduler} when the session is created,
|
|
4100
|
+
* so it is inert unless the adapter was configured with `scheduler`.
|
|
4101
|
+
*
|
|
4102
|
+
* @experimental
|
|
4103
|
+
*/
|
|
4104
|
+
function withScheduledSut(sut) {
|
|
4105
|
+
return {
|
|
4106
|
+
...sut,
|
|
4107
|
+
create: (context) => Effect.runPromise(Effect.gen(function* () {
|
|
4108
|
+
const scheduler = getCurrentScheduler();
|
|
4109
|
+
const session = yield* awaited$2(sut.create(context));
|
|
4110
|
+
if (scheduler === void 0) return session;
|
|
4111
|
+
const send = scheduler.scheduleFunction((event, sendContext) => Promise.resolve(session.send(event, sendContext)));
|
|
4112
|
+
const read = session.read !== void 0 ? scheduler.scheduleFunction(() => Promise.resolve(session.read())) : void 0;
|
|
4113
|
+
const settle = scheduleMethod(scheduler, session.settle?.bind(session));
|
|
4114
|
+
const advance = scheduleMethod(scheduler, session.advance?.bind(session));
|
|
4115
|
+
return {
|
|
4116
|
+
...session,
|
|
4117
|
+
send: (event, sendContext) => send(event, sendContext),
|
|
4118
|
+
...read !== void 0 ? { read: () => read() } : {},
|
|
4119
|
+
...settle !== void 0 ? { settle: () => settle() } : {},
|
|
4120
|
+
...advance !== void 0 ? { advance: (milliseconds) => advance(milliseconds) } : {}
|
|
4121
|
+
};
|
|
4122
|
+
}))
|
|
4123
|
+
};
|
|
4124
|
+
}
|
|
4125
|
+
/**
|
|
4126
|
+
* Wraps a reference oracle the way {@link withScheduledSut} wraps a system
|
|
4127
|
+
* under test: its `transition` and `read` calls resolve under the run's
|
|
4128
|
+
* scheduler.
|
|
4129
|
+
*
|
|
4130
|
+
* @experimental
|
|
4131
|
+
*/
|
|
4132
|
+
function withScheduledReference(reference) {
|
|
4133
|
+
return {
|
|
4134
|
+
...reference,
|
|
4135
|
+
create: (context) => Effect.runPromise(Effect.gen(function* () {
|
|
4136
|
+
const scheduler = getCurrentScheduler();
|
|
4137
|
+
const session = yield* awaited$2(reference.create(context));
|
|
4138
|
+
if (scheduler === void 0) return session;
|
|
4139
|
+
const step = scheduler.scheduleFunction((event) => Promise.resolve(session.transition(event)));
|
|
4140
|
+
const read = scheduler.scheduleFunction(() => Promise.resolve(session.read()));
|
|
4141
|
+
return {
|
|
4142
|
+
...session,
|
|
4143
|
+
transition: (event) => step(event),
|
|
4144
|
+
read: () => read()
|
|
4145
|
+
};
|
|
4146
|
+
}))
|
|
4147
|
+
};
|
|
4148
|
+
}
|
|
4149
|
+
// src/adapter.ts
|
|
4150
|
+
/**
|
|
4151
|
+
* Awaits `value` as an Effect. A value and a thenable are both accepted, and a
|
|
4152
|
+
* rejection stays the same error object when the effect is run.
|
|
4153
|
+
*/
|
|
4154
|
+
const awaited$1 = (value) => Effect.promise(() => Promise.resolve(value));
|
|
4155
|
+
function createRunSequencer() {
|
|
4156
|
+
let failureSeen = false;
|
|
4157
|
+
let frozenSwarm;
|
|
4158
|
+
return { nextRun: () => ({
|
|
4159
|
+
shrink: failureSeen,
|
|
4160
|
+
swarm: frozenSwarm,
|
|
4161
|
+
report: ({ passed, swarm }) => {
|
|
4162
|
+
if (passed || failureSeen) return;
|
|
4163
|
+
failureSeen = true;
|
|
4164
|
+
frozenSwarm = swarm;
|
|
4165
|
+
}
|
|
4166
|
+
}) };
|
|
4167
|
+
}
|
|
4168
|
+
/**
|
|
4169
|
+
* Runs one fast-check model scenario: start, the generated commands, then
|
|
4170
|
+
* disposal, so a failing command still disposes the runner.
|
|
4171
|
+
*/
|
|
4172
|
+
const runScenario = (runner, generated) => Effect.runPromise(ensuringFinalizerWins(Effect.gen(function* () {
|
|
4173
|
+
yield* awaited$1(runner.start());
|
|
4174
|
+
yield* awaited$1(fc.asyncModelRun(() => ({
|
|
4175
|
+
model: runner,
|
|
4176
|
+
real: void 0
|
|
4177
|
+
}), generated));
|
|
4178
|
+
runner.finish();
|
|
4179
|
+
}), Effect.promise(() => runner.dispose())));
|
|
4180
|
+
/**
|
|
4181
|
+
* Runs the generated command sequence against a fresh runner, releasing the
|
|
4182
|
+
* scheduler's tasks with `waitFor` when one is present.
|
|
4183
|
+
*/
|
|
4184
|
+
const runCommands = (request, sequencer, generated, scheduler) => Effect.runPromise(Effect.gen(function* () {
|
|
4185
|
+
const runner = request.createRunner(sequencer.nextRun());
|
|
4186
|
+
const scenario = () => runScenario(runner, generated);
|
|
4187
|
+
if (scheduler === void 0) {
|
|
4188
|
+
yield* awaited$1(scenario());
|
|
4189
|
+
return;
|
|
4190
|
+
}
|
|
4191
|
+
const finished = scenario();
|
|
4192
|
+
yield* ensuringFinalizerWins(awaited$1(scheduler.waitFor(finished)), Effect.promise(() => scheduler.waitIdle()));
|
|
4193
|
+
}));
|
|
4194
|
+
function normalizeSchedulerOptions(scheduler) {
|
|
4195
|
+
if (scheduler === void 0 || scheduler === false) return;
|
|
4196
|
+
return scheduler === true ? {} : scheduler;
|
|
4197
|
+
}
|
|
4198
|
+
function summarizeSchedulerReport(scheduler) {
|
|
4199
|
+
const tasks = scheduler.report().map((item) => ({
|
|
4200
|
+
taskId: item.taskId,
|
|
4201
|
+
label: item.label,
|
|
4202
|
+
schedulingType: item.schedulingType,
|
|
4203
|
+
status: item.status
|
|
4204
|
+
}));
|
|
4205
|
+
return {
|
|
4206
|
+
ordering: tasks.filter(({ status }) => status !== "pending").map(({ taskId }) => taskId),
|
|
4207
|
+
tasks
|
|
4208
|
+
};
|
|
4209
|
+
}
|
|
4210
|
+
/**
|
|
4211
|
+
* `fc.oneof` only accepts integer weights, so relative weights are rescaled by
|
|
4212
|
+
* the smallest one before rounding. Ratios are preserved up to rounding, and
|
|
4213
|
+
* every entry keeps a weight of at least 1 so no case becomes ungeneratable.
|
|
4214
|
+
*/
|
|
4215
|
+
function toIntegerWeights(weighted) {
|
|
4216
|
+
const smallest = Math.min(...weighted.map(({ weight }) => weight));
|
|
4217
|
+
return weighted.map(({ arbitrary, weight }) => ({
|
|
4218
|
+
arbitrary,
|
|
4219
|
+
weight: Math.min(MAXIMUM_INTEGER_WEIGHT, Math.max(1, Math.round(weight / smallest)))
|
|
4220
|
+
}));
|
|
4221
|
+
}
|
|
4222
|
+
/** Keeps rescaled weights well inside safe-integer arithmetic. */
|
|
4223
|
+
const MAXIMUM_INTEGER_WEIGHT = 1e6;
|
|
4224
|
+
/**
|
|
4225
|
+
* Extracts the `replayPath` that fast-check embeds in a `fc.commands`
|
|
4226
|
+
* counterexample so a failing run can be replayed deterministically.
|
|
4227
|
+
*
|
|
4228
|
+
* fast-check exposes no typed accessor for this: `CommandsArbitrary` builds a
|
|
4229
|
+
* `CommandsIterable` whose `metadataForReplay()` returns the
|
|
4230
|
+
* `replayPath="<path>"` fragment, and whose `toString()` appends that fragment
|
|
4231
|
+
* inside a trailing `/* ... *\/` comment. Neither member is declared in
|
|
4232
|
+
* `fast-check`'s public typings (checked against fast-check 4.9.0), so this
|
|
4233
|
+
* helper is the single place that depends on that shape. It prefers
|
|
4234
|
+
* `metadataForReplay()` and falls back to parsing `toString()`.
|
|
4235
|
+
*
|
|
4236
|
+
* @experimental
|
|
4237
|
+
*/
|
|
4238
|
+
function extractReplayPath(counterexample) {
|
|
4239
|
+
if (counterexample === null || counterexample === void 0) return;
|
|
4240
|
+
const candidate = counterexample;
|
|
4241
|
+
if (typeof candidate.metadataForReplay === "function") {
|
|
4242
|
+
const fromMetadata = parseReplayPathMetadata(candidate.metadataForReplay());
|
|
4243
|
+
if (fromMetadata !== void 0) return fromMetadata;
|
|
4244
|
+
}
|
|
4245
|
+
if (typeof candidate.toString === "function") return parseReplayPathMetadata(candidate.toString.call(counterexample));
|
|
4246
|
+
}
|
|
4247
|
+
function parseReplayPathMetadata(metadata) {
|
|
4248
|
+
if (typeof metadata !== "string") return;
|
|
4249
|
+
const match = metadata.match(/replayPath="([^"]*)"/);
|
|
4250
|
+
if (match === null) return;
|
|
4251
|
+
return match[1];
|
|
4252
|
+
}
|
|
4253
|
+
var EventPropertyCommand = class {
|
|
4254
|
+
type;
|
|
4255
|
+
generated;
|
|
4256
|
+
caseId;
|
|
4257
|
+
constructor(type, generated, caseId) {
|
|
4258
|
+
this.type = type;
|
|
4259
|
+
this.generated = generated;
|
|
4260
|
+
this.caseId = caseId;
|
|
4261
|
+
}
|
|
4262
|
+
check(runner) {
|
|
4263
|
+
return runner.canRunGenerated(this.type, this.generated, this.caseId);
|
|
4264
|
+
}
|
|
4265
|
+
run(runner) {
|
|
4266
|
+
return Effect.runPromise(awaited$1(runner.runGenerated(this.type, this.generated, this.caseId)));
|
|
4267
|
+
}
|
|
4268
|
+
toString() {
|
|
4269
|
+
return `${this.type}(${JSON.stringify(this.generated)})`;
|
|
4270
|
+
}
|
|
4271
|
+
};
|
|
4272
|
+
var AdvancePropertyCommand = class {
|
|
4273
|
+
milliseconds;
|
|
4274
|
+
constructor(milliseconds) {
|
|
4275
|
+
this.milliseconds = milliseconds;
|
|
4276
|
+
}
|
|
4277
|
+
check(runner) {
|
|
4278
|
+
return runner.canRunCommand(runner.getSnapshot().status === "active");
|
|
4279
|
+
}
|
|
4280
|
+
run(runner) {
|
|
4281
|
+
return Effect.runPromise(awaited$1(runner.advance(this.milliseconds)));
|
|
4282
|
+
}
|
|
4283
|
+
toString() {
|
|
4284
|
+
return `advance(${this.milliseconds})`;
|
|
4285
|
+
}
|
|
4286
|
+
};
|
|
4287
|
+
var CheckpointPropertyCommand = class {
|
|
4288
|
+
label;
|
|
4289
|
+
constructor(label) {
|
|
4290
|
+
this.label = label;
|
|
4291
|
+
}
|
|
4292
|
+
check(runner) {
|
|
4293
|
+
return runner.canRunCommand(true);
|
|
4294
|
+
}
|
|
4295
|
+
run(runner) {
|
|
4296
|
+
return Effect.runPromise(awaited$1(runner.checkpoint(this.label)));
|
|
4297
|
+
}
|
|
4298
|
+
toString() {
|
|
4299
|
+
return `checkpoint(${JSON.stringify(this.label)})`;
|
|
4300
|
+
}
|
|
4301
|
+
};
|
|
4302
|
+
var StopPropertyCommand = class {
|
|
4303
|
+
check(runner) {
|
|
4304
|
+
return runner.canRunCommand(runner.getSnapshot().status === "active");
|
|
4305
|
+
}
|
|
4306
|
+
run(runner) {
|
|
4307
|
+
return Effect.runPromise(awaited$1(runner.stop()));
|
|
4308
|
+
}
|
|
4309
|
+
toString() {
|
|
4310
|
+
return "stop()";
|
|
4311
|
+
}
|
|
4312
|
+
};
|
|
4313
|
+
var OutcomePropertyCommand = class {
|
|
4314
|
+
src;
|
|
4315
|
+
outcome;
|
|
4316
|
+
constructor(src, outcome) {
|
|
4317
|
+
this.src = src;
|
|
4318
|
+
this.outcome = outcome;
|
|
4319
|
+
}
|
|
4320
|
+
check(runner) {
|
|
4321
|
+
return runner.canRunOutcome();
|
|
4322
|
+
}
|
|
4323
|
+
run(runner) {
|
|
4324
|
+
return Effect.runPromise(awaited$1(runner.outcome(this.src, this.outcome)));
|
|
4325
|
+
}
|
|
4326
|
+
toString() {
|
|
4327
|
+
return `outcome(${JSON.stringify(this.src)}, ${JSON.stringify(this.outcome)})`;
|
|
4328
|
+
}
|
|
4329
|
+
};
|
|
4330
|
+
var FastCheckAdapter = class {
|
|
4331
|
+
options;
|
|
4332
|
+
kind;
|
|
4333
|
+
constructor(options) {
|
|
4334
|
+
this.options = options;
|
|
4335
|
+
}
|
|
4336
|
+
run(request) {
|
|
4337
|
+
const { options } = this;
|
|
4338
|
+
let schedulerReport;
|
|
4339
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
4340
|
+
const weighted = request.events.map(({ type, caseId, generator, weight }) => ({
|
|
4341
|
+
arbitrary: generator.map((generated) => new EventPropertyCommand(type, generated, caseId)),
|
|
4342
|
+
weight
|
|
4343
|
+
}));
|
|
4344
|
+
for (const command of request.commands) if (command.type === "advance") weighted.push({
|
|
4345
|
+
arbitrary: command.generator.map((milliseconds) => new AdvancePropertyCommand(milliseconds)),
|
|
4346
|
+
weight: command.weight
|
|
4347
|
+
});
|
|
4348
|
+
else if (command.type === "outcome") {
|
|
4349
|
+
const src = command.src;
|
|
4350
|
+
weighted.push({
|
|
4351
|
+
arbitrary: command.generator.map((outcome) => new OutcomePropertyCommand(src, outcome)),
|
|
4352
|
+
weight: command.weight
|
|
4353
|
+
});
|
|
4354
|
+
} else if (command.type === "checkpoint") weighted.push({
|
|
4355
|
+
arbitrary: command.generator.map((value) => new CheckpointPropertyCommand(value.label)),
|
|
4356
|
+
weight: command.weight
|
|
4357
|
+
});
|
|
4358
|
+
else weighted.push({
|
|
4359
|
+
arbitrary: command.generator.map(() => new StopPropertyCommand()),
|
|
4360
|
+
weight: command.weight
|
|
4361
|
+
});
|
|
4362
|
+
if (weighted.length === 0) throw new Error("Property tests require at least one event or command generator. Generators are derived only from runtime schemas in `schemas.events`: event types declared with a type-only `types<...>()` schema, or with no schema, are skipped, so configure them in `events`.");
|
|
4363
|
+
const commands = weighted.every(({ weight }) => weight === 1) ? weighted.map(({ arbitrary }) => arbitrary) : [fc.oneof(...toIntegerWeights(weighted))];
|
|
4364
|
+
const commandConstraints = {
|
|
4365
|
+
...options.maxCommands === void 0 ? {} : {
|
|
4366
|
+
maxCommands: options.maxCommands,
|
|
4367
|
+
size: "max"
|
|
4368
|
+
},
|
|
4369
|
+
...options.replayPath === void 0 ? {} : { replayPath: options.replayPath }
|
|
4370
|
+
};
|
|
4371
|
+
const commandSequence = fc.commands(commands, commandConstraints);
|
|
4372
|
+
const schedulerOptions = normalizeSchedulerOptions(options.scheduler);
|
|
4373
|
+
const sequencer = createRunSequencer();
|
|
4374
|
+
const property = schedulerOptions !== void 0 ? fc.asyncProperty(commandSequence, fc.scheduler(schedulerOptions.act !== void 0 ? { act: schedulerOptions.act } : void 0), (generated, scheduler) => withCurrentScheduler(scheduler, () => runCommands(request, sequencer, generated, scheduler).catch((error) => {
|
|
4375
|
+
schedulerReport = summarizeSchedulerReport(scheduler);
|
|
4376
|
+
throw error;
|
|
4377
|
+
}))) : fc.asyncProperty(commandSequence, (generated) => runCommands(request, sequencer, generated, void 0));
|
|
4378
|
+
const { maxCommands: _, replayPath: __, scheduler: ___, ...parameters } = options;
|
|
4379
|
+
if (request.runBudget !== void 0) parameters.numRuns = request.runBudget;
|
|
4380
|
+
if (request.runOffset !== void 0 && request.runOffset !== 0 && parameters.seed !== void 0) parameters.seed += request.runOffset;
|
|
4381
|
+
const result = yield* awaited$1(fc.check(property, parameters));
|
|
4382
|
+
if (options.asyncReporter !== void 0) yield* awaited$1(options.asyncReporter(result));
|
|
4383
|
+
else if (options.reporter !== void 0) options.reporter(result);
|
|
4384
|
+
const report = result.failed && Number(options.verbose ?? 0) >= 1 ? fc.defaultReportMessage(result) : void 0;
|
|
4385
|
+
const configuredRuns = result.runConfiguration.numRuns ?? 100;
|
|
4386
|
+
const truncationReasons = [];
|
|
4387
|
+
if (result.interrupted) truncationReasons.push("adapter run interrupted");
|
|
4388
|
+
if (result.failed && result.numRuns < configuredRuns) truncationReasons.push(result.errorInstance !== void 0 && result.errorInstance !== null ? "counterexample found before configured runs completed" : "precondition skips exhausted before configured runs completed");
|
|
4389
|
+
const exploration = {
|
|
4390
|
+
configuredRuns,
|
|
4391
|
+
maximumSequenceLength: options.maxCommands ?? null,
|
|
4392
|
+
engine: "fast-check",
|
|
4393
|
+
seed: result.seed,
|
|
4394
|
+
path: result.counterexamplePath ?? void 0,
|
|
4395
|
+
truncated: truncationReasons.length > 0,
|
|
4396
|
+
truncationReasons
|
|
4397
|
+
};
|
|
4398
|
+
if (!result.failed) return {
|
|
4399
|
+
runs: result.numRuns,
|
|
4400
|
+
exploration
|
|
4401
|
+
};
|
|
4402
|
+
return {
|
|
4403
|
+
runs: result.numRuns,
|
|
4404
|
+
exploration,
|
|
4405
|
+
...report !== void 0 ? { report } : {},
|
|
4406
|
+
error: result.errorInstance ?? /* @__PURE__ */ new Error(result.interrupted ? "FastCheck property run was interrupted" : "FastCheck property run exhausted its precondition skips"),
|
|
4407
|
+
replay: {
|
|
4408
|
+
engine: "fast-check",
|
|
4409
|
+
seed: result.seed,
|
|
4410
|
+
path: result.counterexamplePath ?? void 0,
|
|
4411
|
+
replayPath: extractReplayPath(result.counterexample?.[0]),
|
|
4412
|
+
numShrinks: result.numShrinks,
|
|
4413
|
+
...schedulerReport !== void 0 ? { data: { scheduler: schedulerReport } } : {}
|
|
4414
|
+
}
|
|
4415
|
+
};
|
|
4416
|
+
}));
|
|
4417
|
+
}
|
|
4418
|
+
};
|
|
4419
|
+
/** @experimental */
|
|
4420
|
+
function fastCheckAdapter(options = {}) {
|
|
4421
|
+
return new FastCheckAdapter(options);
|
|
4422
|
+
}
|
|
4423
|
+
// src/failures.ts
|
|
4424
|
+
/**
|
|
4425
|
+
* Awaits `value` as an Effect. A value and a thenable are both accepted, and a
|
|
4426
|
+
* rejection stays the same error object when the effect is run.
|
|
4427
|
+
*/
|
|
4428
|
+
const awaited = (value) => Effect.promise(() => Promise.resolve(value));
|
|
4429
|
+
/** Reads a saved failure file. A file of any other shape is left alone. */
|
|
4430
|
+
const parseSavedFailure = (text) => JSON.parse(text);
|
|
4431
|
+
/** The bytes a saved failure file holds. */
|
|
4432
|
+
const serializeSavedFailure = (saved) => JSON.stringify(saved, null, 2);
|
|
4433
|
+
/** The fixture's JSON, hashed to name its file. */
|
|
4434
|
+
const fixtureJson = (fixture) => JSON.stringify(fixture);
|
|
4435
|
+
const DEFAULT_DIR = ".xstate-test";
|
|
4436
|
+
/** Keeps a key usable as a single directory name. */
|
|
4437
|
+
function sanitizeKey(key) {
|
|
4438
|
+
return key.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "default";
|
|
4439
|
+
}
|
|
4440
|
+
/**
|
|
4441
|
+
* A {@link TestFailureStore} that saves each failing fixture as
|
|
4442
|
+
* `<dir>/<key>/<hash>.json`. Node's `fs` is loaded only when a campaign reads
|
|
4443
|
+
* or writes a failure, so importing `@xstate/test` stays free of Node APIs.
|
|
4444
|
+
*
|
|
4445
|
+
* @experimental
|
|
4446
|
+
*/
|
|
4447
|
+
function createFailureDatabase(options = {}) {
|
|
4448
|
+
const dir = options.dir ?? DEFAULT_DIR;
|
|
4449
|
+
const folderOf = (key) => Effect.runPromise(Effect.gen(function* () {
|
|
4450
|
+
return (yield* awaited(import("node:path"))).join(dir, sanitizeKey(key));
|
|
4451
|
+
}));
|
|
4452
|
+
return {
|
|
4453
|
+
...options.key === void 0 ? {} : { key: options.key },
|
|
4454
|
+
replay: options.replay ?? "first",
|
|
4455
|
+
load: (key) => Effect.runPromise(Effect.gen(function* () {
|
|
4456
|
+
const [fs, path] = yield* awaited(Promise.all([import("node:fs/promises"), import("node:path")]));
|
|
4457
|
+
const folder = yield* awaited(folderOf(key));
|
|
4458
|
+
const names = yield* Effect.catchCause(awaited(fs.readdir(folder)), () => Effect.succeed([]));
|
|
4459
|
+
const stored = [];
|
|
4460
|
+
for (const name of names.filter((file) => file.endsWith(".json")).sort()) {
|
|
4461
|
+
const location = path.join(folder, name);
|
|
4462
|
+
yield* Effect.ignoreCause(Effect.gen(function* () {
|
|
4463
|
+
const saved = parseSavedFailure(yield* awaited(fs.readFile(location, "utf8")));
|
|
4464
|
+
if (saved?.fixture !== void 0) stored.push({
|
|
4465
|
+
fixture: saved.fixture,
|
|
4466
|
+
location
|
|
4467
|
+
});
|
|
4468
|
+
}));
|
|
4469
|
+
}
|
|
4470
|
+
return stored;
|
|
4471
|
+
})),
|
|
4472
|
+
onFailure: (fixture, key, failure) => Effect.runPromise(Effect.gen(function* () {
|
|
4473
|
+
const [fs, path, crypto] = yield* awaited(Promise.all([
|
|
4474
|
+
import("node:fs/promises"),
|
|
4475
|
+
import("node:path"),
|
|
4476
|
+
import("node:crypto")
|
|
4477
|
+
]));
|
|
4478
|
+
const folder = yield* awaited(folderOf(key));
|
|
4479
|
+
const serialized = fixtureJson(fixture);
|
|
4480
|
+
const hash = crypto.createHash("sha256").update(serialized).digest("hex").slice(0, 12);
|
|
4481
|
+
const location = path.join(folder, `${hash}.json`);
|
|
4482
|
+
const saved = {
|
|
4483
|
+
formatVersion: 1,
|
|
4484
|
+
key,
|
|
4485
|
+
summary: failure.summary,
|
|
4486
|
+
...failure.replay !== void 0 ? { replay: failure.replay } : {},
|
|
4487
|
+
fixture
|
|
4488
|
+
};
|
|
4489
|
+
yield* awaited(fs.mkdir(folder, { recursive: true }));
|
|
4490
|
+
yield* awaited(fs.writeFile(location, `${serializeSavedFailure(saved)}\n`));
|
|
4491
|
+
return location;
|
|
4492
|
+
})),
|
|
4493
|
+
remove: (stored) => Effect.runPromise(Effect.gen(function* () {
|
|
4494
|
+
if (stored.location === void 0 || stored.location.length === 0) return;
|
|
4495
|
+
const fs = yield* awaited(import("node:fs/promises"));
|
|
4496
|
+
yield* awaited(fs.rm(stored.location, { force: true }));
|
|
4497
|
+
}))
|
|
4498
|
+
};
|
|
4499
|
+
}
|
|
4500
|
+
/** Turns the `failures` option into the store `xstate/graph` expects. */
|
|
4501
|
+
function resolveFailuresOption(failures) {
|
|
4502
|
+
if (failures === void 0 || failures === false) return;
|
|
4503
|
+
if (failures === true) return createFailureDatabase();
|
|
4504
|
+
if (typeof failures.load === "function") return failures;
|
|
4505
|
+
return createFailureDatabase(failures);
|
|
4506
|
+
}
|
|
4507
|
+
// src/propertyTest.ts
|
|
4508
|
+
const ADAPTER_OPTION_KEY_SET = /* @__PURE__ */ new Set([
|
|
4509
|
+
"asyncReporter",
|
|
4510
|
+
"endOnFailure",
|
|
4511
|
+
"ignoreEqualValues",
|
|
4512
|
+
"includeErrorInReport",
|
|
4513
|
+
"interruptAfterTimeLimit",
|
|
4514
|
+
"markInterruptAsFailure",
|
|
4515
|
+
"maxCommands",
|
|
4516
|
+
"maxSkipsPerRun",
|
|
4517
|
+
"numRuns",
|
|
4518
|
+
"path",
|
|
4519
|
+
"randomType",
|
|
4520
|
+
"replayPath",
|
|
4521
|
+
"reporter",
|
|
4522
|
+
"scheduler",
|
|
4523
|
+
"seed",
|
|
4524
|
+
"skipAllAfterTimeLimit",
|
|
4525
|
+
"skipEqualValues",
|
|
4526
|
+
"timeout",
|
|
4527
|
+
"unbiased",
|
|
4528
|
+
"verbose"
|
|
4529
|
+
]);
|
|
4530
|
+
/**
|
|
4531
|
+
* Derives generators for the event types the machine declares a runtime schema
|
|
4532
|
+
* for and that `events` does not already cover.
|
|
4533
|
+
*
|
|
4534
|
+
* Event types configured explicitly, and type-only `types<...>()` declarations,
|
|
4535
|
+
* are filtered out before `eventsFromSchemas()` sees them. Anything left that
|
|
4536
|
+
* the converters do not recognize still throws, so an unsupported schema
|
|
4537
|
+
* library is reported rather than silently ignored.
|
|
4538
|
+
*/
|
|
4539
|
+
function deriveMissingEvents(source, events) {
|
|
4540
|
+
const schemas = source?.schemas?.events;
|
|
4541
|
+
if (schemas === void 0) return;
|
|
4542
|
+
const missing = Object.fromEntries(Object.entries(schemas).filter(([type, schema]) => events?.[type] === void 0 && !isTypeOnlySchema(schema)));
|
|
4543
|
+
if (Object.keys(missing).length === 0) return;
|
|
4544
|
+
return eventsFromSchemas({
|
|
4545
|
+
schemas: { events: missing },
|
|
4546
|
+
events: []
|
|
4547
|
+
}, { eventsWithoutSchema: "skip" });
|
|
4548
|
+
}
|
|
4549
|
+
function splitOptions(options) {
|
|
4550
|
+
const adapterOptions = {};
|
|
4551
|
+
const rest = {};
|
|
4552
|
+
for (const [key, value] of Object.entries(options)) if (ADAPTER_OPTION_KEY_SET.has(key)) adapterOptions[key] = value;
|
|
4553
|
+
else rest[key] = value;
|
|
4554
|
+
return {
|
|
4555
|
+
adapterOptions,
|
|
4556
|
+
rest
|
|
4557
|
+
};
|
|
4558
|
+
}
|
|
4559
|
+
/**
|
|
4560
|
+
* Builds the options `xstate/graph` expects: the implicit adapter, and the
|
|
4561
|
+
* schema-derived `events` map that explicit entries are layered onto.
|
|
4562
|
+
*/
|
|
4563
|
+
function resolveOptions(source, options) {
|
|
4564
|
+
const { adapterOptions, rest } = splitOptions(options);
|
|
4565
|
+
const { deriveEvents, adapter, events, failures, ...propertyOptions } = rest;
|
|
4566
|
+
const failureStore = resolveFailuresOption(failures);
|
|
4567
|
+
const derived = deriveEvents === false ? void 0 : deriveMissingEvents(source, events);
|
|
4568
|
+
const { maxRuns } = propertyOptions;
|
|
4569
|
+
return {
|
|
4570
|
+
...propertyOptions,
|
|
4571
|
+
...maxRuns === void 0 && typeof adapterOptions["numRuns"] === "number" ? { maxRuns: adapterOptions["numRuns"] } : {},
|
|
4572
|
+
events: derived !== void 0 ? {
|
|
4573
|
+
...derived,
|
|
4574
|
+
...events
|
|
4575
|
+
} : events ?? {},
|
|
4576
|
+
adapter: adapter ?? fastCheckAdapter(adapterOptions),
|
|
4577
|
+
...failureStore !== void 0 ? { failures: failureStore } : {}
|
|
4578
|
+
};
|
|
4579
|
+
}
|
|
4580
|
+
/**
|
|
4581
|
+
* Runs a property-testing campaign with fast-check as the generator.
|
|
4582
|
+
*
|
|
4583
|
+
* Identical to `propertyTest()` from `xstate/graph`, except that fast-check
|
|
4584
|
+
* options (`seed`, `numRuns`, `maxCommands`, `scheduler`, …) are top-level
|
|
4585
|
+
* options rather than `fastCheckAdapter()` arguments, and `events` is derived
|
|
4586
|
+
* from the machine's `schemas.events` when it declares them.
|
|
4587
|
+
*
|
|
4588
|
+
* @experimental
|
|
4589
|
+
*/
|
|
4590
|
+
const propertyTest = dual(2, function propertyTest(source, options) {
|
|
4591
|
+
return propertyTest$1(source, resolveOptions(source, options));
|
|
4592
|
+
});
|
|
4593
|
+
/**
|
|
4594
|
+
* Records an offline property suite from a passing campaign, with fast-check
|
|
4595
|
+
* as the generator. See `generateTestSuite()` in `xstate/graph`.
|
|
4596
|
+
*
|
|
4597
|
+
* @experimental
|
|
4598
|
+
*/
|
|
4599
|
+
const generateTestSuite = dual(2, function generateTestSuite(source, options) {
|
|
4600
|
+
return generateTestSuite$1(source, resolveOptions(source, options));
|
|
4601
|
+
});
|
|
4602
|
+
/** fast-check arbitraries expose a `generate` method; plain generators do not. */
|
|
4603
|
+
function isArbitrary(value) {
|
|
4604
|
+
return value !== void 0 && value !== null && typeof value === "object" && typeof value.generate === "function";
|
|
4605
|
+
}
|
|
4606
|
+
/**
|
|
4607
|
+
* Replaces every fast-check arbitrary with a `{ sample(rng) }` generator
|
|
4608
|
+
* backed by `fc.sample`, so `xstate/graph` can expand it without depending on
|
|
4609
|
+
* fast-check. An object rather than a function, so `testPaths()` never
|
|
4610
|
+
* mistakes it for a pre-2.0 event executor.
|
|
4611
|
+
*/
|
|
4612
|
+
function sampleArbitraries(events, samples, seed, prefix = "") {
|
|
4613
|
+
const sampled = (generator, caseId) => {
|
|
4614
|
+
if (!isArbitrary(generator)) return generator;
|
|
4615
|
+
const values = fc.sample(generator, {
|
|
4616
|
+
seed: deriveCaseSeed(seed, caseId),
|
|
4617
|
+
numRuns: Math.max(1, samples)
|
|
4618
|
+
});
|
|
4619
|
+
let index = 0;
|
|
4620
|
+
return { sample: () => values[index++ % values.length] };
|
|
4621
|
+
};
|
|
4622
|
+
const one = (eventCase, type) => {
|
|
4623
|
+
if (!isEventDescriptorObject(eventCase)) return sampled(eventCase, `${prefix}${type}:default`);
|
|
4624
|
+
const descriptor = eventCase;
|
|
4625
|
+
return {
|
|
4626
|
+
...descriptor,
|
|
4627
|
+
generate: sampled(descriptor.generate, `${prefix}${type}:${descriptor.case ?? "default"}`)
|
|
4628
|
+
};
|
|
4629
|
+
};
|
|
4630
|
+
return Object.fromEntries(Object.entries(events ?? {}).map(([type, configured]) => [type, Array.isArray(configured) ? configured.map((eventCase) => one(eventCase, type)) : one(configured, type)]));
|
|
4631
|
+
}
|
|
4632
|
+
/**
|
|
4633
|
+
* Executes model paths against a system under test.
|
|
4634
|
+
*
|
|
4635
|
+
* The same options as `propertyTest()`, minus the generation controls
|
|
4636
|
+
* (`numRuns`, `maxCommands`, `swarm`, `frontiers`, …) and plus the traversal
|
|
4637
|
+
* controls (`pathGenerator`, `limit`, `toState`, `samples`, `seed`). fast-check
|
|
4638
|
+
* arbitraries are accepted in `events` and sampled into `samples` concrete
|
|
4639
|
+
* payloads before traversal.
|
|
4640
|
+
*
|
|
4641
|
+
* @experimental
|
|
4642
|
+
*/
|
|
4643
|
+
const testPaths = dual((args) => args.length >= 2 || typeof args[0] === "object" && args[0] !== null && "transition" in args[0], function testPaths(source, options = {}) {
|
|
4644
|
+
const { deriveEvents, events, outcomes, failures, ...rest } = options;
|
|
4645
|
+
const failureStore = resolveFailuresOption(failures);
|
|
4646
|
+
const derived = deriveEvents === false ? void 0 : deriveMissingEvents(source, events);
|
|
4647
|
+
const merged = derived !== void 0 ? {
|
|
4648
|
+
...derived,
|
|
4649
|
+
...events
|
|
4650
|
+
} : events ?? {};
|
|
4651
|
+
const samples = options.samples ?? 3;
|
|
4652
|
+
const seed = options.seed ?? 0;
|
|
4653
|
+
return testPaths$1(source, {
|
|
4654
|
+
...rest,
|
|
4655
|
+
...failureStore !== void 0 ? { failures: failureStore } : {},
|
|
4656
|
+
...outcomes !== void 0 ? { outcomes: sampleArbitraries(outcomes, samples, seed, "outcome:") } : {},
|
|
4657
|
+
events: sampleArbitraries(merged, samples, seed)
|
|
4658
|
+
});
|
|
4659
|
+
});
|
|
4660
|
+
export { consoleLineLogger as C, formatTestStatistics as D, formatTestCoverageJUnit as E, testCoverageToJSON as O, assertTestCoverage as S, formatTestCoverageHTML as T, ReplayNotReproducedError as _, extractReplayPath as a, replayTest as b, withScheduledReference as c, parseTestSuite as d, replayTestSuite as f, PropertyScenarioRunner as g, ModelTestFailure as h, createFailureDatabase as i, createPickDescriptor as k, withScheduledSut as l, serializeTestSuite as m, propertyTest as n, fastCheckAdapter as o, replayTestSuiteFixture as p, testPaths as r, getCurrentScheduler as s, generateTestSuite as t, describeTestSuite as u, TestCampaignError as v, formatTestCoverage as w, serializeTestTrace as x, defaultEquivalent as y };
|