@ralphralphai/playwright 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,708 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ let node_path = require("node:path");
25
+ node_path = __toESM(node_path, 1);
26
+ let _playwright_test = require("@playwright/test");
27
+ let node_fs = require("node:fs");
28
+ let node_fs_promises = require("node:fs/promises");
29
+ let _ralphralphai_config_package_json = require("@ralphralphai/config/package.json");
30
+ _ralphralphai_config_package_json = __toESM(_ralphralphai_config_package_json, 1);
31
+ let _ralphralphai_config_zod = require("@ralphralphai/config/zod");
32
+ let jsonc_parser = require("jsonc-parser");
33
+ let node_crypto = require("node:crypto");
34
+ let node_timers_promises = require("node:timers/promises");
35
+ let _playwright_test_package_json = require("@playwright/test/package.json");
36
+ _playwright_test_package_json = __toESM(_playwright_test_package_json, 1);
37
+ let node_util = require("node:util");
38
+ let node_zlib = require("node:zlib");
39
+ let tar_stream = require("tar-stream");
40
+ //#region src/config.ts
41
+ function resolveMode(options) {
42
+ const mode = options.mode ?? process.env["RALPH_MODE"] ?? (process.env["RALPH_UPLOAD_KEY"] ? "upload" : "off");
43
+ if (mode === "off" || mode === "local" || mode === "upload") return mode;
44
+ throw new Error("RALPH_MODE must be off, local, or upload.");
45
+ }
46
+ async function loadConfig(options, root) {
47
+ const file = configFile(options, root);
48
+ return snapshot(options.config ?? parseConfig(await (0, node_fs_promises.readFile)(file, "utf8")));
49
+ }
50
+ function loadConfigSync(options, root) {
51
+ const file = configFile(options, root);
52
+ return snapshot(options.config ?? parseConfig((0, node_fs.readFileSync)(file, "utf8")));
53
+ }
54
+ function configFile(options, root) {
55
+ if (options.config && options.configPath) throw new Error("Provide either ralphOptions.config or configPath, not both.");
56
+ return node_path.default.resolve(root, options.configPath ?? "ralph.jsonc");
57
+ }
58
+ function parseConfig(text) {
59
+ const errors = [];
60
+ const value = (0, jsonc_parser.parse)(text, errors, { allowTrailingComma: true });
61
+ if (errors.length) throw new Error("Invalid Ralph config: " + errors.map((error) => (0, jsonc_parser.printParseErrorCode)(error.error) + " at " + error.offset).join(", "));
62
+ return value;
63
+ }
64
+ function snapshot(value) {
65
+ _ralphralphai_config_zod.WebRalphConfigSchema.parse(value);
66
+ return {
67
+ config: JSON.parse(JSON.stringify(value)),
68
+ packageVersion: _ralphralphai_config_package_json.default.version
69
+ };
70
+ }
71
+ //#endregion
72
+ //#region package.json
73
+ var version = "0.1.0";
74
+ //#endregion
75
+ //#region src/image.ts
76
+ /**
77
+ * Matches the crawler's full-page captures: lossless measured larger than this
78
+ * on real sites, which are mostly photographic content.
79
+ */
80
+ const WEBP_QUALITY = 80;
81
+ async function takeScreenshot(page, deadline) {
82
+ const options = {
83
+ fullPage: true,
84
+ scale: "css"
85
+ };
86
+ const webp = await page.screenshot({
87
+ ...options,
88
+ type: "webp",
89
+ quality: WEBP_QUALITY,
90
+ timeout: Math.max(1, deadline - Date.now())
91
+ });
92
+ if (webp.length > 0) return {
93
+ bytes: webp,
94
+ contentType: "image/webp",
95
+ ...webpSize(webp)
96
+ };
97
+ const png = await page.screenshot({
98
+ ...options,
99
+ type: "png",
100
+ timeout: Math.max(1, deadline - Date.now())
101
+ });
102
+ return {
103
+ bytes: png,
104
+ contentType: "image/png",
105
+ width: png.readUInt32BE(16),
106
+ height: png.readUInt32BE(20)
107
+ };
108
+ }
109
+ function imageExtension(contentType) {
110
+ return contentType === "image/png" ? ".png" : ".webp";
111
+ }
112
+ function webpSize(bytes) {
113
+ if (bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") throw new Error("The screenshot is not a WebP image.");
114
+ const chunk = bytes.toString("ascii", 12, 16);
115
+ if (chunk === "VP8X") return {
116
+ width: 1 + bytes.readUIntLE(24, 3),
117
+ height: 1 + bytes.readUIntLE(27, 3)
118
+ };
119
+ if (chunk === "VP8 ") return {
120
+ width: bytes.readUInt16LE(26) & 16383,
121
+ height: bytes.readUInt16LE(28) & 16383
122
+ };
123
+ if (chunk === "VP8L") {
124
+ const bits = bytes.readUInt32LE(21);
125
+ return {
126
+ width: 1 + (bits & 16383),
127
+ height: 1 + (bits >> 14 & 16383)
128
+ };
129
+ }
130
+ throw new Error("Unsupported WebP chunk: " + chunk);
131
+ }
132
+ //#endregion
133
+ //#region src/layout.ts
134
+ function readLayout(page) {
135
+ return page.locator("[data-track-id]").filter({ visible: true }).evaluateAll((elements) => ({
136
+ viewport: {
137
+ width: window.innerWidth,
138
+ height: window.innerHeight
139
+ },
140
+ document: {
141
+ width: Math.max(document.documentElement.scrollWidth, document.documentElement.clientWidth, document.body?.scrollWidth ?? 0),
142
+ height: Math.max(document.documentElement.scrollHeight, document.documentElement.clientHeight, document.body?.scrollHeight ?? 0)
143
+ },
144
+ scroll: {
145
+ x: window.scrollX,
146
+ y: window.scrollY
147
+ },
148
+ devicePixelRatio: window.devicePixelRatio,
149
+ trackedElements: elements.flatMap((element) => {
150
+ const trackId = element.getAttribute("data-track-id");
151
+ const rect = element.getBoundingClientRect();
152
+ if (!trackId || rect.width <= 0 || rect.height <= 0 || rect.right <= 0 || rect.left >= window.innerWidth) return [];
153
+ return [{
154
+ trackId,
155
+ x: rect.x + window.scrollX,
156
+ y: rect.y + window.scrollY,
157
+ width: rect.width,
158
+ height: rect.height
159
+ }];
160
+ })
161
+ }));
162
+ }
163
+ //#endregion
164
+ //#region src/upload.ts
165
+ function resolveUploadOptions(options) {
166
+ const uploadKey = process.env["RALPH_UPLOAD_KEY"];
167
+ const apiUrl = options.apiUrl ?? process.env["RALPH_API_URL"];
168
+ const appId = options.appId ?? process.env["RALPH_APP_ID"];
169
+ if (!uploadKey || !apiUrl || !appId) throw new Error("Ralph upload requires RALPH_UPLOAD_KEY, RALPH_API_URL (or ralphOptions.apiUrl), and RALPH_APP_ID (or ralphOptions.appId).");
170
+ return {
171
+ uploadKey,
172
+ apiUrl,
173
+ appId,
174
+ timeoutMs: options.uploadTimeoutMs
175
+ };
176
+ }
177
+ const CAPTURE_BUNDLE_INDEX = "capture.json";
178
+ const CAPTURE_BUNDLE_IMAGE_DIR = "assets/images";
179
+ /**
180
+ * Packs the manifest and screenshots into a gzipped tar archive:
181
+ *
182
+ * capture.json { manifest, screenshots: [{ nodeId, path }] }
183
+ * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
184
+ */
185
+ async function createCaptureBundle(manifest, screenshots) {
186
+ if (Buffer.byteLength(JSON.stringify(manifest)) > 2097152 || screenshots.length > 500 || screenshots.reduce((total, screenshot) => total + screenshot.bytes.byteLength, 0) > 67108864) throw new Error("Ralph capture attempt exceeds upload limits.");
187
+ const images = screenshots.map(({ nodeId, contentType, bytes }) => {
188
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
189
+ return {
190
+ nodeId,
191
+ path: `${CAPTURE_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`,
192
+ bytes
193
+ };
194
+ });
195
+ const index = {
196
+ manifest,
197
+ screenshots: images.map(({ nodeId, path }) => ({
198
+ nodeId,
199
+ path
200
+ }))
201
+ };
202
+ const attrs = {
203
+ mtime: /* @__PURE__ */ new Date(0),
204
+ uid: 0,
205
+ gid: 0
206
+ };
207
+ const tar = (0, tar_stream.pack)();
208
+ tar.entry({
209
+ name: CAPTURE_BUNDLE_INDEX,
210
+ ...attrs
211
+ }, JSON.stringify(index));
212
+ for (const { path, bytes } of images) tar.entry({
213
+ name: path,
214
+ ...attrs
215
+ }, Buffer.from(bytes));
216
+ tar.finalize();
217
+ const chunks = [];
218
+ for await (const chunk of tar) chunks.push(chunk);
219
+ const compressed = await (0, node_util.promisify)(node_zlib.gzip)(Buffer.concat(chunks));
220
+ if (compressed.byteLength > 67108864) throw new Error("Ralph compressed capture bundle exceeds upload limits.");
221
+ return compressed;
222
+ }
223
+ function isLoopback(url) {
224
+ return url.hostname === "localhost" || url.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(url.hostname);
225
+ }
226
+ async function uploadCapture(manifest, screenshots, options) {
227
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) throw new Error(`Ralph app id is not valid: ${options.appId}`);
228
+ const url = new URL(`/api/upload/playwright/apps/${options.appId}/attempts`, options.apiUrl);
229
+ if (!(url.protocol === "https:" || url.protocol === "http:" && isLoopback(url)) || !options.uploadKey) throw new Error("Ralph upload requires an HTTPS API URL (HTTP only for loopback hosts) and an upload key.");
230
+ const timeoutMs = options.timeoutMs ?? 3e4;
231
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Ralph uploadTimeoutMs must be positive and finite.");
232
+ const blob = new Blob([new Uint8Array(await createCaptureBundle(manifest, screenshots))], { type: "application/gzip" });
233
+ const response = await fetch(url, {
234
+ method: "POST",
235
+ headers: { Authorization: `Bearer ${options.uploadKey}` },
236
+ body: blob,
237
+ redirect: "error",
238
+ signal: AbortSignal.timeout(timeoutMs)
239
+ });
240
+ if (!response.ok) throw new Error(`Ralph upload failed (HTTP ${response.status}). Local capture artifacts are retained for retry.`);
241
+ const receipt = await response.json();
242
+ if (receipt.result !== "ok" || receipt.status !== "received" || receipt.attemptId !== manifest.attemptId || typeof receipt.replayed !== "boolean") throw new Error("Ralph server returned an invalid upload receipt.");
243
+ return receipt;
244
+ }
245
+ //#endregion
246
+ //#region src/recorder.ts
247
+ const MANIFEST_ATTACHMENT = "ralph-captures";
248
+ const DEFAULT_SETTLE_MS = 150;
249
+ const DEFAULT_CAPTURE_TIMEOUT_MS = 5e3;
250
+ /** Where captures are written, inside Playwright's per-test output directory. */
251
+ const OUTPUT_DIR = "ralph";
252
+ var Recorder = class {
253
+ options;
254
+ config;
255
+ testInfo;
256
+ upload;
257
+ /**
258
+ * Identifies this run of the test. A retry gets a new recorder, and so a new
259
+ * id, which is what lets the server tell retries apart and dedupe re-uploads.
260
+ */
261
+ attemptId = (0, node_crypto.randomUUID)();
262
+ startedAt = (/* @__PURE__ */ new Date()).toISOString();
263
+ /** How long to wait after a navigation's DOM loads before capturing. */
264
+ settleMs;
265
+ /**
266
+ * The budget for one capture, from settling through the screenshot. Past it,
267
+ * the capture is recorded as failed.
268
+ */
269
+ captureTimeoutMs;
270
+ /**
271
+ * Every context being watched. `onPage` is kept so `stopContext` can remove
272
+ * the exact listener `observe` added. The id is stored in the manifest's
273
+ * `pages[].contextId`.
274
+ */
275
+ contexts = /* @__PURE__ */ new Map();
276
+ /**
277
+ * Every tab seen in a watched context, including closed ones, so their
278
+ * captures and opener links still reach the manifest.
279
+ */
280
+ pages = /* @__PURE__ */ new Map();
281
+ /**
282
+ * Each capture's outcome, in completion order. Captures in one tab complete
283
+ * in sequence, but tabs interleave; `finish` sorts them.
284
+ */
285
+ nodes = [];
286
+ /**
287
+ * When each capture was requested, keyed by capture id, for linking a
288
+ * popup's first capture to its opener's latest. Numbers, so they compare
289
+ * without parsing each node's `requestedAt` string.
290
+ */
291
+ requestedAtMs = /* @__PURE__ */ new Map();
292
+ /** Set by `finish`, after which `observe` throws. */
293
+ closed = false;
294
+ constructor(options, config, testInfo, upload) {
295
+ this.options = options;
296
+ this.config = config;
297
+ this.testInfo = testInfo;
298
+ this.upload = upload;
299
+ this.settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
300
+ this.captureTimeoutMs = options.captureTimeoutMs ?? DEFAULT_CAPTURE_TIMEOUT_MS;
301
+ if (!(Number.isFinite(this.settleMs) && this.settleMs >= 0 && Number.isFinite(this.captureTimeoutMs) && this.captureTimeoutMs > this.settleMs)) throw new Error("Ralph captureTimeoutMs must be finite and greater than settleMs, which must be non-negative.");
302
+ }
303
+ /** Starts capturing URL changes in every current and future tab of `context`. */
304
+ observe(context) {
305
+ if (this.closed) throw new Error("The Ralph recorder has finished.");
306
+ if (this.contexts.has(context)) return;
307
+ const id = "context-" + this.contexts.size;
308
+ const onPage = (page) => this.watchPage(page, id);
309
+ this.contexts.set(context, {
310
+ id,
311
+ onPage
312
+ });
313
+ context.on("page", onPage);
314
+ for (const page of context.pages()) onPage(page);
315
+ }
316
+ /** Captures the page as it is now, e.g. an open dialog at the same URL. */
317
+ async captureNode(page, options = {}) {
318
+ this.observe(page.context());
319
+ const state = this.pages.get(page);
320
+ if (!state) throw new Error("Cannot capture a closed browser page.");
321
+ await this.enqueue(state, "explicit", options);
322
+ }
323
+ /** Waits for every queued capture to finish. */
324
+ async flush() {
325
+ await Promise.all([...this.pages.values()].map((state) => state.tail));
326
+ }
327
+ /** Stops watching `context` and waits for its queued captures. */
328
+ async stopContext(context) {
329
+ const entry = this.contexts.get(context);
330
+ if (entry) context.off("page", entry.onPage);
331
+ const states = [...this.pages.values()].filter((state) => state.page.context() === context);
332
+ for (const state of states) state.page.off("framenavigated", state.onNavigation);
333
+ await Promise.all(states.map((state) => state.tail));
334
+ }
335
+ /**
336
+ * Stops recording, then writes the manifest, uploads it if configured, and
337
+ * fails a passing test whose captures failed.
338
+ */
339
+ async finish() {
340
+ this.closed = true;
341
+ await Promise.all([...this.contexts.keys()].map((context) => this.stopContext(context)));
342
+ await Promise.all([...this.pages.values()].map((state) => state.opener));
343
+ const nodes = this.nodes.sort((a, b) => a.pageId.localeCompare(b.pageId) || a.sequence - b.sequence);
344
+ linkPreviousNodes(nodes, (pageId) => this.pageInfo(pageId)?.openerPageId, (nodeId) => this.requestedAtMs.get(nodeId) ?? 0);
345
+ const failures = nodes.filter((item) => item.status === "failed");
346
+ const manifest = this.buildManifest(nodes, failures.length);
347
+ await this.writeManifest(manifest);
348
+ if (this.upload) await this.uploadManifest(manifest, this.upload);
349
+ if (failures.length && this.testInfo.status === "passed") throw new Error("Ralph could not capture " + failures.length + " page state(s). See the ralph-captures attachment.");
350
+ }
351
+ watchPage(page, contextId) {
352
+ if (this.pages.has(page)) return;
353
+ const state = {
354
+ page,
355
+ info: {
356
+ pageId: "page-" + this.pages.size,
357
+ contextId,
358
+ browserName: page.context().browser()?.browserType().name()
359
+ },
360
+ lastUrl: "",
361
+ generation: 0,
362
+ sequence: 0,
363
+ tail: Promise.resolve(),
364
+ opener: Promise.resolve(),
365
+ onNavigation: (frame) => {
366
+ if (frame === page.mainFrame()) this.urlChanged(state);
367
+ }
368
+ };
369
+ this.pages.set(page, state);
370
+ state.opener = page.opener().then((opener) => {
371
+ if (opener) state.info.openerPageId = this.pages.get(opener)?.info.pageId;
372
+ }).catch(() => void 0);
373
+ page.on("framenavigated", state.onNavigation);
374
+ this.urlChanged(state);
375
+ }
376
+ urlChanged(state) {
377
+ state.generation++;
378
+ const url = state.page.url();
379
+ if (url === state.lastUrl) return;
380
+ state.lastUrl = url;
381
+ if (isHttpUrl(url)) this.enqueue(state, "url-change", {}).catch(() => void 0);
382
+ }
383
+ /** Queues a capture behind the tab's earlier ones. */
384
+ enqueue(state, trigger, options) {
385
+ const actualUrl = state.page.url();
386
+ const url = options.url === void 0 ? actualUrl : new URL(options.url, actualUrl).href;
387
+ if (!isHttpUrl(actualUrl) || !isHttpUrl(url)) throw new Error("Ralph captures require HTTP or HTTPS page and recorded URLs.");
388
+ const request = {
389
+ nodeId: (0, node_crypto.randomUUID)(),
390
+ pageId: state.info.pageId,
391
+ sequence: state.sequence++,
392
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString(),
393
+ actualUrl,
394
+ url,
395
+ trigger,
396
+ ...options.state === void 0 ? {} : { state: options.state }
397
+ };
398
+ this.requestedAtMs.set(request.nodeId, Date.now());
399
+ const generation = state.generation;
400
+ const task = state.tail.then(() => this.record(state, generation, request));
401
+ state.tail = task.catch(() => void 0);
402
+ return task;
403
+ }
404
+ /**
405
+ * Takes one capture and records its outcome. Fails if the tab navigates or
406
+ * closes before it finishes, or if it runs past the capture timeout.
407
+ */
408
+ async record(state, generation, request) {
409
+ const { page } = state;
410
+ const deadline = startDeadline(this.captureTimeoutMs, "Ralph capture timed out.");
411
+ const { wait } = deadline;
412
+ const navigatedAway = () => state.generation !== generation || page.url() !== request.actualUrl;
413
+ const check = () => {
414
+ deadline.signal.throwIfAborted();
415
+ if (navigatedAway()) throw new Error("The page navigated before its capture completed.");
416
+ if (page.isClosed()) throw new Error("The page closed before its capture completed.");
417
+ };
418
+ try {
419
+ check();
420
+ if (request.trigger === "url-change") await this.waitForPageToSettle(page, deadline, check);
421
+ check();
422
+ const before = await wait(readLayout(page));
423
+ check();
424
+ const image = await wait(takeScreenshot(page, deadline.endsAt));
425
+ check();
426
+ const after = await wait(readLayout(page));
427
+ check();
428
+ this.nodes.push({
429
+ ...request,
430
+ status: "captured",
431
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
432
+ layout: before,
433
+ geometryStable: JSON.stringify(before) === JSON.stringify(after),
434
+ screenshot: await this.saveScreenshot(request.nodeId, image)
435
+ });
436
+ } catch (error) {
437
+ this.nodes.push({
438
+ ...request,
439
+ status: navigatedAway() ? "superseded" : "failed",
440
+ reason: error instanceof Error ? error.message : String(error)
441
+ });
442
+ if (request.trigger === "explicit") throw error;
443
+ } finally {
444
+ deadline.cancel();
445
+ }
446
+ }
447
+ /** After a navigation: the DOM, the settle delay, then the `ready` hook. */
448
+ async waitForPageToSettle(page, { wait, signal }, check) {
449
+ await wait(page.waitForLoadState("domcontentloaded", { timeout: this.captureTimeoutMs }));
450
+ await wait((0, node_timers_promises.setTimeout)(this.settleMs, void 0, { signal }));
451
+ check();
452
+ if (this.options.ready) await wait(this.options.ready(page, signal));
453
+ }
454
+ /** Writes the image to the test output and attaches it to the report. */
455
+ async saveScreenshot(nodeId, image) {
456
+ const path = "ralph/" + nodeId + imageExtension(image.contentType);
457
+ const attachmentName = "ralph-screenshot-" + nodeId;
458
+ await (0, node_fs_promises.mkdir)(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
459
+ await (0, node_fs_promises.writeFile)(this.testInfo.outputPath(path), image.bytes);
460
+ await this.testInfo.attach(attachmentName, {
461
+ path: this.testInfo.outputPath(path),
462
+ contentType: image.contentType
463
+ });
464
+ return {
465
+ path,
466
+ attachmentName,
467
+ width: image.width,
468
+ height: image.height,
469
+ scale: "css",
470
+ contentType: image.contentType
471
+ };
472
+ }
473
+ pageInfo(pageId) {
474
+ return [...this.pages.values()].find((state) => state.info.pageId === pageId)?.info;
475
+ }
476
+ buildManifest(nodes, failureCount) {
477
+ const { testInfo } = this;
478
+ const testPassed = testInfo.status === "passed";
479
+ return {
480
+ formatVersion: 1,
481
+ producer: {
482
+ name: "@ralphralphai/playwright",
483
+ version,
484
+ playwrightVersion: _playwright_test_package_json.default.version
485
+ },
486
+ runId: this.options.runId ?? process.env["RALPH_RUN_ID"],
487
+ buildId: this.options.buildId ?? process.env["RALPH_BUILD_ID"],
488
+ attemptId: this.attemptId,
489
+ test: {
490
+ id: testInfo.testId,
491
+ titlePath: testInfo.titlePath,
492
+ project: testInfo.project.name,
493
+ retry: testInfo.retry,
494
+ repeatEachIndex: testInfo.repeatEachIndex,
495
+ workerIndex: testInfo.workerIndex,
496
+ parallelIndex: testInfo.parallelIndex,
497
+ shard: testInfo.config.shard,
498
+ status: failureCount && testPassed ? "failed" : testInfo.status ?? "unknown",
499
+ expectedStatus: testInfo.expectedStatus
500
+ },
501
+ startedAt: this.startedAt,
502
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
503
+ config: this.config,
504
+ pages: [...this.pages.values()].map((state) => state.info),
505
+ nodes,
506
+ complete: testPassed && nodes.length > 0 && nodes.every((item) => item.status === "captured" && item.geometryStable)
507
+ };
508
+ }
509
+ async writeManifest(manifest) {
510
+ const file = this.testInfo.outputPath(OUTPUT_DIR, "captures.json");
511
+ await (0, node_fs_promises.mkdir)(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
512
+ await (0, node_fs_promises.writeFile)(file, JSON.stringify(manifest, null, 2) + "\n");
513
+ await this.testInfo.attach(MANIFEST_ATTACHMENT, {
514
+ path: file,
515
+ contentType: "application/json"
516
+ });
517
+ }
518
+ async uploadManifest(manifest, upload) {
519
+ const captured = manifest.nodes.filter((item) => item.status === "captured");
520
+ const screenshots = [];
521
+ for (const { nodeId, screenshot } of captured) screenshots.push({
522
+ nodeId,
523
+ contentType: screenshot.contentType,
524
+ bytes: await (0, node_fs_promises.readFile)(this.testInfo.outputPath(screenshot.path))
525
+ });
526
+ const receipt = await uploadCapture(manifest, screenshots, upload);
527
+ await this.testInfo.attach("ralph-upload-receipt", {
528
+ body: JSON.stringify(receipt),
529
+ contentType: "application/json"
530
+ });
531
+ }
532
+ };
533
+ function isHttpUrl(url) {
534
+ return /^https?:/.test(url);
535
+ }
536
+ function startDeadline(timeoutMs, message) {
537
+ const controller = new AbortController();
538
+ const { signal } = controller;
539
+ const timer = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
540
+ const aborted = new Promise((_, reject) => {
541
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
542
+ });
543
+ aborted.catch(() => void 0);
544
+ return {
545
+ signal,
546
+ endsAt: Date.now() + timeoutMs,
547
+ wait: (promise) => Promise.race([promise, aborted]),
548
+ cancel: () => clearTimeout(timer)
549
+ };
550
+ }
551
+ /**
552
+ * Sets `previousNodeId` on each node: the one before it in the same
553
+ * tab, or for a popup's first capture, its opener's latest. Superseded captures
554
+ * are skipped, since their page state was never shown.
555
+ *
556
+ * `nodes` must be sorted by page, then sequence.
557
+ */
558
+ function linkPreviousNodes(nodes, openerOf, requestedAtMs) {
559
+ const byPage = /* @__PURE__ */ new Map();
560
+ for (const item of nodes) byPage.set(item.pageId, [...byPage.get(item.pageId) ?? [], item]);
561
+ const openerNodeBefore = (item) => {
562
+ const openerPageId = openerOf(item.pageId);
563
+ const requestedAt = requestedAtMs(item.nodeId);
564
+ return byPage.get(openerPageId ?? "")?.filter((candidate) => requestedAtMs(candidate.nodeId) <= requestedAt).at(-1);
565
+ };
566
+ const previous = /* @__PURE__ */ new Map();
567
+ for (const items of byPage.values()) items.forEach((item, index) => {
568
+ const from = index > 0 ? items[index - 1] : openerNodeBefore(item);
569
+ if (from) previous.set(item, from);
570
+ });
571
+ for (const item of nodes) {
572
+ let from = previous.get(item);
573
+ while (from?.status === "superseded") from = previous.get(from);
574
+ if (from) item.previousNodeId = from.nodeId;
575
+ }
576
+ }
577
+ //#endregion
578
+ //#region src/screens.ts
579
+ const SCREEN_TAG_PREFIX = "@ralph-screen:";
580
+ function screenLabel(size) {
581
+ return size.width + "x" + size.height;
582
+ }
583
+ /**
584
+ * Expands `base` into one project per `screenSizes` entry of the Ralph config.
585
+ * Each project's viewport is that screen size.
586
+ */
587
+ function ralphProjects(base = {}, options = {}) {
588
+ const { screenSizes } = loadConfigSync(options, options.root ?? process.cwd()).config;
589
+ if (!screenSizes.length) throw new Error("The Ralph config declares no screenSizes.");
590
+ return screenSizes.map((screenSize) => {
591
+ const label = screenLabel(screenSize);
592
+ const ralph = {
593
+ screenSize,
594
+ screenSizes
595
+ };
596
+ return {
597
+ ...base,
598
+ name: base.name ? base.name + " " + label : label,
599
+ use: {
600
+ ...base.use,
601
+ viewport: {
602
+ width: screenSize.width,
603
+ height: screenSize.height
604
+ }
605
+ },
606
+ metadata: {
607
+ ...base.metadata,
608
+ ralph
609
+ }
610
+ };
611
+ });
612
+ }
613
+ /**
614
+ * Skips a test whose `@ralph-screen:WxH` tags exclude the current project's
615
+ * screen size. Untagged tests run at every size; other projects are untouched.
616
+ */
617
+ function applyScreenSelection(testInfo) {
618
+ const selected = testInfo.tags.filter((tag) => tag.startsWith(SCREEN_TAG_PREFIX)).map((tag) => tag.slice(14));
619
+ const metadata = testInfo.project.metadata.ralph;
620
+ if (!selected.length || !metadata) return;
621
+ const known = metadata.screenSizes.map(screenLabel);
622
+ const unknown = selected.filter((label) => !known.includes(label));
623
+ if (unknown.length) throw new Error("Unknown Ralph screen size " + unknown.join(", ") + "; the config declares " + known.join(", ") + ".");
624
+ const current = screenLabel(metadata.screenSize);
625
+ testInfo.skip(!selected.includes(current), "Not selected for Ralph screen size " + current + ".");
626
+ }
627
+ //#endregion
628
+ //#region src/index.ts
629
+ const test = _playwright_test.test.extend({
630
+ ralphOptions: [{}, { option: true }],
631
+ _ralphConfigs: [async ({}, use) => {
632
+ await use(/* @__PURE__ */ new Map());
633
+ }, { scope: "worker" }],
634
+ _ralphScreen: [async ({}, use, testInfo) => {
635
+ applyScreenSelection(testInfo);
636
+ await use();
637
+ }, { auto: true }],
638
+ _ralphRecorder: [async ({ ralphOptions, _ralphConfigs, _ralphScreen }, use, testInfo) => {
639
+ if (!testInfo.tags.includes("@ralph") || resolveMode(ralphOptions) === "off") {
640
+ await use(null);
641
+ return;
642
+ }
643
+ const upload = resolveMode(ralphOptions) === "upload" ? resolveUploadOptions(ralphOptions) : void 0;
644
+ const root = testInfo.config.configFile ? node_path.default.dirname(testInfo.config.configFile) : process.cwd();
645
+ const key = JSON.stringify({
646
+ root,
647
+ config: ralphOptions.config,
648
+ configPath: ralphOptions.configPath
649
+ });
650
+ let pending = _ralphConfigs.get(key);
651
+ if (!pending) {
652
+ pending = loadConfig(ralphOptions, root);
653
+ _ralphConfigs.set(key, pending);
654
+ }
655
+ const recorder = new Recorder(ralphOptions, await pending, testInfo, upload);
656
+ try {
657
+ await use(recorder);
658
+ } finally {
659
+ await recorder.finish();
660
+ }
661
+ }, {
662
+ auto: true,
663
+ timeout: 3e4
664
+ }],
665
+ ralph: async ({ _ralphRecorder, ralphOptions }, use) => {
666
+ const checkOptIn = () => {
667
+ if (resolveMode(ralphOptions) !== "off") throw new Error("Opt this test into Ralph with the @ralph tag.");
668
+ };
669
+ await use(_ralphRecorder ?? {
670
+ captureNode: async () => {
671
+ checkOptIn();
672
+ },
673
+ flush: async () => {},
674
+ observe: () => {
675
+ checkOptIn();
676
+ }
677
+ });
678
+ },
679
+ context: async ({ context, _ralphRecorder }, use) => {
680
+ _ralphRecorder?.observe(context);
681
+ try {
682
+ await use(context);
683
+ } finally {
684
+ await _ralphRecorder?.stopContext(context);
685
+ }
686
+ }
687
+ });
688
+ //#endregion
689
+ exports.MANIFEST_ATTACHMENT = MANIFEST_ATTACHMENT;
690
+ exports.SCREEN_TAG_PREFIX = SCREEN_TAG_PREFIX;
691
+ exports.createCaptureBundle = createCaptureBundle;
692
+ Object.defineProperty(exports, "expect", {
693
+ enumerable: true,
694
+ get: function() {
695
+ return _playwright_test.expect;
696
+ }
697
+ });
698
+ Object.defineProperty(exports, "mergeTests", {
699
+ enumerable: true,
700
+ get: function() {
701
+ return _playwright_test.mergeTests;
702
+ }
703
+ });
704
+ exports.ralphProjects = ralphProjects;
705
+ exports.test = test;
706
+ exports.uploadCapture = uploadCapture;
707
+
708
+ //# sourceMappingURL=index.cjs.map