@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.
@@ -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 };