@systemfsoftware/xstate-test 0.0.0-stage → 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 +88 -3
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { t as ensuringFinalizerWins } from "./finalizerError-DOptAIDs.mjs";
|
|
2
|
+
import { dual } from "effect/Function";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Data from "effect/Data";
|
|
5
|
+
// src/playwright.ts
|
|
6
|
+
/**
|
|
7
|
+
* Awaits `value` as an Effect. A value and a thenable are both accepted, and a
|
|
8
|
+
* rejection stays the same error object when the effect is run.
|
|
9
|
+
*/
|
|
10
|
+
const awaited = (value) => Effect.promise(() => Promise.resolve(value));
|
|
11
|
+
/** The fixture document a failed run stores next to its artifacts. */
|
|
12
|
+
const fixtureJson = (fixture) => JSON.stringify(fixture, null, 2);
|
|
13
|
+
/**
|
|
14
|
+
* Thrown by a session's `check()` when a page-level oracle fails.
|
|
15
|
+
*
|
|
16
|
+
* @experimental
|
|
17
|
+
*/
|
|
18
|
+
const PlaywrightOracleErrorBase = Data.TaggedError("@systemfsoftware/xstate-test/playwright/PlaywrightOracleError");
|
|
19
|
+
var PlaywrightOracleError = class extends PlaywrightOracleErrorBase {
|
|
20
|
+
messages;
|
|
21
|
+
name = "PlaywrightOracleError";
|
|
22
|
+
constructor(messages) {
|
|
23
|
+
super({
|
|
24
|
+
message: `The page reported ${messages.length} error${messages.length === 1 ? "" : "s"}:\n${messages.map((message) => ` - ${message}`).join("\n")}`,
|
|
25
|
+
cause: messages
|
|
26
|
+
});
|
|
27
|
+
this.messages = messages;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const DEFAULT_ORACLES = {
|
|
31
|
+
pageError: true,
|
|
32
|
+
console: "error",
|
|
33
|
+
http: 400,
|
|
34
|
+
unhandledRejection: true
|
|
35
|
+
};
|
|
36
|
+
/** Marks the console messages the rejection listener writes. */
|
|
37
|
+
const REJECTION_PREFIX = "[xstate-test] unhandledrejection: ";
|
|
38
|
+
const REJECTION_SCRIPT = `window.addEventListener('unhandledrejection', function (event) {
|
|
39
|
+
var reason = event.reason;
|
|
40
|
+
console.error(${JSON.stringify(REJECTION_PREFIX)} + (reason && reason.message ? reason.message : String(reason)));
|
|
41
|
+
});`;
|
|
42
|
+
function resolveOracles(oracles) {
|
|
43
|
+
if (oracles === false) return;
|
|
44
|
+
if (oracles === void 0 || oracles === "defaults") return DEFAULT_ORACLES;
|
|
45
|
+
return {
|
|
46
|
+
pageError: oracles.pageError ?? false,
|
|
47
|
+
console: oracles.console ?? false,
|
|
48
|
+
http: oracles.http ?? false,
|
|
49
|
+
unhandledRejection: oracles.unhandledRejection ?? false
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function formatStepName(event) {
|
|
53
|
+
const { type, ...payload } = event;
|
|
54
|
+
return Object.keys(payload).length !== 0 ? `${type} ${JSON.stringify(payload)}` : type;
|
|
55
|
+
}
|
|
56
|
+
function defaultCaseOf(event) {
|
|
57
|
+
const explicit = event.case;
|
|
58
|
+
return typeof explicit === "string" ? explicit : event.type;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Wraps a page so every `route()` a mock installs is recorded and can be
|
|
62
|
+
* removed again when another case's mock is applied or the session is disposed.
|
|
63
|
+
*/
|
|
64
|
+
function trackRoutes(page, installed, mockedRequests) {
|
|
65
|
+
if (typeof page.route !== "function") return page;
|
|
66
|
+
return new Proxy(page, { get(target, property, receiver) {
|
|
67
|
+
const value = Reflect.get(target, property, receiver);
|
|
68
|
+
if (typeof value !== "function") return value;
|
|
69
|
+
if (property === "route") return (url, handler, ...rest) => {
|
|
70
|
+
const args = [
|
|
71
|
+
url,
|
|
72
|
+
typeof handler === "function" ? (route, ...more) => {
|
|
73
|
+
const request = more[0] ?? route?.request?.();
|
|
74
|
+
if (request !== void 0 && request !== null && typeof request === "object") mockedRequests.add(request);
|
|
75
|
+
return handler(route, ...more);
|
|
76
|
+
} : handler,
|
|
77
|
+
...rest
|
|
78
|
+
];
|
|
79
|
+
installed.push(args);
|
|
80
|
+
return value.apply(target, args);
|
|
81
|
+
};
|
|
82
|
+
return value.bind(target);
|
|
83
|
+
} });
|
|
84
|
+
}
|
|
85
|
+
/** Removes every route a mock installed through {@link trackRoutes}. */
|
|
86
|
+
function releaseRoutes(page, installed) {
|
|
87
|
+
return Effect.runPromise(Effect.gen(function* () {
|
|
88
|
+
const unroute = page.unroute;
|
|
89
|
+
if (typeof unroute === "function") for (const args of installed) yield* awaited(unroute.call(page, args[0], args[1]));
|
|
90
|
+
installed.length = 0;
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolves the mock for a generated event case, trying `"<type>.<case>"`, then
|
|
95
|
+
* `"<case>"`, then the case resolved by `caseOf`.
|
|
96
|
+
*/
|
|
97
|
+
function resolveMock(mocks, eventCase, fallbackCase) {
|
|
98
|
+
if (mocks === void 0) return;
|
|
99
|
+
const keys = [...eventCase !== void 0 ? [`${eventCase.type}.${eventCase.name}`, eventCase.name] : [], ...fallbackCase === void 0 ? [] : [fallbackCase]];
|
|
100
|
+
for (const key of keys) {
|
|
101
|
+
const mock = mocks[key];
|
|
102
|
+
if (mock !== void 0) return {
|
|
103
|
+
key,
|
|
104
|
+
mock
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function sanitizeLabel(label) {
|
|
109
|
+
return label.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Creates a `TestSut` that drives a Playwright page as the system under
|
|
113
|
+
* test for `propertyTest()` and `testPaths()`.
|
|
114
|
+
*
|
|
115
|
+
* @experimental
|
|
116
|
+
*/
|
|
117
|
+
const createPlaywrightSut = dual(2, function createPlaywrightSut(page, config) {
|
|
118
|
+
const caseOf = config.caseOf ?? defaultCaseOf;
|
|
119
|
+
const screenshotDir = config.screenshotDir ?? "property-screenshots";
|
|
120
|
+
const testInfo = config.testInfo;
|
|
121
|
+
const trace = config.trace ?? (testInfo !== void 0 ? "retain-on-failure" : "off");
|
|
122
|
+
const screenshots = config.screenshots ?? (testInfo !== void 0 ? "on-failure" : "off");
|
|
123
|
+
if (testInfo === void 0 && (trace !== "off" || screenshots !== "off")) throw new Error("`trace` and `screenshots` attach their files to `testInfo`; pass Playwright's `testInfo` fixture to `createPlaywrightSut()`.");
|
|
124
|
+
const oracles = resolveOracles(config.oracles);
|
|
125
|
+
let rejectionScriptInstalled = false;
|
|
126
|
+
let lastFailure;
|
|
127
|
+
let lastPassingTrace;
|
|
128
|
+
return {
|
|
129
|
+
...config.projectModel !== void 0 ? { projectModel: config.projectModel } : {},
|
|
130
|
+
...config.projectSut !== void 0 ? { projectSut: config.projectSut } : {},
|
|
131
|
+
...config.equivalent !== void 0 ? { equivalent: config.equivalent } : {},
|
|
132
|
+
create: (_context) => {
|
|
133
|
+
const collected = [];
|
|
134
|
+
const mockedRequests = /* @__PURE__ */ new WeakSet();
|
|
135
|
+
const listeners = [];
|
|
136
|
+
const installRejectionScript = oracles !== void 0 && oracles.unhandledRejection && !rejectionScriptInstalled;
|
|
137
|
+
if (installRejectionScript) rejectionScriptInstalled = true;
|
|
138
|
+
if (oracles !== void 0 && typeof page.on === "function") {
|
|
139
|
+
listeners.push(["pageerror", (error) => {
|
|
140
|
+
if (oracles.pageError) collected.push(`pageerror: ${error instanceof Error ? error.message : String(error)}`);
|
|
141
|
+
}], ["console", (message) => {
|
|
142
|
+
const text = message.text();
|
|
143
|
+
if (text.startsWith(REJECTION_PREFIX)) {
|
|
144
|
+
if (oracles.unhandledRejection) collected.push(`unhandledrejection: ${text.slice(34)}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const type = message.type();
|
|
148
|
+
if (type === "error" && oracles.console !== false || type === "warning" && oracles.console === "warn") collected.push(`console.${type}: ${text}`);
|
|
149
|
+
}], ["response", (response) => {
|
|
150
|
+
if (oracles.http === false || response.status() < oracles.http || mockedRequests.has(response.request())) return;
|
|
151
|
+
collected.push(`HTTP ${response.status()} ${response.request().method()} ${response.url()}`);
|
|
152
|
+
}]);
|
|
153
|
+
for (const [event, listener] of listeners) page.on(event, listener);
|
|
154
|
+
}
|
|
155
|
+
const tracing = trace === "off" ? void 0 : page.context?.().tracing;
|
|
156
|
+
const startTracing = tracing?.start;
|
|
157
|
+
const session = {
|
|
158
|
+
tracingStarted: false,
|
|
159
|
+
appliedCase: void 0,
|
|
160
|
+
checkpoints: 0
|
|
161
|
+
};
|
|
162
|
+
const setupSession = () => Effect.gen(function* () {
|
|
163
|
+
if (installRejectionScript) yield* awaited(page.addInitScript?.(REJECTION_SCRIPT));
|
|
164
|
+
if (startTracing !== void 0) yield* Effect.ignoreCause(Effect.gen(function* () {
|
|
165
|
+
yield* awaited(startTracing({
|
|
166
|
+
screenshots: true,
|
|
167
|
+
snapshots: true
|
|
168
|
+
}));
|
|
169
|
+
session.tracingStarted = true;
|
|
170
|
+
}));
|
|
171
|
+
yield* awaited(config.reset?.(page));
|
|
172
|
+
return session;
|
|
173
|
+
});
|
|
174
|
+
const createSession = (setup) => {
|
|
175
|
+
const stepScreenshots = [];
|
|
176
|
+
const installedRoutes = [];
|
|
177
|
+
const mockPage = trackRoutes(page, installedRoutes, mockedRequests);
|
|
178
|
+
return {
|
|
179
|
+
send: (event, context) => Effect.runPromise(Effect.gen(function* () {
|
|
180
|
+
const resolved = resolveMock(config.mocks, context?.case, caseOf(event));
|
|
181
|
+
if (resolved !== void 0 && resolved.key !== setup.appliedCase) {
|
|
182
|
+
yield* awaited(releaseRoutes(page, installedRoutes));
|
|
183
|
+
yield* awaited(resolved.mock(mockPage));
|
|
184
|
+
setup.appliedCase = resolved.key;
|
|
185
|
+
}
|
|
186
|
+
const action = config.events[event.type];
|
|
187
|
+
if (action === void 0) throw new Error(`No Playwright action configured for event "${event.type}"`);
|
|
188
|
+
if (config.step !== void 0) {
|
|
189
|
+
yield* awaited(config.step(formatStepName(event), () => Promise.resolve(action(page, event))));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
yield* awaited(action(page, event));
|
|
193
|
+
})),
|
|
194
|
+
...config.read !== void 0 ? { read: () => config.read(page) } : {},
|
|
195
|
+
...config.states !== void 0 ? { states: Object.fromEntries(Object.entries(config.states).map(([key, assertion]) => [key, (snapshot) => assertion(page, snapshot)])) } : {},
|
|
196
|
+
settle: () => Effect.runPromise(Effect.gen(function* () {
|
|
197
|
+
if (config.settle !== void 0) {
|
|
198
|
+
yield* awaited(config.settle(page));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
yield* awaited(page.waitForLoadState?.("load"));
|
|
202
|
+
yield* Effect.callback((resume) => {
|
|
203
|
+
queueMicrotask(() => resume(Effect.void));
|
|
204
|
+
});
|
|
205
|
+
})),
|
|
206
|
+
check: () => Effect.runPromise(Effect.gen(function* () {
|
|
207
|
+
if (screenshots === "every-step") {
|
|
208
|
+
const shot = yield* awaited(page.screenshot?.());
|
|
209
|
+
if (shot !== void 0 && shot !== null) stepScreenshots.push(shot);
|
|
210
|
+
}
|
|
211
|
+
if (collected.length !== 0) throw new PlaywrightOracleError(collected.splice(0));
|
|
212
|
+
})),
|
|
213
|
+
advance: (milliseconds) => Effect.runPromise(Effect.gen(function* () {
|
|
214
|
+
if (config.advance !== void 0) return yield* awaited(config.advance(page, milliseconds));
|
|
215
|
+
yield* awaited(page.clock?.runFor?.(milliseconds));
|
|
216
|
+
return [];
|
|
217
|
+
})),
|
|
218
|
+
checkpoint: (label) => Effect.runPromise(Effect.gen(function* () {
|
|
219
|
+
const resolved = label ?? `checkpoint-${setup.checkpoints}`;
|
|
220
|
+
setup.checkpoints++;
|
|
221
|
+
if (config.checkpoint !== void 0) {
|
|
222
|
+
yield* awaited(config.checkpoint(page, resolved));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
yield* awaited(page.screenshot?.({ path: `${screenshotDir}/${sanitizeLabel(resolved)}.png` }));
|
|
226
|
+
})),
|
|
227
|
+
...config.stop !== void 0 ? { stop: () => config.stop(page) } : {},
|
|
228
|
+
dispose: ({ passed }) => Effect.runPromise(ensuringFinalizerWins(Effect.gen(function* () {
|
|
229
|
+
for (const [event, listener] of listeners) page.off?.(event, listener);
|
|
230
|
+
if (!passed) {
|
|
231
|
+
const screenshot = screenshots === "on-failure" ? yield* awaited(page.screenshot?.()) : void 0;
|
|
232
|
+
let tracePath;
|
|
233
|
+
if (setup.tracingStarted) {
|
|
234
|
+
tracePath = testInfo.outputPath("xstate-test-failure-trace.zip");
|
|
235
|
+
yield* awaited(tracing.stop({ path: tracePath }));
|
|
236
|
+
}
|
|
237
|
+
lastFailure = {
|
|
238
|
+
...tracePath !== void 0 ? { trace: tracePath } : {},
|
|
239
|
+
...screenshot !== void 0 && screenshot !== null ? { screenshot } : {},
|
|
240
|
+
steps: stepScreenshots.slice()
|
|
241
|
+
};
|
|
242
|
+
} else if (setup.tracingStarted) {
|
|
243
|
+
if (trace === "on") {
|
|
244
|
+
lastPassingTrace = testInfo.outputPath("xstate-test-trace.zip");
|
|
245
|
+
yield* awaited(tracing.stop({ path: lastPassingTrace }));
|
|
246
|
+
} else yield* awaited(tracing.stop());
|
|
247
|
+
}
|
|
248
|
+
yield* awaited(releaseRoutes(page, installedRoutes));
|
|
249
|
+
}), Effect.promise(() => Promise.resolve(config.dispose?.(page)))))
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
return Effect.runPromise(Effect.map(setupSession(), createSession));
|
|
253
|
+
},
|
|
254
|
+
complete: ({ passed, failure }) => Effect.runPromise(Effect.gen(function* () {
|
|
255
|
+
const artifacts = lastFailure;
|
|
256
|
+
const passingTrace = lastPassingTrace;
|
|
257
|
+
lastFailure = void 0;
|
|
258
|
+
lastPassingTrace = void 0;
|
|
259
|
+
if (testInfo === void 0) return;
|
|
260
|
+
if (passed) {
|
|
261
|
+
if (passingTrace !== void 0) yield* awaited(testInfo.attach("trace", {
|
|
262
|
+
path: passingTrace,
|
|
263
|
+
contentType: "application/zip"
|
|
264
|
+
}));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const fixture = failure?.fixture;
|
|
268
|
+
if (fixture !== void 0) yield* awaited(testInfo.attach("fixture.json", {
|
|
269
|
+
body: fixtureJson(fixture),
|
|
270
|
+
contentType: "application/json"
|
|
271
|
+
}));
|
|
272
|
+
if (artifacts?.trace !== void 0) yield* awaited(testInfo.attach("trace", {
|
|
273
|
+
path: artifacts.trace,
|
|
274
|
+
contentType: "application/zip"
|
|
275
|
+
}));
|
|
276
|
+
if (artifacts?.screenshot !== void 0) yield* awaited(testInfo.attach("failure.png", {
|
|
277
|
+
body: artifacts.screenshot,
|
|
278
|
+
contentType: "image/png"
|
|
279
|
+
}));
|
|
280
|
+
for (const [index, shot] of (artifacts?.steps ?? []).entries()) yield* awaited(testInfo.attach(`step-${index}.png`, {
|
|
281
|
+
body: shot,
|
|
282
|
+
contentType: "image/png"
|
|
283
|
+
}));
|
|
284
|
+
}))
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
export { PlaywrightOracleError, createPlaywrightSut };
|