@ralphralphai/playwright 0.2.0 → 1.2.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.
@@ -0,0 +1,702 @@
1
+ import path from "node:path";
2
+ import { errors } from "@playwright/test";
3
+ import { readFileSync } from "node:fs";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import configPackage from "@ralphralphai/config/package.json" with { type: "json" };
6
+ import { WebRalphConfigSchema } from "@ralphralphai/config/zod";
7
+ import { parse, printParseErrorCode } from "jsonc-parser";
8
+ import { randomUUID } from "node:crypto";
9
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
10
+ import playwrightPackage from "@playwright/test/package.json" with { type: "json" };
11
+ import { Readable } from "node:stream";
12
+ import { pipeline } from "node:stream/promises";
13
+ import { createGzip } from "node:zlib";
14
+ import { pack } from "tar-stream";
15
+ //#region src/config.ts
16
+ function resolveMode(options) {
17
+ const mode = options.mode ?? process.env["RALPH_MODE"] ?? (process.env["RALPH_UPLOAD_KEY"] ? "upload" : "off");
18
+ if (mode === "off" || mode === "local" || mode === "upload") return mode;
19
+ throw new Error("RALPH_MODE must be off, local, or upload.");
20
+ }
21
+ async function loadConfig(options, root) {
22
+ const file = configFile(options, root);
23
+ return snapshot(options.config ?? parseConfig(await readFile(file, "utf8")));
24
+ }
25
+ function loadConfigSync(options, root) {
26
+ const file = configFile(options, root);
27
+ return snapshot(options.config ?? parseConfig(readFileSync(file, "utf8")));
28
+ }
29
+ function configFile(options, root) {
30
+ if (options.config && options.configPath) throw new Error("Provide either ralphOptions.config or configPath, not both.");
31
+ return path.resolve(root, options.configPath ?? "ralph.jsonc");
32
+ }
33
+ function parseConfig(text) {
34
+ const errors = [];
35
+ const value = parse(text, errors, { allowTrailingComma: true });
36
+ if (errors.length) throw new Error("Invalid Ralph config: " + errors.map((error) => printParseErrorCode(error.error) + " at " + error.offset).join(", "));
37
+ return value;
38
+ }
39
+ function snapshot(value) {
40
+ WebRalphConfigSchema.parse(value);
41
+ return {
42
+ config: JSON.parse(JSON.stringify(value)),
43
+ packageVersion: configPackage.version
44
+ };
45
+ }
46
+ //#endregion
47
+ //#region package.json
48
+ var version = "1.2.0";
49
+ //#endregion
50
+ //#region src/animations.ts
51
+ /**
52
+ * Waits until no finite CSS animation, CSS transition, or Web Animation is
53
+ * running. Infinite ones (spinners, shimmers) never finish, so they are
54
+ * ignored. Animations driven by `requestAnimationFrame` are invisible here.
55
+ *
56
+ * Stops waiting after `timeoutMs` without failing; the screenshot then jumps
57
+ * whatever is left to its end state.
58
+ */
59
+ async function waitForAnimations(page, timeoutMs) {
60
+ try {
61
+ await page.waitForFunction(() => document.getAnimations().every((animation) => animation.playState !== "running" || animation.playbackRate === 0 || !Number.isFinite(animation.effect?.getComputedTiming().endTime)), void 0, {
62
+ polling: "raf",
63
+ timeout: Math.max(1, timeoutMs)
64
+ });
65
+ } catch (error) {
66
+ if (!(error instanceof errors.TimeoutError)) throw error;
67
+ }
68
+ }
69
+ //#endregion
70
+ //#region src/image.ts
71
+ /**
72
+ * Matches the crawler's full-page screenshots: lossless measured larger than this
73
+ * on real sites, which are mostly photographic content.
74
+ */
75
+ const WEBP_QUALITY = 80;
76
+ async function takeScreenshot(page, deadline) {
77
+ const options = {
78
+ fullPage: true,
79
+ scale: "css",
80
+ animations: "disabled"
81
+ };
82
+ const webp = await page.screenshot({
83
+ ...options,
84
+ type: "webp",
85
+ quality: WEBP_QUALITY,
86
+ timeout: Math.max(1, deadline - Date.now())
87
+ });
88
+ if (webp.length > 0) return {
89
+ bytes: webp,
90
+ contentType: "image/webp",
91
+ ...webpSize(webp)
92
+ };
93
+ const png = await page.screenshot({
94
+ ...options,
95
+ type: "png",
96
+ timeout: Math.max(1, deadline - Date.now())
97
+ });
98
+ return {
99
+ bytes: png,
100
+ contentType: "image/png",
101
+ width: png.readUInt32BE(16),
102
+ height: png.readUInt32BE(20)
103
+ };
104
+ }
105
+ function imageExtension(contentType) {
106
+ return contentType === "image/png" ? ".png" : ".webp";
107
+ }
108
+ function webpSize(bytes) {
109
+ if (bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") throw new Error("The screenshot is not a WebP image.");
110
+ const chunk = bytes.toString("ascii", 12, 16);
111
+ if (chunk === "VP8X") return {
112
+ width: 1 + bytes.readUIntLE(24, 3),
113
+ height: 1 + bytes.readUIntLE(27, 3)
114
+ };
115
+ if (chunk === "VP8 ") return {
116
+ width: bytes.readUInt16LE(26) & 16383,
117
+ height: bytes.readUInt16LE(28) & 16383
118
+ };
119
+ if (chunk === "VP8L") {
120
+ const bits = bytes.readUInt32LE(21);
121
+ return {
122
+ width: 1 + (bits & 16383),
123
+ height: 1 + (bits >> 14 & 16383)
124
+ };
125
+ }
126
+ throw new Error("Unsupported WebP chunk: " + chunk);
127
+ }
128
+ //#endregion
129
+ //#region src/layout.ts
130
+ function readLayout(page) {
131
+ return page.locator("[data-track-id]").filter({ visible: true }).evaluateAll((elements) => ({
132
+ viewport: {
133
+ width: window.innerWidth,
134
+ height: window.innerHeight
135
+ },
136
+ document: {
137
+ width: Math.max(document.documentElement.scrollWidth, document.documentElement.clientWidth, document.body?.scrollWidth ?? 0),
138
+ height: Math.max(document.documentElement.scrollHeight, document.documentElement.clientHeight, document.body?.scrollHeight ?? 0)
139
+ },
140
+ scroll: {
141
+ x: window.scrollX,
142
+ y: window.scrollY
143
+ },
144
+ devicePixelRatio: window.devicePixelRatio,
145
+ trackedElements: elements.flatMap((element) => {
146
+ const trackId = element.getAttribute("data-track-id");
147
+ const rect = element.getBoundingClientRect();
148
+ if (!trackId || rect.width <= 0 || rect.height <= 0 || rect.right <= 0 || rect.left >= window.innerWidth) return [];
149
+ return [{
150
+ trackId,
151
+ x: rect.x + window.scrollX,
152
+ y: rect.y + window.scrollY,
153
+ width: rect.width,
154
+ height: rect.height
155
+ }];
156
+ })
157
+ }));
158
+ }
159
+ //#endregion
160
+ //#region src/recorder.ts
161
+ /** The attachment holding a test's `RawGraphFile`, which the reporter merges. */
162
+ const MANIFEST_ATTACHMENT = "ralph-raw-graph";
163
+ /**
164
+ * Set by the reporter in the main process before any worker starts, so the
165
+ * fixtures can refuse to upload without it.
166
+ */
167
+ const REPORTER_ENV = "RALPH_PLAYWRIGHT_REPORTER";
168
+ /** Prefix of the attachment holding each recorded node's screenshot; the node id follows. */
169
+ const SCREENSHOT_ATTACHMENT_PREFIX = "ralph-screenshot-";
170
+ const DEFAULT_SETTLE_MS = 150;
171
+ const DEFAULT_RECORD_TIMEOUT_MS = 5e3;
172
+ /** Where the raw graph and screenshots are written, inside Playwright's per-test output directory. */
173
+ const OUTPUT_DIR = "ralph";
174
+ var Recorder = class {
175
+ options;
176
+ config;
177
+ testInfo;
178
+ mode;
179
+ startedAt = (/* @__PURE__ */ new Date()).toISOString();
180
+ /** How long to wait after a navigation's DOM loads before recording. */
181
+ settleMs;
182
+ /**
183
+ * The budget for recording one node, from settling through the screenshot.
184
+ * Past it, the node is recorded as failed.
185
+ */
186
+ recordTimeoutMs;
187
+ /**
188
+ * Every context being watched, with its page listener, kept so
189
+ * `stopContext` can remove the exact listener `observe` added.
190
+ */
191
+ contexts = /* @__PURE__ */ new Map();
192
+ /**
193
+ * Every tab seen in a watched context, including closed ones, so their
194
+ * nodes and opener links still reach the manifest.
195
+ */
196
+ pages = /* @__PURE__ */ new Map();
197
+ /**
198
+ * Each node's outcome, in completion order. Nodes in one tab complete
199
+ * in sequence, but tabs interleave; `finish` sorts them.
200
+ */
201
+ nodes = [];
202
+ /**
203
+ * When each node was requested, keyed by node id, for linking a
204
+ * popup's first node to its opener's latest. Numbers, so they compare
205
+ * without parsing each node's `requestedAt` string.
206
+ */
207
+ requestedAtMs = /* @__PURE__ */ new Map();
208
+ /** Set by `finish`, after which `observe` throws. */
209
+ closed = false;
210
+ /** Set by `pause`; while set, no new nodes are requested. */
211
+ paused = false;
212
+ /** The most recently requested node, the anchor for tabs used while paused. */
213
+ lastRequestedNodeId;
214
+ constructor(options, config, testInfo, mode) {
215
+ this.options = options;
216
+ this.config = config;
217
+ this.testInfo = testInfo;
218
+ this.mode = mode;
219
+ this.settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
220
+ this.recordTimeoutMs = options.recordTimeoutMs ?? DEFAULT_RECORD_TIMEOUT_MS;
221
+ if (!(Number.isFinite(this.settleMs) && this.settleMs >= 0 && Number.isFinite(this.recordTimeoutMs) && this.recordTimeoutMs > this.settleMs)) throw new Error("Ralph recordTimeoutMs must be finite and greater than settleMs, which must be non-negative.");
222
+ }
223
+ /** Starts recording URL changes in every current and future tab of `context`. */
224
+ observe(context) {
225
+ if (this.closed) throw new Error("The Ralph recorder has finished.");
226
+ if (this.contexts.has(context)) return;
227
+ const onPage = (page) => this.watchPage(page);
228
+ this.contexts.set(context, onPage);
229
+ context.on("page", onPage);
230
+ for (const page of context.pages()) onPage(page);
231
+ }
232
+ /** Records the page as it is now, e.g. an open dialog at the same URL. */
233
+ async recordNode(page, options = {}) {
234
+ this.observe(page.context());
235
+ const state = this.pages.get(page);
236
+ if (!state) throw new Error("Cannot record a closed browser page.");
237
+ if (this.paused) return;
238
+ await this.enqueueCreateNodeRequest(state, "explicit", options);
239
+ }
240
+ /**
241
+ * Stops requesting nodes, automatic or explicit, until `resume`. Recordings
242
+ * already requested still complete. The next node after `resume` links to
243
+ * the last one before `pause`, so the skipped steps drop out of the graph.
244
+ */
245
+ pause() {
246
+ this.paused = true;
247
+ }
248
+ /** Undoes `pause`. The current page is not recorded until its URL changes. */
249
+ resume() {
250
+ this.paused = false;
251
+ }
252
+ /** Waits until every page recording requested so far has finished. */
253
+ async waitForCapture() {
254
+ await Promise.all([...this.pages.values()].map((state) => state.tail));
255
+ }
256
+ /** Stops watching `context` and waits for its queued recordings. */
257
+ async stopContext(context) {
258
+ const onPage = this.contexts.get(context);
259
+ if (onPage) context.off("page", onPage);
260
+ const states = [...this.pages.values()].filter((state) => state.page.context() === context);
261
+ for (const state of states) state.page.off("framenavigated", state.onNavigation);
262
+ await Promise.all(states.map((state) => state.tail));
263
+ }
264
+ /**
265
+ * Stops recording, then writes the test's raw graph for the reporter, and
266
+ * fails a passing test whose nodes failed to record.
267
+ */
268
+ async finish() {
269
+ this.closed = true;
270
+ await Promise.all([...this.contexts.keys()].map((context) => this.stopContext(context)));
271
+ await Promise.all([...this.pages.values()].map((state) => state.opener));
272
+ const nodes = this.nodes.sort((a, b) => a.pageId.localeCompare(b.pageId) || a.sequence - b.sequence);
273
+ linkPreviousNodes(nodes, (pageId) => this.pageInfo(pageId)?.openerPageId, (nodeId) => this.requestedAtMs.get(nodeId) ?? 0, (pageId) => this.pageState(pageId)?.pauseAnchor);
274
+ const failures = nodes.filter((item) => item.status === "uncaptured" && item.reason === "error");
275
+ await this.writeRawGraph(this.buildRawGraph(nodes, failures.length));
276
+ if (failures.length && this.testInfo.status === "passed") throw new Error("Ralph could not record " + failures.length + " page state(s). See the ralph-raw-graph attachment.");
277
+ }
278
+ watchPage(page) {
279
+ if (this.pages.has(page)) return;
280
+ const state = {
281
+ page,
282
+ info: {
283
+ pageId: "page-" + this.pages.size,
284
+ browserName: page.context().browser()?.browserType().name()
285
+ },
286
+ lastUrl: "",
287
+ generation: 0,
288
+ sequence: 0,
289
+ tail: Promise.resolve(),
290
+ opener: Promise.resolve(),
291
+ onNavigation: (frame) => {
292
+ if (frame === page.mainFrame()) this.urlChanged(state);
293
+ }
294
+ };
295
+ this.pages.set(page, state);
296
+ state.opener = page.opener().then((opener) => {
297
+ if (opener) {
298
+ const openerState = this.pages.get(opener);
299
+ state.info.openerPageId = openerState?.info.pageId;
300
+ state.pauseAnchor ??= openerState?.pauseAnchor;
301
+ }
302
+ }).catch(() => void 0);
303
+ page.on("framenavigated", state.onNavigation);
304
+ this.urlChanged(state);
305
+ }
306
+ urlChanged(state) {
307
+ if (this.paused && state.sequence === 0) state.pauseAnchor ??= this.lastRequestedNodeId;
308
+ const url = state.page.url();
309
+ if (url === state.lastUrl) return;
310
+ state.generation++;
311
+ state.lastUrl = url;
312
+ if (!this.paused && isHttpUrl(url)) this.enqueueCreateNodeRequest(state, "url-change", {}).catch(() => void 0);
313
+ }
314
+ /** Queues a recording behind the tab's earlier ones. */
315
+ enqueueCreateNodeRequest(state, trigger, options) {
316
+ const actualUrl = state.page.url();
317
+ const url = options.url === void 0 ? actualUrl : new URL(options.url, actualUrl).href;
318
+ if (!isHttpUrl(actualUrl) || !isHttpUrl(url)) throw new Error("Ralph recordings require HTTP or HTTPS page and recorded URLs.");
319
+ const request = {
320
+ nodeId: randomUUID(),
321
+ pageId: state.info.pageId,
322
+ sequence: state.sequence++,
323
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString(),
324
+ actualUrl,
325
+ url,
326
+ trigger,
327
+ ...options.state === void 0 ? {} : { state: options.state }
328
+ };
329
+ this.requestedAtMs.set(request.nodeId, Date.now());
330
+ this.lastRequestedNodeId = request.nodeId;
331
+ const generation = state.generation;
332
+ const task = state.tail.then(() => this.createNode(state, generation, request));
333
+ state.tail = task.catch(() => void 0);
334
+ return task;
335
+ }
336
+ /**
337
+ * Creates the requested node: `recorded`, or `uncaptured` if the tab
338
+ * navigates or closes first, or the record timeout passes.
339
+ */
340
+ async createNode(state, generation, request) {
341
+ const { page } = state;
342
+ const deadline = startDeadline(this.recordTimeoutMs, "Ralph recording timed out.");
343
+ const { wait } = deadline;
344
+ const navigatedAway = () => state.generation !== generation || page.url() !== request.actualUrl;
345
+ const check = () => {
346
+ deadline.signal.throwIfAborted();
347
+ if (navigatedAway()) throw new Error("The page navigated before its recording completed.");
348
+ if (page.isClosed()) throw new Error("The page closed before its recording completed.");
349
+ };
350
+ try {
351
+ check();
352
+ if (request.trigger === "url-change") await this.waitForPageToSettle(page, deadline, check);
353
+ if (this.options.reduceMotion) {
354
+ check();
355
+ await wait(waitForAnimations(page, (deadline.endsAt - Date.now()) / 2));
356
+ }
357
+ check();
358
+ const before = await wait(readLayout(page));
359
+ check();
360
+ const image = await wait(takeScreenshot(page, deadline.endsAt));
361
+ check();
362
+ const after = await wait(readLayout(page));
363
+ check();
364
+ this.nodes.push({
365
+ ...request,
366
+ status: "recorded",
367
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
368
+ layout: before,
369
+ geometryStable: JSON.stringify(before) === JSON.stringify(after),
370
+ screenshot: await this.saveScreenshot(request.nodeId, image)
371
+ });
372
+ } catch (error) {
373
+ this.nodes.push({
374
+ ...request,
375
+ status: "uncaptured",
376
+ reason: navigatedAway() ? "navigated-away" : "error",
377
+ message: error instanceof Error ? error.message : String(error)
378
+ });
379
+ if (request.trigger === "explicit") throw error;
380
+ } finally {
381
+ deadline.cancel();
382
+ }
383
+ }
384
+ /** After a navigation: the DOM, the settle delay, then the `ready` hook. */
385
+ async waitForPageToSettle(page, { wait, signal }, check) {
386
+ await wait(page.waitForLoadState("domcontentloaded", { timeout: this.recordTimeoutMs }));
387
+ await wait(setTimeout$1(this.settleMs, void 0, { signal }));
388
+ check();
389
+ if (this.options.ready) await wait(this.options.ready(page, signal));
390
+ }
391
+ /** Writes the image to the test output and attaches it to the report. */
392
+ async saveScreenshot(nodeId, image) {
393
+ const path = "ralph/" + nodeId + imageExtension(image.contentType);
394
+ const attachmentName = SCREENSHOT_ATTACHMENT_PREFIX + nodeId;
395
+ await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
396
+ await writeFile(this.testInfo.outputPath(path), image.bytes);
397
+ await this.testInfo.attach(attachmentName, {
398
+ ...this.testInfo.config.preserveOutput === "always" ? { path: this.testInfo.outputPath(path) } : { body: image.bytes },
399
+ contentType: image.contentType
400
+ });
401
+ return {
402
+ path,
403
+ attachmentName,
404
+ width: image.width,
405
+ height: image.height,
406
+ scale: "css",
407
+ contentType: image.contentType
408
+ };
409
+ }
410
+ pageState(pageId) {
411
+ return [...this.pages.values()].find((state) => state.info.pageId === pageId);
412
+ }
413
+ pageInfo(pageId) {
414
+ return this.pageState(pageId)?.info;
415
+ }
416
+ buildRawGraph(nodes, failureCount) {
417
+ const { testInfo } = this;
418
+ const testPassed = testInfo.status === "passed";
419
+ return {
420
+ formatVersion: 1,
421
+ producer: {
422
+ name: "@ralphralphai/playwright",
423
+ version,
424
+ playwrightVersion: playwrightPackage.version
425
+ },
426
+ runId: this.options.runId ?? process.env["RALPH_RUN_ID"],
427
+ buildId: this.options.buildId ?? process.env["RALPH_BUILD_ID"],
428
+ config: this.config,
429
+ mode: this.mode,
430
+ rawGraph: {
431
+ id: testInfo.testId,
432
+ titlePath: testInfo.titlePath,
433
+ project: testInfo.project.name,
434
+ retry: testInfo.retry,
435
+ repeatEachIndex: testInfo.repeatEachIndex,
436
+ workerIndex: testInfo.workerIndex,
437
+ parallelIndex: testInfo.parallelIndex,
438
+ status: failureCount && testPassed ? "failed" : testInfo.status ?? "unknown",
439
+ expectedStatus: testInfo.expectedStatus,
440
+ startedAt: this.startedAt,
441
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
442
+ pages: [...this.pages.values()].map((state) => state.info),
443
+ nodes,
444
+ complete: testPassed && nodes.length > 0 && nodes.every((item) => item.status === "recorded" && item.geometryStable)
445
+ }
446
+ };
447
+ }
448
+ async writeRawGraph(file) {
449
+ const path = this.testInfo.outputPath(OUTPUT_DIR, "raw_graph.json");
450
+ const body = JSON.stringify(file, null, 2) + "\n";
451
+ await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
452
+ await writeFile(path, body);
453
+ await this.testInfo.attach(MANIFEST_ATTACHMENT, {
454
+ ...this.testInfo.config.preserveOutput === "always" ? { path } : { body },
455
+ contentType: "application/json"
456
+ });
457
+ }
458
+ };
459
+ function isHttpUrl(url) {
460
+ return /^https?:/.test(url);
461
+ }
462
+ function startDeadline(timeoutMs, message) {
463
+ const controller = new AbortController();
464
+ const { signal } = controller;
465
+ const timer = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
466
+ const aborted = new Promise((_, reject) => {
467
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
468
+ });
469
+ aborted.catch(() => void 0);
470
+ return {
471
+ signal,
472
+ endsAt: Date.now() + timeoutMs,
473
+ wait: (promise) => Promise.race([promise, aborted]),
474
+ cancel: () => clearTimeout(timer)
475
+ };
476
+ }
477
+ /**
478
+ * Sets `previousNodeId` on each node: the one before it in the same
479
+ * tab, or for a popup's first node, its opener's latest. Failing both, a tab
480
+ * first used while paused links to the last node before the pause.
481
+ * Uncaptured nodes stay in the chain, as regular nodes in the server's graph.
482
+ *
483
+ * `nodes` must be sorted by page, then sequence.
484
+ */
485
+ function linkPreviousNodes(nodes, openerOf, requestedAtMs, pauseAnchorOf) {
486
+ const byPage = /* @__PURE__ */ new Map();
487
+ const byId = /* @__PURE__ */ new Map();
488
+ for (const item of nodes) {
489
+ byPage.set(item.pageId, [...byPage.get(item.pageId) ?? [], item]);
490
+ byId.set(item.nodeId, item);
491
+ }
492
+ const openerNodeBefore = (item) => {
493
+ const openerPageId = openerOf(item.pageId);
494
+ const requestedAt = requestedAtMs(item.nodeId);
495
+ return byPage.get(openerPageId ?? "")?.filter((candidate) => requestedAtMs(candidate.nodeId) <= requestedAt).at(-1);
496
+ };
497
+ for (const items of byPage.values()) items.forEach((item, index) => {
498
+ const from = index > 0 ? items[index - 1] : openerNodeBefore(item) ?? byId.get(pauseAnchorOf(item.pageId) ?? "");
499
+ if (from) item.previousNodeId = from.nodeId;
500
+ });
501
+ }
502
+ /** Where the uploaded run opens: the server's link, else the Ralph dashboard. */
503
+ function resultLink({ artifactId, resultUrl }) {
504
+ return resultUrl ?? `https://dash.ralphralph.ai/uploads/${encodeURIComponent(artifactId)}`;
505
+ }
506
+ /** Where the reporter uploads: its options, else the environment, else Ralph's production server. */
507
+ function resolveUploadOptions(options) {
508
+ const uploadKey = process.env["RALPH_UPLOAD_KEY"];
509
+ const apiUrl = options.apiUrl || process.env["RALPH_API_URL"] || "https://api.ralphralph.ai";
510
+ const appId = options.appId ?? process.env["RALPH_APP_ID"];
511
+ if (!uploadKey) throw new Error(`To upload to Ralph, set RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`);
512
+ if (!appId) throw new Error("To upload to Ralph, set RALPH_APP_ID (or the reporter option appId) to the app the recordings belong to.");
513
+ return {
514
+ uploadKey,
515
+ apiUrl,
516
+ appId,
517
+ timeoutMs: options.uploadTimeoutMs
518
+ };
519
+ }
520
+ const RAW_GRAPH_BUNDLE_INDEX = "raw_graph.json";
521
+ const RAW_GRAPH_BUNDLE_IMAGE_DIR = "assets/images";
522
+ /** Where a node's screenshot sits inside the bundle. */
523
+ function bundleImagePath(nodeId, contentType) {
524
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
525
+ return `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`;
526
+ }
527
+ const TOO_BIG = "This run is too big to upload to Ralph. Record fewer pages or screen sizes, or split the tests across runs.";
528
+ var TooBigError = class extends Error {
529
+ constructor() {
530
+ super(TOO_BIG);
531
+ }
532
+ };
533
+ /**
534
+ * Packs the manifest and screenshots into a gzipped tar archive, produced as it
535
+ * is read so the compressed bundle is never held whole:
536
+ *
537
+ * raw_graph.json { manifest, screenshots: [{ nodeId, path }] }
538
+ * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
539
+ *
540
+ * Fails with a `TooBigError` once the compressed bytes pass the limit.
541
+ */
542
+ function rawGraphBundleStream(manifest, screenshots) {
543
+ if (Buffer.byteLength(JSON.stringify(manifest)) > 16777216 || screenshots.length > 5e3 || screenshots.reduce((total, screenshot) => total + screenshot.bytes.byteLength, 0) > 268435456) throw new TooBigError();
544
+ const images = screenshots.map(({ nodeId, contentType, bytes }) => ({
545
+ nodeId,
546
+ path: bundleImagePath(nodeId, contentType),
547
+ bytes
548
+ }));
549
+ const index = {
550
+ manifest,
551
+ screenshots: images.map(({ nodeId, path }) => ({
552
+ nodeId,
553
+ path
554
+ }))
555
+ };
556
+ const attrs = {
557
+ mtime: /* @__PURE__ */ new Date(0),
558
+ uid: 0,
559
+ gid: 0
560
+ };
561
+ const tar = pack();
562
+ const onEntryError = (error) => tar.destroy(error);
563
+ tar.entry({
564
+ name: RAW_GRAPH_BUNDLE_INDEX,
565
+ ...attrs
566
+ }, JSON.stringify(index)).on("error", onEntryError);
567
+ for (const { path, bytes } of images) tar.entry({
568
+ name: path,
569
+ ...attrs
570
+ }, Buffer.from(bytes)).on("error", onEntryError);
571
+ tar.finalize();
572
+ const gzip = createGzip();
573
+ pipeline(tar, gzip).catch((error) => gzip.destroy(error));
574
+ return Readable.from((async function* () {
575
+ let size = 0;
576
+ for await (const chunk of gzip) {
577
+ size += chunk.byteLength;
578
+ if (size > 268435456) throw new TooBigError();
579
+ yield chunk;
580
+ }
581
+ })());
582
+ }
583
+ /** The bundle `uploadRawGraph` streams, whole, for saving or inspecting it. */
584
+ async function createRawGraphBundle(manifest, screenshots) {
585
+ const chunks = [];
586
+ for await (const chunk of rawGraphBundleStream(manifest, screenshots)) chunks.push(chunk);
587
+ return Buffer.concat(chunks);
588
+ }
589
+ function isLoopback(url) {
590
+ return url.hostname === "localhost" || url.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(url.hostname);
591
+ }
592
+ function checkedApiUrl(options, path) {
593
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) throw new Error(`"${options.appId}" isn't a valid Ralph app id. Check RALPH_APP_ID.`);
594
+ const url = new URL(path, options.apiUrl);
595
+ if (!(url.protocol === "https:" || url.protocol === "http:" && isLoopback(url))) throw new Error(`RALPH_API_URL must start with https:// (http:// only works for localhost), not ${options.apiUrl}.`);
596
+ if (!options.uploadKey) throw new Error(`To upload to Ralph, set RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`);
597
+ return url;
598
+ }
599
+ /** Sends one request, turning every way it can fail into a message that says what to fix. */
600
+ async function request(url, init, options, timeoutMs) {
601
+ let response;
602
+ try {
603
+ response = await fetch(url, {
604
+ ...init,
605
+ method: "POST",
606
+ headers: {
607
+ ...init.headers,
608
+ Authorization: `Bearer ${options.uploadKey}`
609
+ },
610
+ redirect: "error",
611
+ signal: AbortSignal.timeout(timeoutMs)
612
+ });
613
+ } catch (error) {
614
+ if (error instanceof TooBigError || error instanceof Error && error.cause instanceof TooBigError) throw new Error(TOO_BIG, { cause: error });
615
+ throw new Error(describeRequestFailure(error, url, timeoutMs), { cause: error });
616
+ }
617
+ if (!response.ok) throw new Error(describeHttpFailure(response.status, await responseDetail(response), url, options.appId));
618
+ try {
619
+ return await response.json();
620
+ } catch {
621
+ return {};
622
+ }
623
+ }
624
+ const unreadableReply = (url) => /* @__PURE__ */ new Error("Upload to Ralph failed. Ralph's reply couldn't be read. Update @ralphralphai/playwright to the latest version and run again." + customServerHint(url));
625
+ const PREPARE_TIMEOUT_MS = 3e4;
626
+ /**
627
+ * Reserves an artifact for the run. Its `resultUrl` works from now on, so the
628
+ * run can be followed while it uploads and while Ralph processes it.
629
+ */
630
+ async function prepareArtifactUpload(options) {
631
+ const url = checkedApiUrl(options, "/api/upload/artifact/prepare");
632
+ const reply = await request(url, {
633
+ headers: { "content-type": "application/json" },
634
+ body: JSON.stringify({ externalAppId: options.appId })
635
+ }, options, PREPARE_TIMEOUT_MS);
636
+ if (reply["result"] !== "ok" || typeof reply["artifactId"] !== "string" || reply["resultUrl"] !== void 0 && typeof reply["resultUrl"] !== "string") throw unreadableReply(url);
637
+ return {
638
+ artifactId: reply["artifactId"],
639
+ resultUrl: reply["resultUrl"]
640
+ };
641
+ }
642
+ /**
643
+ * Streams the run's bundle to the artifact `manifest.artifactId` names, which
644
+ * `prepareArtifactUpload` has to have reserved. Ralph processes it after this
645
+ * returns; the result page shows when it is ready.
646
+ */
647
+ async function uploadRawGraph(manifest, screenshots, options) {
648
+ const url = checkedApiUrl(options, `/api/upload/web/apps/${options.appId}/artifact/${encodeURIComponent(manifest.artifactId)}`);
649
+ const storage = process.env["RALPH_UPLOAD_STORAGE"] || "gcs";
650
+ if (storage !== "gcs" && storage !== "local") throw new Error(`RALPH_UPLOAD_STORAGE must be gcs or local, not ${storage}.`);
651
+ if (storage === "local") url.searchParams.set("storage", "local");
652
+ const timeoutMs = options.timeoutMs ?? 12e4;
653
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("uploadTimeoutMs in the Ralph reporter options must be a positive number of milliseconds.");
654
+ const receipt = await request(url, {
655
+ headers: { "content-type": "application/gzip" },
656
+ body: Readable.toWeb(rawGraphBundleStream(manifest, screenshots)),
657
+ duplex: "half"
658
+ }, options, timeoutMs);
659
+ if (receipt["result"] !== "ok" || receipt["status"] !== "queued" || receipt["artifactId"] !== manifest.artifactId || receipt["resultUrl"] !== void 0 && typeof receipt["resultUrl"] !== "string" || typeof receipt["replayed"] !== "boolean") throw unreadableReply(url);
660
+ return receipt;
661
+ }
662
+ const CREATE_KEY_HINT = "You can create an upload key in your project dashboard on Ralph.";
663
+ const SUPPORT_HINT = "If it keeps failing, contact Ralph support.";
664
+ /** Only users who set their own server need to hear about it. */
665
+ function customServerHint(url) {
666
+ return url.origin === new URL("https://api.ralphralph.ai").origin ? "" : ` Also check RALPH_API_URL (${url.origin}) is right.`;
667
+ }
668
+ function describeRequestFailure(error, url, timeoutMs) {
669
+ if (error instanceof Error && error.name === "TimeoutError") return `Upload to Ralph took longer than ${timeoutMs / 1e3}s and was stopped. Check your network, or raise uploadTimeoutMs in the reporter options.`;
670
+ return `Couldn't connect to Ralph (${error instanceof Error && error.cause instanceof Error ? error.cause.message : error instanceof Error ? error.message : String(error)}). Check that this machine can reach ${url.origin}.` + customServerHint(url);
671
+ }
672
+ function describeHttpFailure(status, detail, url, appId) {
673
+ let message;
674
+ if (status === 401) message = `Ralph didn't accept your upload key. Check RALPH_UPLOAD_KEY. ${CREATE_KEY_HINT}`;
675
+ else if (status === 403) message = `Your upload key can't upload to the app "${appId}". Check RALPH_APP_ID is an app in the same project as the key.`;
676
+ else if (status === 404) message = `Ralph couldn't find the app "${appId}". Check RALPH_APP_ID.` + customServerHint(url);
677
+ else if (status === 413) message = "This run is too big to upload. Record fewer pages or screen sizes, or split the tests across runs.";
678
+ else if (status === 429) message = "Too many uploads right now. Wait a minute and run again.";
679
+ else if (status >= 500) message = `Something went wrong on Ralph's side. Run again later. ${SUPPORT_HINT}`;
680
+ else message = `Ralph couldn't accept this upload. Update @ralphralphai/playwright to the latest version and run again. ${SUPPORT_HINT}`;
681
+ return `Upload to Ralph failed. ${message} (HTTP ${status}${detail ? `: ${detail}` : ""})`;
682
+ }
683
+ /** The server's reason for rejecting the upload, kept short. */
684
+ async function responseDetail(response) {
685
+ let text;
686
+ try {
687
+ text = (await response.text()).trim();
688
+ } catch {
689
+ return;
690
+ }
691
+ try {
692
+ const body = JSON.parse(text);
693
+ const message = body.result ?? body.message ?? body.error;
694
+ if (typeof message === "string") text = message;
695
+ } catch {}
696
+ text = text.replace(/\s+/g, " ");
697
+ return text.length > 200 ? text.slice(0, 200) + "…" : text || void 0;
698
+ }
699
+ //#endregion
700
+ export { resolveUploadOptions as a, MANIFEST_ATTACHMENT as c, version as d, loadConfig as f, prepareArtifactUpload as i, REPORTER_ENV as l, resolveMode as m, bundleImagePath as n, resultLink as o, loadConfigSync as p, createRawGraphBundle as r, uploadRawGraph as s, RAW_GRAPH_BUNDLE_INDEX as t, Recorder as u };
701
+
702
+ //# sourceMappingURL=upload-DPYMUP9Y.mjs.map