@ralphralphai/playwright 0.1.0 → 0.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.
package/dist/index.cjs CHANGED
@@ -70,11 +70,11 @@ function snapshot(value) {
70
70
  }
71
71
  //#endregion
72
72
  //#region package.json
73
- var version = "0.1.0";
73
+ var version = "0.2.0";
74
74
  //#endregion
75
75
  //#region src/image.ts
76
76
  /**
77
- * Matches the crawler's full-page captures: lossless measured larger than this
77
+ * Matches the crawler's full-page screenshots: lossless measured larger than this
78
78
  * on real sites, which are mostly photographic content.
79
79
  */
80
80
  const WEBP_QUALITY = 80;
@@ -174,21 +174,21 @@ function resolveUploadOptions(options) {
174
174
  timeoutMs: options.uploadTimeoutMs
175
175
  };
176
176
  }
177
- const CAPTURE_BUNDLE_INDEX = "capture.json";
178
- const CAPTURE_BUNDLE_IMAGE_DIR = "assets/images";
177
+ const RAW_GRAPH_BUNDLE_INDEX = "raw_graph.json";
178
+ const RAW_GRAPH_BUNDLE_IMAGE_DIR = "assets/images";
179
179
  /**
180
180
  * Packs the manifest and screenshots into a gzipped tar archive:
181
181
  *
182
- * capture.json { manifest, screenshots: [{ nodeId, path }] }
182
+ * raw_graph.json { manifest, screenshots: [{ nodeId, path }] }
183
183
  * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
184
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.");
185
+ async function createRawGraphBundle(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 raw graph exceeds upload limits.");
187
187
  const images = screenshots.map(({ nodeId, contentType, bytes }) => {
188
188
  if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
189
189
  return {
190
190
  nodeId,
191
- path: `${CAPTURE_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`,
191
+ path: `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`,
192
192
  bytes
193
193
  };
194
194
  });
@@ -206,7 +206,7 @@ async function createCaptureBundle(manifest, screenshots) {
206
206
  };
207
207
  const tar = (0, tar_stream.pack)();
208
208
  tar.entry({
209
- name: CAPTURE_BUNDLE_INDEX,
209
+ name: RAW_GRAPH_BUNDLE_INDEX,
210
210
  ...attrs
211
211
  }, JSON.stringify(index));
212
212
  for (const { path, bytes } of images) tar.entry({
@@ -217,19 +217,19 @@ async function createCaptureBundle(manifest, screenshots) {
217
217
  const chunks = [];
218
218
  for await (const chunk of tar) chunks.push(chunk);
219
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.");
220
+ if (compressed.byteLength > 67108864) throw new Error("Ralph compressed raw graph bundle exceeds upload limits.");
221
221
  return compressed;
222
222
  }
223
223
  function isLoopback(url) {
224
224
  return url.hostname === "localhost" || url.hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(url.hostname);
225
225
  }
226
- async function uploadCapture(manifest, screenshots, options) {
226
+ async function uploadRawGraph(manifest, screenshots, options) {
227
227
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) throw new Error(`Ralph app id is not valid: ${options.appId}`);
228
228
  const url = new URL(`/api/upload/playwright/apps/${options.appId}/attempts`, options.apiUrl);
229
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
230
  const timeoutMs = options.timeoutMs ?? 3e4;
231
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" });
232
+ const blob = new Blob([new Uint8Array(await createRawGraphBundle(manifest, screenshots))], { type: "application/gzip" });
233
233
  const response = await fetch(url, {
234
234
  method: "POST",
235
235
  headers: { Authorization: `Bearer ${options.uploadKey}` },
@@ -237,17 +237,17 @@ async function uploadCapture(manifest, screenshots, options) {
237
237
  redirect: "error",
238
238
  signal: AbortSignal.timeout(timeoutMs)
239
239
  });
240
- if (!response.ok) throw new Error(`Ralph upload failed (HTTP ${response.status}). Local capture artifacts are retained for retry.`);
240
+ if (!response.ok) throw new Error(`Ralph upload failed (HTTP ${response.status}). Local Ralph artifacts are retained for retry.`);
241
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.");
242
+ if (receipt.result !== "ok" || receipt.status !== "received" || receipt.attemptId !== manifest.attemptId || typeof receipt.resultId !== "string" || typeof receipt.replayed !== "boolean") throw new Error("Ralph server returned an invalid upload receipt.");
243
243
  return receipt;
244
244
  }
245
245
  //#endregion
246
246
  //#region src/recorder.ts
247
- const MANIFEST_ATTACHMENT = "ralph-captures";
247
+ const MANIFEST_ATTACHMENT = "ralph-raw-graph";
248
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. */
249
+ const DEFAULT_RECORD_TIMEOUT_MS = 5e3;
250
+ /** Where the raw graph and screenshots are written, inside Playwright's per-test output directory. */
251
251
  const OUTPUT_DIR = "ralph";
252
252
  var Recorder = class {
253
253
  options;
@@ -260,13 +260,13 @@ var Recorder = class {
260
260
  */
261
261
  attemptId = (0, node_crypto.randomUUID)();
262
262
  startedAt = (/* @__PURE__ */ new Date()).toISOString();
263
- /** How long to wait after a navigation's DOM loads before capturing. */
263
+ /** How long to wait after a navigation's DOM loads before recording. */
264
264
  settleMs;
265
265
  /**
266
- * The budget for one capture, from settling through the screenshot. Past it,
267
- * the capture is recorded as failed.
266
+ * The budget for recording one node, from settling through the screenshot.
267
+ * Past it, the node is recorded as failed.
268
268
  */
269
- captureTimeoutMs;
269
+ recordTimeoutMs;
270
270
  /**
271
271
  * Every context being watched. `onPage` is kept so `stopContext` can remove
272
272
  * the exact listener `observe` added. The id is stored in the manifest's
@@ -275,17 +275,17 @@ var Recorder = class {
275
275
  contexts = /* @__PURE__ */ new Map();
276
276
  /**
277
277
  * Every tab seen in a watched context, including closed ones, so their
278
- * captures and opener links still reach the manifest.
278
+ * nodes and opener links still reach the manifest.
279
279
  */
280
280
  pages = /* @__PURE__ */ new Map();
281
281
  /**
282
- * Each capture's outcome, in completion order. Captures in one tab complete
282
+ * Each node's outcome, in completion order. Nodes in one tab complete
283
283
  * in sequence, but tabs interleave; `finish` sorts them.
284
284
  */
285
285
  nodes = [];
286
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
287
+ * When each node was requested, keyed by node id, for linking a
288
+ * popup's first node to its opener's latest. Numbers, so they compare
289
289
  * without parsing each node's `requestedAt` string.
290
290
  */
291
291
  requestedAtMs = /* @__PURE__ */ new Map();
@@ -297,10 +297,10 @@ var Recorder = class {
297
297
  this.testInfo = testInfo;
298
298
  this.upload = upload;
299
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.");
300
+ this.recordTimeoutMs = options.recordTimeoutMs ?? DEFAULT_RECORD_TIMEOUT_MS;
301
+ 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.");
302
302
  }
303
- /** Starts capturing URL changes in every current and future tab of `context`. */
303
+ /** Starts recording URL changes in every current and future tab of `context`. */
304
304
  observe(context) {
305
305
  if (this.closed) throw new Error("The Ralph recorder has finished.");
306
306
  if (this.contexts.has(context)) return;
@@ -313,18 +313,18 @@ var Recorder = class {
313
313
  context.on("page", onPage);
314
314
  for (const page of context.pages()) onPage(page);
315
315
  }
316
- /** Captures the page as it is now, e.g. an open dialog at the same URL. */
317
- async captureNode(page, options = {}) {
316
+ /** Records the page as it is now, e.g. an open dialog at the same URL. */
317
+ async recordNode(page, options = {}) {
318
318
  this.observe(page.context());
319
319
  const state = this.pages.get(page);
320
- if (!state) throw new Error("Cannot capture a closed browser page.");
320
+ if (!state) throw new Error("Cannot record a closed browser page.");
321
321
  await this.enqueue(state, "explicit", options);
322
322
  }
323
- /** Waits for every queued capture to finish. */
323
+ /** Waits for every queued recording to finish. */
324
324
  async flush() {
325
325
  await Promise.all([...this.pages.values()].map((state) => state.tail));
326
326
  }
327
- /** Stops watching `context` and waits for its queued captures. */
327
+ /** Stops watching `context` and waits for its queued recordings. */
328
328
  async stopContext(context) {
329
329
  const entry = this.contexts.get(context);
330
330
  if (entry) context.off("page", entry.onPage);
@@ -334,7 +334,7 @@ var Recorder = class {
334
334
  }
335
335
  /**
336
336
  * Stops recording, then writes the manifest, uploads it if configured, and
337
- * fails a passing test whose captures failed.
337
+ * fails a passing test whose nodes failed to record.
338
338
  */
339
339
  async finish() {
340
340
  this.closed = true;
@@ -346,7 +346,7 @@ var Recorder = class {
346
346
  const manifest = this.buildManifest(nodes, failures.length);
347
347
  await this.writeManifest(manifest);
348
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.");
349
+ 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.");
350
350
  }
351
351
  watchPage(page, contextId) {
352
352
  if (this.pages.has(page)) return;
@@ -380,11 +380,11 @@ var Recorder = class {
380
380
  state.lastUrl = url;
381
381
  if (isHttpUrl(url)) this.enqueue(state, "url-change", {}).catch(() => void 0);
382
382
  }
383
- /** Queues a capture behind the tab's earlier ones. */
383
+ /** Queues a recording behind the tab's earlier ones. */
384
384
  enqueue(state, trigger, options) {
385
385
  const actualUrl = state.page.url();
386
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.");
387
+ if (!isHttpUrl(actualUrl) || !isHttpUrl(url)) throw new Error("Ralph recordings require HTTP or HTTPS page and recorded URLs.");
388
388
  const request = {
389
389
  nodeId: (0, node_crypto.randomUUID)(),
390
390
  pageId: state.info.pageId,
@@ -402,18 +402,18 @@ var Recorder = class {
402
402
  return task;
403
403
  }
404
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.
405
+ * Records one node and its outcome. Fails if the tab navigates or
406
+ * closes before it finishes, or if it runs past the record timeout.
407
407
  */
408
408
  async record(state, generation, request) {
409
409
  const { page } = state;
410
- const deadline = startDeadline(this.captureTimeoutMs, "Ralph capture timed out.");
410
+ const deadline = startDeadline(this.recordTimeoutMs, "Ralph recording timed out.");
411
411
  const { wait } = deadline;
412
412
  const navigatedAway = () => state.generation !== generation || page.url() !== request.actualUrl;
413
413
  const check = () => {
414
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.");
415
+ if (navigatedAway()) throw new Error("The page navigated before its recording completed.");
416
+ if (page.isClosed()) throw new Error("The page closed before its recording completed.");
417
417
  };
418
418
  try {
419
419
  check();
@@ -427,8 +427,8 @@ var Recorder = class {
427
427
  check();
428
428
  this.nodes.push({
429
429
  ...request,
430
- status: "captured",
431
- capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
430
+ status: "recorded",
431
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
432
432
  layout: before,
433
433
  geometryStable: JSON.stringify(before) === JSON.stringify(after),
434
434
  screenshot: await this.saveScreenshot(request.nodeId, image)
@@ -446,7 +446,7 @@ var Recorder = class {
446
446
  }
447
447
  /** After a navigation: the DOM, the settle delay, then the `ready` hook. */
448
448
  async waitForPageToSettle(page, { wait, signal }, check) {
449
- await wait(page.waitForLoadState("domcontentloaded", { timeout: this.captureTimeoutMs }));
449
+ await wait(page.waitForLoadState("domcontentloaded", { timeout: this.recordTimeoutMs }));
450
450
  await wait((0, node_timers_promises.setTimeout)(this.settleMs, void 0, { signal }));
451
451
  check();
452
452
  if (this.options.ready) await wait(this.options.ready(page, signal));
@@ -503,11 +503,11 @@ var Recorder = class {
503
503
  config: this.config,
504
504
  pages: [...this.pages.values()].map((state) => state.info),
505
505
  nodes,
506
- complete: testPassed && nodes.length > 0 && nodes.every((item) => item.status === "captured" && item.geometryStable)
506
+ complete: testPassed && nodes.length > 0 && nodes.every((item) => item.status === "recorded" && item.geometryStable)
507
507
  };
508
508
  }
509
509
  async writeManifest(manifest) {
510
- const file = this.testInfo.outputPath(OUTPUT_DIR, "captures.json");
510
+ const file = this.testInfo.outputPath(OUTPUT_DIR, "raw_graph.json");
511
511
  await (0, node_fs_promises.mkdir)(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
512
512
  await (0, node_fs_promises.writeFile)(file, JSON.stringify(manifest, null, 2) + "\n");
513
513
  await this.testInfo.attach(MANIFEST_ATTACHMENT, {
@@ -516,14 +516,14 @@ var Recorder = class {
516
516
  });
517
517
  }
518
518
  async uploadManifest(manifest, upload) {
519
- const captured = manifest.nodes.filter((item) => item.status === "captured");
519
+ const recorded = manifest.nodes.filter((item) => item.status === "recorded");
520
520
  const screenshots = [];
521
- for (const { nodeId, screenshot } of captured) screenshots.push({
521
+ for (const { nodeId, screenshot } of recorded) screenshots.push({
522
522
  nodeId,
523
523
  contentType: screenshot.contentType,
524
524
  bytes: await (0, node_fs_promises.readFile)(this.testInfo.outputPath(screenshot.path))
525
525
  });
526
- const receipt = await uploadCapture(manifest, screenshots, upload);
526
+ const receipt = await uploadRawGraph(manifest, screenshots, upload);
527
527
  await this.testInfo.attach("ralph-upload-receipt", {
528
528
  body: JSON.stringify(receipt),
529
529
  contentType: "application/json"
@@ -550,7 +550,7 @@ function startDeadline(timeoutMs, message) {
550
550
  }
551
551
  /**
552
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
553
+ * tab, or for a popup's first node, its opener's latest. Superseded nodes
554
554
  * are skipped, since their page state was never shown.
555
555
  *
556
556
  * `nodes` must be sorted by page, then sequence.
@@ -667,7 +667,7 @@ const test = _playwright_test.test.extend({
667
667
  if (resolveMode(ralphOptions) !== "off") throw new Error("Opt this test into Ralph with the @ralph tag.");
668
668
  };
669
669
  await use(_ralphRecorder ?? {
670
- captureNode: async () => {
670
+ recordNode: async () => {
671
671
  checkOptIn();
672
672
  },
673
673
  flush: async () => {},
@@ -688,7 +688,7 @@ const test = _playwright_test.test.extend({
688
688
  //#endregion
689
689
  exports.MANIFEST_ATTACHMENT = MANIFEST_ATTACHMENT;
690
690
  exports.SCREEN_TAG_PREFIX = SCREEN_TAG_PREFIX;
691
- exports.createCaptureBundle = createCaptureBundle;
691
+ exports.createRawGraphBundle = createRawGraphBundle;
692
692
  Object.defineProperty(exports, "expect", {
693
693
  enumerable: true,
694
694
  get: function() {
@@ -703,6 +703,6 @@ Object.defineProperty(exports, "mergeTests", {
703
703
  });
704
704
  exports.ralphProjects = ralphProjects;
705
705
  exports.test = test;
706
- exports.uploadCapture = uploadCapture;
706
+ exports.uploadRawGraph = uploadRawGraph;
707
707
 
708
708
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["readFile","readFileSync","path","parse","printParseErrorCode","configPackage","pack","promisify","gzip","randomUUID","delay","mkdir","writeFile","packageInfo.version","playwrightPackage","readFile","base","path"],"sources":["../src/config.ts","../package.json","../src/image.ts","../src/layout.ts","../src/upload.ts","../src/recorder.ts","../src/screens.ts","../src/index.ts"],"sourcesContent":["import { readFileSync } from 'node:fs';\nimport { readFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport configPackage from '@ralphralphai/config/package.json' with { type: 'json' };\nimport { WebRalphConfigSchema } from '@ralphralphai/config/zod';\nimport { parse, printParseErrorCode, type ParseError } from 'jsonc-parser';\n\nimport type { ConfigSnapshot, RalphOptions } from './types';\n\nexport function resolveMode(options: RalphOptions): 'off' | 'local' | 'upload' {\n const mode =\n options.mode ??\n process.env['RALPH_MODE'] ??\n (process.env['RALPH_UPLOAD_KEY'] ? 'upload' : 'off');\n if (mode === 'off' || mode === 'local' || mode === 'upload') {\n return mode;\n }\n\n throw new Error('RALPH_MODE must be off, local, or upload.');\n}\n\ntype ConfigSource = Pick<RalphOptions, 'config' | 'configPath'>;\n\nexport async function loadConfig(\n options: ConfigSource,\n root: string,\n): Promise<ConfigSnapshot> {\n const file = configFile(options, root);\n return snapshot(options.config ?? parseConfig(await readFile(file, 'utf8')));\n}\n\n// Playwright config files may be CommonJS, so project generation cannot await.\nexport function loadConfigSync(\n options: ConfigSource,\n root: string,\n): ConfigSnapshot {\n const file = configFile(options, root);\n return snapshot(options.config ?? parseConfig(readFileSync(file, 'utf8')));\n}\n\nfunction configFile(options: ConfigSource, root: string): string {\n if (options.config && options.configPath) {\n throw new Error(\n 'Provide either ralphOptions.config or configPath, not both.',\n );\n }\n return path.resolve(root, options.configPath ?? 'ralph.jsonc');\n}\n\nfunction parseConfig(text: string): unknown {\n const errors: ParseError[] = [];\n const value: unknown = parse(text, errors, { allowTrailingComma: true });\n if (errors.length) {\n throw new Error(\n 'Invalid Ralph config: ' +\n errors\n .map(\n (error) => printParseErrorCode(error.error) + ' at ' + error.offset,\n )\n .join(', '),\n );\n }\n return value;\n}\n\nfunction snapshot(value: unknown): ConfigSnapshot {\n WebRalphConfigSchema.parse(value);\n return {\n config: JSON.parse(JSON.stringify(value)) as ConfigSnapshot['config'],\n packageVersion: configPackage.version,\n };\n}\n","","import type { Page } from '@playwright/test';\n\n/**\n * Matches the crawler's full-page captures: lossless measured larger than this\n * on real sites, which are mostly photographic content.\n */\nconst WEBP_QUALITY = 80;\n\nexport type Screenshot = {\n bytes: Buffer;\n contentType: 'image/webp' | 'image/png';\n width: number;\n height: number;\n};\n\nexport async function takeScreenshot(\n page: Page,\n deadline: number,\n): Promise<Screenshot> {\n const options = { fullPage: true, scale: 'css' } as const;\n const webp = await page.screenshot({\n ...options,\n type: 'webp',\n quality: WEBP_QUALITY,\n timeout: Math.max(1, deadline - Date.now()),\n });\n // WebP stores each dimension in 14 bits, so a page over 16383px has no valid\n // encoding. Chromium signals that with an empty buffer rather than an error;\n // PNG has no such limit.\n if (webp.length > 0) {\n return { bytes: webp, contentType: 'image/webp', ...webpSize(webp) };\n }\n const png = await page.screenshot({\n ...options,\n type: 'png',\n timeout: Math.max(1, deadline - Date.now()),\n });\n return {\n bytes: png,\n contentType: 'image/png',\n width: png.readUInt32BE(16),\n height: png.readUInt32BE(20),\n };\n}\n\nexport function imageExtension(contentType: Screenshot['contentType']): string {\n return contentType === 'image/png' ? '.png' : '.webp';\n}\n\nfunction webpSize(bytes: Buffer): { width: number; height: number } {\n if (\n bytes.toString('ascii', 0, 4) !== 'RIFF' ||\n bytes.toString('ascii', 8, 12) !== 'WEBP'\n ) {\n throw new Error('The screenshot is not a WebP image.');\n }\n const chunk = bytes.toString('ascii', 12, 16);\n if (chunk === 'VP8X') {\n return {\n width: 1 + bytes.readUIntLE(24, 3),\n height: 1 + bytes.readUIntLE(27, 3),\n };\n }\n if (chunk === 'VP8 ') {\n return {\n width: bytes.readUInt16LE(26) & 0x3fff,\n height: bytes.readUInt16LE(28) & 0x3fff,\n };\n }\n if (chunk === 'VP8L') {\n const bits = bytes.readUInt32LE(21);\n return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };\n }\n throw new Error('Unsupported WebP chunk: ' + chunk);\n}\n","import type { Page } from '@playwright/test';\n\nimport type { PageLayout } from './types';\n\nexport function readLayout(page: Page): Promise<PageLayout> {\n return page\n .locator('[data-track-id]')\n .filter({ visible: true })\n .evaluateAll((elements) => ({\n viewport: { width: window.innerWidth, height: window.innerHeight },\n document: {\n width: Math.max(\n document.documentElement.scrollWidth,\n document.documentElement.clientWidth,\n document.body?.scrollWidth ?? 0,\n ),\n height: Math.max(\n document.documentElement.scrollHeight,\n document.documentElement.clientHeight,\n document.body?.scrollHeight ?? 0,\n ),\n },\n scroll: { x: window.scrollX, y: window.scrollY },\n devicePixelRatio: window.devicePixelRatio,\n trackedElements: elements.flatMap((element) => {\n const trackId = element.getAttribute('data-track-id');\n const rect = element.getBoundingClientRect();\n // Off-screen horizontally (drawers, carousel slides) is dropped like\n // the crawler does; below the fold is kept, since the capture is\n // full-page.\n if (\n !trackId ||\n rect.width <= 0 ||\n rect.height <= 0 ||\n rect.right <= 0 ||\n rect.left >= window.innerWidth\n ) {\n return [];\n }\n return [\n {\n trackId,\n x: rect.x + window.scrollX,\n y: rect.y + window.scrollY,\n width: rect.width,\n height: rect.height,\n },\n ];\n }),\n }));\n}\n","import { promisify } from 'node:util';\nimport { gzip } from 'node:zlib';\n\nimport { pack } from 'tar-stream';\n\nimport { imageExtension, type Screenshot } from './image';\nimport type { CaptureManifest, RalphOptions } from './types';\n\nexport type UploadOptions = {\n apiUrl: string;\n appId: string;\n uploadKey: string;\n timeoutMs?: number;\n};\nexport type CaptureScreenshot = {\n nodeId: string;\n contentType: Screenshot['contentType'];\n bytes: Uint8Array;\n};\nexport type UploadReceipt = {\n result: 'ok';\n status: 'received';\n attemptId: string;\n replayed: boolean;\n};\n\nexport function resolveUploadOptions(options: RalphOptions): UploadOptions {\n const uploadKey = process.env['RALPH_UPLOAD_KEY'];\n const apiUrl = options.apiUrl ?? process.env['RALPH_API_URL'];\n const appId = options.appId ?? process.env['RALPH_APP_ID'];\n if (!uploadKey || !apiUrl || !appId) {\n throw new Error(\n 'Ralph upload requires RALPH_UPLOAD_KEY, RALPH_API_URL (or ralphOptions.apiUrl), and RALPH_APP_ID (or ralphOptions.appId).',\n );\n }\n return { uploadKey, apiUrl, appId, timeoutMs: options.uploadTimeoutMs };\n}\n\nexport const CAPTURE_BUNDLE_INDEX = 'capture.json';\nexport const CAPTURE_BUNDLE_IMAGE_DIR = 'assets/images';\n\nexport type CaptureBundleIndex = {\n manifest: CaptureManifest;\n screenshots: { nodeId: string; path: string }[];\n};\n\n/**\n * Packs the manifest and screenshots into a gzipped tar archive:\n *\n * capture.json { manifest, screenshots: [{ nodeId, path }] }\n * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)\n */\nexport async function createCaptureBundle(\n manifest: CaptureManifest,\n screenshots: readonly CaptureScreenshot[],\n): Promise<Uint8Array> {\n if (\n Buffer.byteLength(JSON.stringify(manifest)) > 2 * 1024 * 1024 ||\n screenshots.length > 500 ||\n screenshots.reduce(\n (total, screenshot) => total + screenshot.bytes.byteLength,\n 0,\n ) >\n 64 * 1024 * 1024\n ) {\n throw new Error('Ralph capture attempt exceeds upload limits.');\n }\n\n const images = screenshots.map(({ nodeId, contentType, bytes }) => {\n if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) {\n throw new Error(`Ralph node id is not path-safe: ${nodeId}`);\n }\n return {\n nodeId,\n path: `${CAPTURE_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`,\n bytes,\n };\n });\n const index: CaptureBundleIndex = {\n manifest,\n screenshots: images.map(({ nodeId, path }) => ({ nodeId, path })),\n };\n\n // Fixed attributes keep retries of the same attempt byte-identical.\n const attrs = { mtime: new Date(0), uid: 0, gid: 0 };\n const tar = pack();\n tar.entry({ name: CAPTURE_BUNDLE_INDEX, ...attrs }, JSON.stringify(index));\n for (const { path, bytes } of images) {\n tar.entry({ name: path, ...attrs }, Buffer.from(bytes));\n }\n tar.finalize();\n const chunks: Buffer[] = [];\n for await (const chunk of tar) {\n chunks.push(chunk as Buffer);\n }\n const compressed = await promisify(gzip)(Buffer.concat(chunks));\n if (compressed.byteLength > 64 * 1024 * 1024) {\n throw new Error('Ralph compressed capture bundle exceeds upload limits.');\n }\n\n return compressed;\n}\n\nfunction isLoopback(url: URL): boolean {\n return (\n url.hostname === 'localhost' ||\n url.hostname === '[::1]' ||\n /^127(\\.\\d{1,3}){3}$/.test(url.hostname)\n );\n}\n\nexport async function uploadCapture(\n manifest: CaptureManifest,\n screenshots: readonly CaptureScreenshot[],\n options: UploadOptions,\n): Promise<UploadReceipt> {\n if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) {\n throw new Error(`Ralph app id is not valid: ${options.appId}`);\n }\n const url = new URL(\n `/api/upload/playwright/apps/${options.appId}/attempts`,\n options.apiUrl,\n );\n if (\n !(\n url.protocol === 'https:' ||\n (url.protocol === 'http:' && isLoopback(url))\n ) ||\n !options.uploadKey\n ) {\n throw new Error(\n 'Ralph upload requires an HTTPS API URL (HTTP only for loopback hosts) and an upload key.',\n );\n }\n\n const timeoutMs = options.timeoutMs ?? 30_000;\n if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {\n throw new Error('Ralph uploadTimeoutMs must be positive and finite.');\n }\n\n const blob = new Blob(\n [new Uint8Array(await createCaptureBundle(manifest, screenshots))],\n { type: 'application/gzip' },\n );\n\n const response = await fetch(url, {\n method: 'POST',\n headers: { Authorization: `Bearer ${options.uploadKey}` },\n body: blob,\n redirect: 'error',\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!response.ok) {\n throw new Error(\n `Ralph upload failed (HTTP ${response.status}). Local capture artifacts are retained for retry.`,\n );\n }\n\n const receipt = (await response.json()) as Partial<UploadReceipt>;\n if (\n receipt.result !== 'ok' ||\n receipt.status !== 'received' ||\n receipt.attemptId !== manifest.attemptId ||\n typeof receipt.replayed !== 'boolean'\n ) {\n throw new Error('Ralph server returned an invalid upload receipt.');\n }\n return receipt as UploadReceipt;\n}\n","import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, writeFile } from 'node:fs/promises';\nimport { setTimeout as delay } from 'node:timers/promises';\n\nimport type { BrowserContext, Frame, Page, TestInfo } from '@playwright/test';\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\n\nimport packageInfo from '../package.json';\nimport { imageExtension, takeScreenshot, type Screenshot } from './image';\nimport { readLayout } from './layout';\nimport type {\n CaptureManifest,\n RawNode,\n CaptureOptions,\n ConfigSnapshot,\n Ralph,\n RalphOptions,\n} from './types';\nimport { uploadCapture, type UploadOptions } from './upload';\n\nexport const MANIFEST_ATTACHMENT = 'ralph-captures';\n\nconst DEFAULT_SETTLE_MS = 150;\nconst DEFAULT_CAPTURE_TIMEOUT_MS = 5_000;\n\n/** Where captures are written, inside Playwright's per-test output directory. */\nconst OUTPUT_DIR = 'ralph';\n\ntype PageInfo = CaptureManifest['pages'][number];\n\ntype NodeRequest = Omit<RawNode, 'status'>;\n\ntype CapturedNode = Extract<RawNode, { status: 'captured' }>;\n\n/** What the recorder tracks for one browser tab. */\ntype PageState = {\n page: Page;\n info: PageInfo;\n\n /** The URL of the last navigation, so a reload is not captured again. */\n lastUrl: string;\n\n /**\n * Bumped on every main-frame navigation, including reloads. A capture that\n * sees a different generation when it finishes was overtaken by one.\n */\n generation: number;\n\n /** The next capture's position within this tab. */\n sequence: number;\n\n /** The last queued capture. Captures in a tab run one at a time. */\n tail: Promise<void>;\n\n /** Resolves once `info.openerPageId` is known. */\n opener: Promise<void>;\n\n onNavigation: (frame: Frame) => void;\n};\n\nexport class Recorder implements Ralph {\n /**\n * Identifies this run of the test. A retry gets a new recorder, and so a new\n * id, which is what lets the server tell retries apart and dedupe re-uploads.\n */\n private readonly attemptId = randomUUID();\n private readonly startedAt = new Date().toISOString();\n\n /** How long to wait after a navigation's DOM loads before capturing. */\n private readonly settleMs: number;\n\n /**\n * The budget for one capture, from settling through the screenshot. Past it,\n * the capture is recorded as failed.\n */\n private readonly captureTimeoutMs: number;\n\n /**\n * Every context being watched. `onPage` is kept so `stopContext` can remove\n * the exact listener `observe` added. The id is stored in the manifest's\n * `pages[].contextId`.\n */\n private readonly contexts = new Map<\n BrowserContext,\n { id: string; onPage: (page: Page) => void }\n >();\n\n /**\n * Every tab seen in a watched context, including closed ones, so their\n * captures and opener links still reach the manifest.\n */\n private readonly pages = new Map<Page, PageState>();\n\n /**\n * Each capture's outcome, in completion order. Captures in one tab complete\n * in sequence, but tabs interleave; `finish` sorts them.\n */\n private readonly nodes: RawNode[] = [];\n\n /**\n * When each capture was requested, keyed by capture id, for linking a\n * popup's first capture to its opener's latest. Numbers, so they compare\n * without parsing each node's `requestedAt` string.\n */\n private readonly requestedAtMs = new Map<string, number>();\n\n /** Set by `finish`, after which `observe` throws. */\n private closed = false;\n\n constructor(\n private readonly options: RalphOptions,\n private readonly config: ConfigSnapshot,\n private readonly testInfo: TestInfo,\n private readonly upload?: UploadOptions,\n ) {\n this.settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;\n this.captureTimeoutMs =\n options.captureTimeoutMs ?? DEFAULT_CAPTURE_TIMEOUT_MS;\n\n const validTimings =\n Number.isFinite(this.settleMs) &&\n this.settleMs >= 0 &&\n Number.isFinite(this.captureTimeoutMs) &&\n this.captureTimeoutMs > this.settleMs;\n if (!validTimings) {\n throw new Error(\n 'Ralph captureTimeoutMs must be finite and greater than settleMs, which must be non-negative.',\n );\n }\n }\n\n // ---------------------------------------------------------------------------\n // Public API\n // ---------------------------------------------------------------------------\n\n /** Starts capturing URL changes in every current and future tab of `context`. */\n observe(context: BrowserContext): void {\n if (this.closed) {\n throw new Error('The Ralph recorder has finished.');\n }\n if (this.contexts.has(context)) {\n return;\n }\n\n const id = 'context-' + this.contexts.size;\n const onPage = (page: Page) => this.watchPage(page, id);\n this.contexts.set(context, { id, onPage });\n\n context.on('page', onPage);\n for (const page of context.pages()) {\n onPage(page);\n }\n }\n\n /** Captures the page as it is now, e.g. an open dialog at the same URL. */\n async captureNode(page: Page, options: CaptureOptions = {}): Promise<void> {\n this.observe(page.context());\n const state = this.pages.get(page);\n if (!state) {\n throw new Error('Cannot capture a closed browser page.');\n }\n await this.enqueue(state, 'explicit', options);\n }\n\n /** Waits for every queued capture to finish. */\n async flush(): Promise<void> {\n await Promise.all([...this.pages.values()].map((state) => state.tail));\n }\n\n /** Stops watching `context` and waits for its queued captures. */\n async stopContext(context: BrowserContext): Promise<void> {\n const entry = this.contexts.get(context);\n if (entry) {\n context.off('page', entry.onPage);\n }\n\n const states = [...this.pages.values()].filter(\n (state) => state.page.context() === context,\n );\n for (const state of states) {\n state.page.off('framenavigated', state.onNavigation);\n }\n await Promise.all(states.map((state) => state.tail));\n }\n\n /**\n * Stops recording, then writes the manifest, uploads it if configured, and\n * fails a passing test whose captures failed.\n */\n async finish(): Promise<void> {\n this.closed = true;\n await Promise.all(\n [...this.contexts.keys()].map((context) => this.stopContext(context)),\n );\n await Promise.all([...this.pages.values()].map((state) => state.opener));\n\n const nodes = this.nodes.sort(\n (a, b) => a.pageId.localeCompare(b.pageId) || a.sequence - b.sequence,\n );\n linkPreviousNodes(\n nodes,\n (pageId) => this.pageInfo(pageId)?.openerPageId,\n (nodeId) => this.requestedAtMs.get(nodeId) ?? 0,\n );\n\n const failures = nodes.filter((item) => item.status === 'failed');\n const manifest = this.buildManifest(nodes, failures.length);\n await this.writeManifest(manifest);\n if (this.upload) {\n await this.uploadManifest(manifest, this.upload);\n }\n\n if (failures.length && this.testInfo.status === 'passed') {\n throw new Error(\n 'Ralph could not capture ' +\n failures.length +\n ' page state(s). See the ralph-captures attachment.',\n );\n }\n }\n\n // ---------------------------------------------------------------------------\n // Watching tabs\n // ---------------------------------------------------------------------------\n\n private watchPage(page: Page, contextId: string): void {\n if (this.pages.has(page)) {\n return;\n }\n\n const state: PageState = {\n page,\n info: {\n pageId: 'page-' + this.pages.size,\n contextId,\n browserName: page.context().browser()?.browserType().name(),\n },\n lastUrl: '',\n generation: 0,\n sequence: 0,\n tail: Promise.resolve(),\n opener: Promise.resolve(),\n onNavigation: (frame) => {\n if (frame === page.mainFrame()) {\n this.urlChanged(state);\n }\n },\n };\n this.pages.set(page, state);\n\n state.opener = page\n .opener()\n .then((opener) => {\n if (opener) {\n state.info.openerPageId = this.pages.get(opener)?.info.pageId;\n }\n })\n .catch(() => undefined);\n\n page.on('framenavigated', state.onNavigation);\n this.urlChanged(state);\n }\n\n private urlChanged(state: PageState): void {\n state.generation++;\n\n const url = state.page.url();\n if (url === state.lastUrl) {\n return;\n }\n state.lastUrl = url;\n\n if (isHttpUrl(url)) {\n // Automatic captures never fail the test; failures land in the manifest.\n void this.enqueue(state, 'url-change', {}).catch(() => undefined);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Capturing\n // ---------------------------------------------------------------------------\n\n /** Queues a capture behind the tab's earlier ones. */\n private enqueue(\n state: PageState,\n trigger: RawNode['trigger'],\n options: CaptureOptions,\n ): Promise<void> {\n const actualUrl = state.page.url();\n const url =\n options.url === undefined\n ? actualUrl\n : new URL(options.url, actualUrl).href;\n if (!isHttpUrl(actualUrl) || !isHttpUrl(url)) {\n throw new Error(\n 'Ralph captures require HTTP or HTTPS page and recorded URLs.',\n );\n }\n\n const request: NodeRequest = {\n nodeId: randomUUID(),\n pageId: state.info.pageId,\n sequence: state.sequence++,\n requestedAt: new Date().toISOString(),\n actualUrl,\n url,\n trigger,\n ...(options.state === undefined ? {} : { state: options.state }),\n };\n this.requestedAtMs.set(request.nodeId, Date.now());\n\n const generation = state.generation;\n const task = state.tail.then(() => this.record(state, generation, request));\n state.tail = task.catch(() => undefined);\n\n return task;\n }\n\n /**\n * Takes one capture and records its outcome. Fails if the tab navigates or\n * closes before it finishes, or if it runs past the capture timeout.\n */\n private async record(\n state: PageState,\n generation: number,\n request: NodeRequest,\n ): Promise<void> {\n const { page } = state;\n const deadline = startDeadline(\n this.captureTimeoutMs,\n 'Ralph capture timed out.',\n );\n const { wait } = deadline;\n const navigatedAway = () =>\n state.generation !== generation || page.url() !== request.actualUrl;\n\n const check = () => {\n deadline.signal.throwIfAborted();\n if (navigatedAway()) {\n throw new Error('The page navigated before its capture completed.');\n }\n if (page.isClosed()) {\n throw new Error('The page closed before its capture completed.');\n }\n };\n\n try {\n check();\n if (request.trigger === 'url-change') {\n await this.waitForPageToSettle(page, deadline, check);\n }\n\n // Geometry is read on both sides of the screenshot to detect layout drift.\n check();\n const before = await wait(readLayout(page));\n check();\n const image = await wait(takeScreenshot(page, deadline.endsAt));\n check();\n const after = await wait(readLayout(page));\n check();\n\n this.nodes.push({\n ...request,\n status: 'captured',\n capturedAt: new Date().toISOString(),\n layout: before,\n geometryStable: JSON.stringify(before) === JSON.stringify(after),\n screenshot: await this.saveScreenshot(request.nodeId, image),\n });\n } catch (error) {\n this.nodes.push({\n ...request,\n status: navigatedAway() ? 'superseded' : 'failed',\n reason: error instanceof Error ? error.message : String(error),\n });\n if (request.trigger === 'explicit') {\n throw error;\n }\n } finally {\n deadline.cancel();\n }\n }\n\n /** After a navigation: the DOM, the settle delay, then the `ready` hook. */\n private async waitForPageToSettle(\n page: Page,\n { wait, signal }: Deadline,\n check: () => void,\n ): Promise<void> {\n await wait(\n page.waitForLoadState('domcontentloaded', {\n timeout: this.captureTimeoutMs,\n }),\n );\n await wait(delay(this.settleMs, undefined, { signal }));\n check();\n if (this.options.ready) {\n await wait(this.options.ready(page, signal));\n }\n }\n\n /** Writes the image to the test output and attaches it to the report. */\n private async saveScreenshot(\n nodeId: string,\n image: Screenshot,\n ): Promise<CapturedNode['screenshot']> {\n const path = OUTPUT_DIR + '/' + nodeId + imageExtension(image.contentType);\n const attachmentName = 'ralph-screenshot-' + nodeId;\n\n await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });\n await writeFile(this.testInfo.outputPath(path), image.bytes);\n await this.testInfo.attach(attachmentName, {\n path: this.testInfo.outputPath(path),\n contentType: image.contentType,\n });\n\n return {\n path,\n attachmentName,\n width: image.width,\n height: image.height,\n scale: 'css',\n contentType: image.contentType,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Manifest\n // ---------------------------------------------------------------------------\n\n private pageInfo(pageId: string): PageInfo | undefined {\n return [...this.pages.values()].find(\n (state) => state.info.pageId === pageId,\n )?.info;\n }\n\n private buildManifest(\n nodes: RawNode[],\n failureCount: number,\n ): CaptureManifest {\n const { testInfo } = this;\n const testPassed = testInfo.status === 'passed';\n\n return {\n formatVersion: 1,\n producer: {\n name: '@ralphralphai/playwright',\n version: packageInfo.version,\n playwrightVersion: playwrightPackage.version,\n },\n runId: this.options.runId ?? process.env['RALPH_RUN_ID'],\n buildId: this.options.buildId ?? process.env['RALPH_BUILD_ID'],\n attemptId: this.attemptId,\n test: {\n id: testInfo.testId,\n titlePath: testInfo.titlePath,\n project: testInfo.project.name,\n retry: testInfo.retry,\n repeatEachIndex: testInfo.repeatEachIndex,\n workerIndex: testInfo.workerIndex,\n parallelIndex: testInfo.parallelIndex,\n shard: testInfo.config.shard,\n // Failed captures fail the test in `finish`, after this is written.\n status:\n failureCount && testPassed\n ? 'failed'\n : (testInfo.status ?? 'unknown'),\n expectedStatus: testInfo.expectedStatus,\n },\n startedAt: this.startedAt,\n finishedAt: new Date().toISOString(),\n config: this.config,\n pages: [...this.pages.values()].map((state) => state.info),\n nodes,\n complete:\n testPassed &&\n nodes.length > 0 &&\n nodes.every(\n (item) => item.status === 'captured' && item.geometryStable,\n ),\n };\n }\n\n private async writeManifest(manifest: CaptureManifest): Promise<void> {\n const file = this.testInfo.outputPath(OUTPUT_DIR, 'captures.json');\n await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });\n await writeFile(file, JSON.stringify(manifest, null, 2) + '\\n');\n await this.testInfo.attach(MANIFEST_ATTACHMENT, {\n path: file,\n contentType: 'application/json',\n });\n }\n\n private async uploadManifest(\n manifest: CaptureManifest,\n upload: UploadOptions,\n ): Promise<void> {\n const captured = manifest.nodes.filter(\n (item): item is CapturedNode => item.status === 'captured',\n );\n const screenshots = [];\n for (const { nodeId, screenshot } of captured) {\n screenshots.push({\n nodeId,\n contentType: screenshot.contentType,\n bytes: await readFile(this.testInfo.outputPath(screenshot.path)),\n });\n }\n\n const receipt = await uploadCapture(manifest, screenshots, upload);\n await this.testInfo.attach('ralph-upload-receipt', {\n body: JSON.stringify(receipt),\n contentType: 'application/json',\n });\n }\n}\n\n// -----------------------------------------------------------------------------\n// Helpers\n// -----------------------------------------------------------------------------\n\nfunction isHttpUrl(url: string): boolean {\n return /^https?:/.test(url);\n}\n\ntype Deadline = {\n signal: AbortSignal;\n /** Epoch milliseconds at which the deadline passes. */\n endsAt: number;\n /** Rejects with the timeout error if `promise` outlives the deadline. */\n wait: <T>(promise: Promise<T>) => Promise<T>;\n cancel: () => void;\n};\n\nfunction startDeadline(timeoutMs: number, message: string): Deadline {\n const controller = new AbortController();\n const { signal } = controller;\n const timer = setTimeout(\n () => controller.abort(new Error(message)),\n timeoutMs,\n );\n const aborted = new Promise<never>((_, reject) => {\n signal.addEventListener('abort', () => reject(signal.reason), {\n once: true,\n });\n });\n // Nothing may be waiting when the deadline passes.\n aborted.catch(() => undefined);\n\n return {\n signal,\n endsAt: Date.now() + timeoutMs,\n wait: (promise) => Promise.race([promise, aborted]),\n cancel: () => clearTimeout(timer),\n };\n}\n\n/**\n * Sets `previousNodeId` on each node: the one before it in the same\n * tab, or for a popup's first capture, its opener's latest. Superseded captures\n * are skipped, since their page state was never shown.\n *\n * `nodes` must be sorted by page, then sequence.\n */\nfunction linkPreviousNodes(\n nodes: RawNode[],\n openerOf: (pageId: string) => string | undefined,\n requestedAtMs: (nodeId: string) => number,\n): void {\n const byPage = new Map<string, RawNode[]>();\n for (const item of nodes) {\n byPage.set(item.pageId, [...(byPage.get(item.pageId) ?? []), item]);\n }\n\n const openerNodeBefore = (item: RawNode) => {\n const openerPageId = openerOf(item.pageId);\n const requestedAt = requestedAtMs(item.nodeId);\n return byPage\n .get(openerPageId ?? '')\n ?.filter((candidate) => requestedAtMs(candidate.nodeId) <= requestedAt)\n .at(-1);\n };\n\n // First the raw chain, superseded captures included.\n const previous = new Map<RawNode, RawNode>();\n for (const items of byPage.values()) {\n items.forEach((item, index) => {\n const from = index > 0 ? items[index - 1] : openerNodeBefore(item);\n if (from) {\n previous.set(item, from);\n }\n });\n }\n\n // Then skip past superseded ones, e.g. a redirect or a replaced route.\n for (const item of nodes) {\n let from = previous.get(item);\n while (from?.status === 'superseded') {\n from = previous.get(from);\n }\n if (from) {\n item.previousNodeId = from.nodeId;\n }\n }\n}\n","import type {\n PlaywrightTestOptions,\n PlaywrightWorkerOptions,\n Project,\n TestInfo,\n} from '@playwright/test';\nimport type { ScreenSize } from '@ralphralphai/config';\n\nimport { loadConfigSync } from './config';\nimport type { RalphOptions } from './types';\n\ntype RalphProject = Project<\n PlaywrightTestOptions & { ralphOptions: RalphOptions },\n PlaywrightWorkerOptions\n>;\n\nexport const SCREEN_TAG_PREFIX = '@ralph-screen:';\n\nexport type RalphProjectOptions = Pick<\n RalphOptions,\n 'config' | 'configPath'\n> & {\n /** Resolves a relative `configPath`; defaults to the current directory. */\n root?: string;\n};\n\ntype ScreenMetadata = { screenSize: ScreenSize; screenSizes: ScreenSize[] };\n\nexport function screenLabel(size: ScreenSize): string {\n return size.width + 'x' + size.height;\n}\n\n/**\n * Expands `base` into one project per `screenSizes` entry of the Ralph config.\n * Each project's viewport is that screen size.\n */\nexport function ralphProjects(\n base: RalphProject = {},\n options: RalphProjectOptions = {},\n): RalphProject[] {\n const { screenSizes } = loadConfigSync(\n options,\n options.root ?? process.cwd(),\n ).config;\n if (!screenSizes.length) {\n throw new Error('The Ralph config declares no screenSizes.');\n }\n return screenSizes.map((screenSize) => {\n const label = screenLabel(screenSize);\n const ralph: ScreenMetadata = { screenSize, screenSizes };\n return {\n ...base,\n name: base.name ? base.name + ' ' + label : label,\n use: {\n ...base.use,\n viewport: { width: screenSize.width, height: screenSize.height },\n },\n metadata: { ...base.metadata, ralph },\n };\n });\n}\n\n/**\n * Skips a test whose `@ralph-screen:WxH` tags exclude the current project's\n * screen size. Untagged tests run at every size; other projects are untouched.\n */\nexport function applyScreenSelection(testInfo: TestInfo): void {\n const selected = testInfo.tags\n .filter((tag) => tag.startsWith(SCREEN_TAG_PREFIX))\n .map((tag) => tag.slice(SCREEN_TAG_PREFIX.length));\n const metadata = (testInfo.project.metadata as { ralph?: ScreenMetadata })\n .ralph;\n if (!selected.length || !metadata) {\n return;\n }\n const known = metadata.screenSizes.map(screenLabel);\n const unknown = selected.filter((label) => !known.includes(label));\n if (unknown.length) {\n throw new Error(\n 'Unknown Ralph screen size ' +\n unknown.join(', ') +\n '; the config declares ' +\n known.join(', ') +\n '.',\n );\n }\n const current = screenLabel(metadata.screenSize);\n testInfo.skip(\n !selected.includes(current),\n 'Not selected for Ralph screen size ' + current + '.',\n );\n}\n","import path from 'node:path';\n\nimport { test as base } from '@playwright/test';\nimport type {\n PlaywrightTestArgs,\n PlaywrightTestOptions,\n PlaywrightWorkerArgs,\n PlaywrightWorkerOptions,\n TestType,\n} from '@playwright/test';\n\nimport { loadConfig, resolveMode } from './config';\nimport { Recorder } from './recorder';\nimport { applyScreenSelection } from './screens';\nimport type { ConfigSnapshot, Ralph, RalphOptions } from './types';\nimport { resolveUploadOptions } from './upload';\n\nexport { expect, mergeTests } from '@playwright/test';\nexport type { WebRalphConfig } from '@ralphralphai/config';\nexport { createCaptureBundle, uploadCapture } from './upload';\nexport type {\n UploadOptions,\n UploadReceipt,\n CaptureScreenshot,\n CaptureBundleIndex,\n} from './upload';\nexport { MANIFEST_ATTACHMENT } from './recorder';\nexport { ralphProjects, SCREEN_TAG_PREFIX } from './screens';\nexport type { RalphProjectOptions } from './screens';\nexport type {\n CaptureManifest,\n RawNode,\n CaptureOptions,\n ConfigSnapshot,\n PageLayout,\n Ralph,\n RalphOptions,\n TrackedElement,\n} from './types';\n\nexport type RalphFixtures = { ralph: Ralph; ralphOptions: RalphOptions };\n\ntype InternalFixtures = {\n _ralphScreen: void;\n _ralphRecorder: Recorder | null;\n};\ntype WorkerFixtures = { _ralphConfigs: Map<string, Promise<ConfigSnapshot>> };\n\nexport const test: TestType<\n PlaywrightTestArgs & PlaywrightTestOptions & RalphFixtures,\n PlaywrightWorkerArgs & PlaywrightWorkerOptions\n> = base.extend<RalphFixtures & InternalFixtures, WorkerFixtures>({\n ralphOptions: [{}, { option: true }],\n _ralphConfigs: [\n // Playwright requires destructuring here to discover fixture dependencies.\n // oxlint-disable-next-line no-empty-pattern\n async ({}, use) => {\n await use(new Map());\n },\n { scope: 'worker' },\n ],\n _ralphScreen: [\n // Playwright requires destructuring here to discover fixture dependencies.\n // oxlint-disable-next-line no-empty-pattern\n async ({}, use, testInfo) => {\n // Screen selection chooses which flows run, so it applies in every mode.\n applyScreenSelection(testInfo);\n await use();\n },\n { auto: true },\n ],\n _ralphRecorder: [\n // _ralphScreen is specified here so that the playwright treat that it is\n // a dependency of this fixture.\n async ({ ralphOptions, _ralphConfigs, _ralphScreen }, use, testInfo) => {\n if (\n !testInfo.tags.includes('@ralph') ||\n resolveMode(ralphOptions) === 'off'\n ) {\n await use(null);\n return;\n }\n\n const upload =\n resolveMode(ralphOptions) === 'upload'\n ? resolveUploadOptions(ralphOptions)\n : undefined;\n\n const root = testInfo.config.configFile\n ? path.dirname(testInfo.config.configFile)\n : process.cwd();\n\n const key = JSON.stringify({\n root,\n config: ralphOptions.config,\n configPath: ralphOptions.configPath,\n });\n\n let pending = _ralphConfigs.get(key);\n if (!pending) {\n pending = loadConfig(ralphOptions, root);\n _ralphConfigs.set(key, pending);\n }\n\n const recorder = new Recorder(\n ralphOptions,\n await pending,\n testInfo,\n upload,\n );\n\n try {\n await use(recorder);\n } finally {\n await recorder.finish();\n }\n },\n { auto: true, timeout: 30_000 },\n ],\n ralph: async ({ _ralphRecorder, ralphOptions }, use) => {\n const checkOptIn = () => {\n if (resolveMode(ralphOptions) !== 'off') {\n throw new Error('Opt this test into Ralph with the @ralph tag.');\n }\n };\n\n await use(\n _ralphRecorder ?? {\n captureNode: async () => {\n checkOptIn();\n },\n flush: async () => {},\n observe: () => {\n checkOptIn();\n },\n },\n );\n },\n context: async ({ context, _ralphRecorder }, use) => {\n _ralphRecorder?.observe(context);\n try {\n await use(context);\n } finally {\n // The base context fixture closes its pages next; pending captures still need them alive.\n await _ralphRecorder?.stopContext(context);\n }\n },\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["readFile","readFileSync","path","parse","printParseErrorCode","configPackage","pack","promisify","gzip","randomUUID","delay","mkdir","writeFile","packageInfo.version","playwrightPackage","readFile","base","path"],"sources":["../src/config.ts","../package.json","../src/image.ts","../src/layout.ts","../src/upload.ts","../src/recorder.ts","../src/screens.ts","../src/index.ts"],"sourcesContent":["import { readFileSync } from 'node:fs';\nimport { readFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nimport configPackage from '@ralphralphai/config/package.json' with { type: 'json' };\nimport { WebRalphConfigSchema } from '@ralphralphai/config/zod';\nimport { parse, printParseErrorCode, type ParseError } from 'jsonc-parser';\n\nimport type { ConfigSnapshot, RalphOptions } from './types';\n\nexport function resolveMode(options: RalphOptions): 'off' | 'local' | 'upload' {\n const mode =\n options.mode ??\n process.env['RALPH_MODE'] ??\n (process.env['RALPH_UPLOAD_KEY'] ? 'upload' : 'off');\n if (mode === 'off' || mode === 'local' || mode === 'upload') {\n return mode;\n }\n\n throw new Error('RALPH_MODE must be off, local, or upload.');\n}\n\ntype ConfigSource = Pick<RalphOptions, 'config' | 'configPath'>;\n\nexport async function loadConfig(\n options: ConfigSource,\n root: string,\n): Promise<ConfigSnapshot> {\n const file = configFile(options, root);\n return snapshot(options.config ?? parseConfig(await readFile(file, 'utf8')));\n}\n\n// Playwright config files may be CommonJS, so project generation cannot await.\nexport function loadConfigSync(\n options: ConfigSource,\n root: string,\n): ConfigSnapshot {\n const file = configFile(options, root);\n return snapshot(options.config ?? parseConfig(readFileSync(file, 'utf8')));\n}\n\nfunction configFile(options: ConfigSource, root: string): string {\n if (options.config && options.configPath) {\n throw new Error(\n 'Provide either ralphOptions.config or configPath, not both.',\n );\n }\n return path.resolve(root, options.configPath ?? 'ralph.jsonc');\n}\n\nfunction parseConfig(text: string): unknown {\n const errors: ParseError[] = [];\n const value: unknown = parse(text, errors, { allowTrailingComma: true });\n if (errors.length) {\n throw new Error(\n 'Invalid Ralph config: ' +\n errors\n .map(\n (error) => printParseErrorCode(error.error) + ' at ' + error.offset,\n )\n .join(', '),\n );\n }\n return value;\n}\n\nfunction snapshot(value: unknown): ConfigSnapshot {\n WebRalphConfigSchema.parse(value);\n return {\n config: JSON.parse(JSON.stringify(value)) as ConfigSnapshot['config'],\n packageVersion: configPackage.version,\n };\n}\n","","import type { Page } from '@playwright/test';\n\n/**\n * Matches the crawler's full-page screenshots: lossless measured larger than this\n * on real sites, which are mostly photographic content.\n */\nconst WEBP_QUALITY = 80;\n\nexport type Screenshot = {\n bytes: Buffer;\n contentType: 'image/webp' | 'image/png';\n width: number;\n height: number;\n};\n\nexport async function takeScreenshot(\n page: Page,\n deadline: number,\n): Promise<Screenshot> {\n const options = { fullPage: true, scale: 'css' } as const;\n const webp = await page.screenshot({\n ...options,\n type: 'webp',\n quality: WEBP_QUALITY,\n timeout: Math.max(1, deadline - Date.now()),\n });\n // WebP stores each dimension in 14 bits, so a page over 16383px has no valid\n // encoding. Chromium signals that with an empty buffer rather than an error;\n // PNG has no such limit.\n if (webp.length > 0) {\n return { bytes: webp, contentType: 'image/webp', ...webpSize(webp) };\n }\n const png = await page.screenshot({\n ...options,\n type: 'png',\n timeout: Math.max(1, deadline - Date.now()),\n });\n return {\n bytes: png,\n contentType: 'image/png',\n width: png.readUInt32BE(16),\n height: png.readUInt32BE(20),\n };\n}\n\nexport function imageExtension(contentType: Screenshot['contentType']): string {\n return contentType === 'image/png' ? '.png' : '.webp';\n}\n\nfunction webpSize(bytes: Buffer): { width: number; height: number } {\n if (\n bytes.toString('ascii', 0, 4) !== 'RIFF' ||\n bytes.toString('ascii', 8, 12) !== 'WEBP'\n ) {\n throw new Error('The screenshot is not a WebP image.');\n }\n const chunk = bytes.toString('ascii', 12, 16);\n if (chunk === 'VP8X') {\n return {\n width: 1 + bytes.readUIntLE(24, 3),\n height: 1 + bytes.readUIntLE(27, 3),\n };\n }\n if (chunk === 'VP8 ') {\n return {\n width: bytes.readUInt16LE(26) & 0x3fff,\n height: bytes.readUInt16LE(28) & 0x3fff,\n };\n }\n if (chunk === 'VP8L') {\n const bits = bytes.readUInt32LE(21);\n return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };\n }\n throw new Error('Unsupported WebP chunk: ' + chunk);\n}\n","import type { Page } from '@playwright/test';\n\nimport type { PageLayout } from './types';\n\nexport function readLayout(page: Page): Promise<PageLayout> {\n return page\n .locator('[data-track-id]')\n .filter({ visible: true })\n .evaluateAll((elements) => ({\n viewport: { width: window.innerWidth, height: window.innerHeight },\n document: {\n width: Math.max(\n document.documentElement.scrollWidth,\n document.documentElement.clientWidth,\n document.body?.scrollWidth ?? 0,\n ),\n height: Math.max(\n document.documentElement.scrollHeight,\n document.documentElement.clientHeight,\n document.body?.scrollHeight ?? 0,\n ),\n },\n scroll: { x: window.scrollX, y: window.scrollY },\n devicePixelRatio: window.devicePixelRatio,\n trackedElements: elements.flatMap((element) => {\n const trackId = element.getAttribute('data-track-id');\n const rect = element.getBoundingClientRect();\n // Off-screen horizontally (drawers, carousel slides) is dropped like\n // the crawler does; below the fold is kept, since the screenshot is\n // full-page.\n if (\n !trackId ||\n rect.width <= 0 ||\n rect.height <= 0 ||\n rect.right <= 0 ||\n rect.left >= window.innerWidth\n ) {\n return [];\n }\n return [\n {\n trackId,\n x: rect.x + window.scrollX,\n y: rect.y + window.scrollY,\n width: rect.width,\n height: rect.height,\n },\n ];\n }),\n }));\n}\n","import { promisify } from 'node:util';\nimport { gzip } from 'node:zlib';\n\nimport { pack } from 'tar-stream';\n\nimport { imageExtension, type Screenshot } from './image';\nimport type { RawGraphManifest, RalphOptions } from './types';\n\nexport type UploadOptions = {\n apiUrl: string;\n appId: string;\n uploadKey: string;\n timeoutMs?: number;\n};\nexport type RawGraphScreenshot = {\n nodeId: string;\n contentType: Screenshot['contentType'];\n bytes: Uint8Array;\n};\nexport type UploadReceipt = {\n result: 'ok';\n status: 'received';\n attemptId: string;\n resultId: string;\n replayed: boolean;\n};\n\nexport function resolveUploadOptions(options: RalphOptions): UploadOptions {\n const uploadKey = process.env['RALPH_UPLOAD_KEY'];\n const apiUrl = options.apiUrl ?? process.env['RALPH_API_URL'];\n const appId = options.appId ?? process.env['RALPH_APP_ID'];\n if (!uploadKey || !apiUrl || !appId) {\n throw new Error(\n 'Ralph upload requires RALPH_UPLOAD_KEY, RALPH_API_URL (or ralphOptions.apiUrl), and RALPH_APP_ID (or ralphOptions.appId).',\n );\n }\n return { uploadKey, apiUrl, appId, timeoutMs: options.uploadTimeoutMs };\n}\n\nexport const RAW_GRAPH_BUNDLE_INDEX = 'raw_graph.json';\nexport const RAW_GRAPH_BUNDLE_IMAGE_DIR = 'assets/images';\n\nexport type RawGraphBundleIndex = {\n manifest: RawGraphManifest;\n screenshots: { nodeId: string; path: string }[];\n};\n\n/**\n * Packs the manifest and screenshots into a gzipped tar archive:\n *\n * raw_graph.json { manifest, screenshots: [{ nodeId, path }] }\n * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)\n */\nexport async function createRawGraphBundle(\n manifest: RawGraphManifest,\n screenshots: readonly RawGraphScreenshot[],\n): Promise<Uint8Array> {\n if (\n Buffer.byteLength(JSON.stringify(manifest)) > 2 * 1024 * 1024 ||\n screenshots.length > 500 ||\n screenshots.reduce(\n (total, screenshot) => total + screenshot.bytes.byteLength,\n 0,\n ) >\n 64 * 1024 * 1024\n ) {\n throw new Error('Ralph raw graph exceeds upload limits.');\n }\n\n const images = screenshots.map(({ nodeId, contentType, bytes }) => {\n if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) {\n throw new Error(`Ralph node id is not path-safe: ${nodeId}`);\n }\n return {\n nodeId,\n path: `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`,\n bytes,\n };\n });\n const index: RawGraphBundleIndex = {\n manifest,\n screenshots: images.map(({ nodeId, path }) => ({ nodeId, path })),\n };\n\n // Fixed attributes keep retries of the same attempt byte-identical.\n const attrs = { mtime: new Date(0), uid: 0, gid: 0 };\n const tar = pack();\n tar.entry({ name: RAW_GRAPH_BUNDLE_INDEX, ...attrs }, JSON.stringify(index));\n for (const { path, bytes } of images) {\n tar.entry({ name: path, ...attrs }, Buffer.from(bytes));\n }\n tar.finalize();\n const chunks: Buffer[] = [];\n for await (const chunk of tar) {\n chunks.push(chunk as Buffer);\n }\n const compressed = await promisify(gzip)(Buffer.concat(chunks));\n if (compressed.byteLength > 64 * 1024 * 1024) {\n throw new Error('Ralph compressed raw graph bundle exceeds upload limits.');\n }\n\n return compressed;\n}\n\nfunction isLoopback(url: URL): boolean {\n return (\n url.hostname === 'localhost' ||\n url.hostname === '[::1]' ||\n /^127(\\.\\d{1,3}){3}$/.test(url.hostname)\n );\n}\n\nexport async function uploadRawGraph(\n manifest: RawGraphManifest,\n screenshots: readonly RawGraphScreenshot[],\n options: UploadOptions,\n): Promise<UploadReceipt> {\n if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.appId)) {\n throw new Error(`Ralph app id is not valid: ${options.appId}`);\n }\n const url = new URL(\n `/api/upload/playwright/apps/${options.appId}/attempts`,\n options.apiUrl,\n );\n if (\n !(\n url.protocol === 'https:' ||\n (url.protocol === 'http:' && isLoopback(url))\n ) ||\n !options.uploadKey\n ) {\n throw new Error(\n 'Ralph upload requires an HTTPS API URL (HTTP only for loopback hosts) and an upload key.',\n );\n }\n\n const timeoutMs = options.timeoutMs ?? 30_000;\n if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {\n throw new Error('Ralph uploadTimeoutMs must be positive and finite.');\n }\n\n const blob = new Blob(\n [new Uint8Array(await createRawGraphBundle(manifest, screenshots))],\n { type: 'application/gzip' },\n );\n\n const response = await fetch(url, {\n method: 'POST',\n headers: { Authorization: `Bearer ${options.uploadKey}` },\n body: blob,\n redirect: 'error',\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (!response.ok) {\n throw new Error(\n `Ralph upload failed (HTTP ${response.status}). Local Ralph artifacts are retained for retry.`,\n );\n }\n\n const receipt = (await response.json()) as Partial<UploadReceipt>;\n if (\n receipt.result !== 'ok' ||\n receipt.status !== 'received' ||\n receipt.attemptId !== manifest.attemptId ||\n typeof receipt.resultId !== 'string' ||\n typeof receipt.replayed !== 'boolean'\n ) {\n throw new Error('Ralph server returned an invalid upload receipt.');\n }\n return receipt as UploadReceipt;\n}\n","import { randomUUID } from 'node:crypto';\nimport { mkdir, readFile, writeFile } from 'node:fs/promises';\nimport { setTimeout as delay } from 'node:timers/promises';\n\nimport type { BrowserContext, Frame, Page, TestInfo } from '@playwright/test';\nimport playwrightPackage from '@playwright/test/package.json' with { type: 'json' };\n\nimport packageInfo from '../package.json';\nimport { imageExtension, takeScreenshot, type Screenshot } from './image';\nimport { readLayout } from './layout';\nimport type {\n RawGraphManifest,\n RawNode,\n RecordNodeOptions,\n ConfigSnapshot,\n Ralph,\n RalphOptions,\n} from './types';\nimport { uploadRawGraph, type UploadOptions } from './upload';\n\nexport const MANIFEST_ATTACHMENT = 'ralph-raw-graph';\n\nconst DEFAULT_SETTLE_MS = 150;\nconst DEFAULT_RECORD_TIMEOUT_MS = 5_000;\n\n/** Where the raw graph and screenshots are written, inside Playwright's per-test output directory. */\nconst OUTPUT_DIR = 'ralph';\n\ntype PageInfo = RawGraphManifest['pages'][number];\n\ntype NodeRequest = Omit<RawNode, 'status'>;\n\ntype RecordedNode = Extract<RawNode, { status: 'recorded' }>;\n\n/** What the recorder tracks for one browser tab. */\ntype PageState = {\n page: Page;\n info: PageInfo;\n\n /** The URL of the last navigation, so a reload is not recorded again. */\n lastUrl: string;\n\n /**\n * Bumped on every main-frame navigation, including reloads. A recording that\n * sees a different generation when it finishes was overtaken by one.\n */\n generation: number;\n\n /** The next node's position within this tab. */\n sequence: number;\n\n /** The last queued recording. Recordings in a tab run one at a time. */\n tail: Promise<void>;\n\n /** Resolves once `info.openerPageId` is known. */\n opener: Promise<void>;\n\n onNavigation: (frame: Frame) => void;\n};\n\nexport class Recorder implements Ralph {\n /**\n * Identifies this run of the test. A retry gets a new recorder, and so a new\n * id, which is what lets the server tell retries apart and dedupe re-uploads.\n */\n private readonly attemptId = randomUUID();\n private readonly startedAt = new Date().toISOString();\n\n /** How long to wait after a navigation's DOM loads before recording. */\n private readonly settleMs: number;\n\n /**\n * The budget for recording one node, from settling through the screenshot.\n * Past it, the node is recorded as failed.\n */\n private readonly recordTimeoutMs: number;\n\n /**\n * Every context being watched. `onPage` is kept so `stopContext` can remove\n * the exact listener `observe` added. The id is stored in the manifest's\n * `pages[].contextId`.\n */\n private readonly contexts = new Map<\n BrowserContext,\n { id: string; onPage: (page: Page) => void }\n >();\n\n /**\n * Every tab seen in a watched context, including closed ones, so their\n * nodes and opener links still reach the manifest.\n */\n private readonly pages = new Map<Page, PageState>();\n\n /**\n * Each node's outcome, in completion order. Nodes in one tab complete\n * in sequence, but tabs interleave; `finish` sorts them.\n */\n private readonly nodes: RawNode[] = [];\n\n /**\n * When each node was requested, keyed by node id, for linking a\n * popup's first node to its opener's latest. Numbers, so they compare\n * without parsing each node's `requestedAt` string.\n */\n private readonly requestedAtMs = new Map<string, number>();\n\n /** Set by `finish`, after which `observe` throws. */\n private closed = false;\n\n constructor(\n private readonly options: RalphOptions,\n private readonly config: ConfigSnapshot,\n private readonly testInfo: TestInfo,\n private readonly upload?: UploadOptions,\n ) {\n this.settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;\n this.recordTimeoutMs = options.recordTimeoutMs ?? DEFAULT_RECORD_TIMEOUT_MS;\n\n const validTimings =\n Number.isFinite(this.settleMs) &&\n this.settleMs >= 0 &&\n Number.isFinite(this.recordTimeoutMs) &&\n this.recordTimeoutMs > this.settleMs;\n if (!validTimings) {\n throw new Error(\n 'Ralph recordTimeoutMs must be finite and greater than settleMs, which must be non-negative.',\n );\n }\n }\n\n // ---------------------------------------------------------------------------\n // Public API\n // ---------------------------------------------------------------------------\n\n /** Starts recording URL changes in every current and future tab of `context`. */\n observe(context: BrowserContext): void {\n if (this.closed) {\n throw new Error('The Ralph recorder has finished.');\n }\n if (this.contexts.has(context)) {\n return;\n }\n\n const id = 'context-' + this.contexts.size;\n const onPage = (page: Page) => this.watchPage(page, id);\n this.contexts.set(context, { id, onPage });\n\n context.on('page', onPage);\n for (const page of context.pages()) {\n onPage(page);\n }\n }\n\n /** Records the page as it is now, e.g. an open dialog at the same URL. */\n async recordNode(page: Page, options: RecordNodeOptions = {}): Promise<void> {\n this.observe(page.context());\n const state = this.pages.get(page);\n if (!state) {\n throw new Error('Cannot record a closed browser page.');\n }\n await this.enqueue(state, 'explicit', options);\n }\n\n /** Waits for every queued recording to finish. */\n async flush(): Promise<void> {\n await Promise.all([...this.pages.values()].map((state) => state.tail));\n }\n\n /** Stops watching `context` and waits for its queued recordings. */\n async stopContext(context: BrowserContext): Promise<void> {\n const entry = this.contexts.get(context);\n if (entry) {\n context.off('page', entry.onPage);\n }\n\n const states = [...this.pages.values()].filter(\n (state) => state.page.context() === context,\n );\n for (const state of states) {\n state.page.off('framenavigated', state.onNavigation);\n }\n await Promise.all(states.map((state) => state.tail));\n }\n\n /**\n * Stops recording, then writes the manifest, uploads it if configured, and\n * fails a passing test whose nodes failed to record.\n */\n async finish(): Promise<void> {\n this.closed = true;\n await Promise.all(\n [...this.contexts.keys()].map((context) => this.stopContext(context)),\n );\n await Promise.all([...this.pages.values()].map((state) => state.opener));\n\n const nodes = this.nodes.sort(\n (a, b) => a.pageId.localeCompare(b.pageId) || a.sequence - b.sequence,\n );\n linkPreviousNodes(\n nodes,\n (pageId) => this.pageInfo(pageId)?.openerPageId,\n (nodeId) => this.requestedAtMs.get(nodeId) ?? 0,\n );\n\n const failures = nodes.filter((item) => item.status === 'failed');\n const manifest = this.buildManifest(nodes, failures.length);\n await this.writeManifest(manifest);\n if (this.upload) {\n await this.uploadManifest(manifest, this.upload);\n }\n\n if (failures.length && this.testInfo.status === 'passed') {\n throw new Error(\n 'Ralph could not record ' +\n failures.length +\n ' page state(s). See the ralph-raw-graph attachment.',\n );\n }\n }\n\n // ---------------------------------------------------------------------------\n // Watching tabs\n // ---------------------------------------------------------------------------\n\n private watchPage(page: Page, contextId: string): void {\n if (this.pages.has(page)) {\n return;\n }\n\n const state: PageState = {\n page,\n info: {\n pageId: 'page-' + this.pages.size,\n contextId,\n browserName: page.context().browser()?.browserType().name(),\n },\n lastUrl: '',\n generation: 0,\n sequence: 0,\n tail: Promise.resolve(),\n opener: Promise.resolve(),\n onNavigation: (frame) => {\n if (frame === page.mainFrame()) {\n this.urlChanged(state);\n }\n },\n };\n this.pages.set(page, state);\n\n state.opener = page\n .opener()\n .then((opener) => {\n if (opener) {\n state.info.openerPageId = this.pages.get(opener)?.info.pageId;\n }\n })\n .catch(() => undefined);\n\n page.on('framenavigated', state.onNavigation);\n this.urlChanged(state);\n }\n\n private urlChanged(state: PageState): void {\n state.generation++;\n\n const url = state.page.url();\n if (url === state.lastUrl) {\n return;\n }\n state.lastUrl = url;\n\n if (isHttpUrl(url)) {\n // Automatic recordings never fail the test; failures land in the manifest.\n void this.enqueue(state, 'url-change', {}).catch(() => undefined);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Recording\n // ---------------------------------------------------------------------------\n\n /** Queues a recording behind the tab's earlier ones. */\n private enqueue(\n state: PageState,\n trigger: RawNode['trigger'],\n options: RecordNodeOptions,\n ): Promise<void> {\n const actualUrl = state.page.url();\n const url =\n options.url === undefined\n ? actualUrl\n : new URL(options.url, actualUrl).href;\n if (!isHttpUrl(actualUrl) || !isHttpUrl(url)) {\n throw new Error(\n 'Ralph recordings require HTTP or HTTPS page and recorded URLs.',\n );\n }\n\n const request: NodeRequest = {\n nodeId: randomUUID(),\n pageId: state.info.pageId,\n sequence: state.sequence++,\n requestedAt: new Date().toISOString(),\n actualUrl,\n url,\n trigger,\n ...(options.state === undefined ? {} : { state: options.state }),\n };\n this.requestedAtMs.set(request.nodeId, Date.now());\n\n const generation = state.generation;\n const task = state.tail.then(() => this.record(state, generation, request));\n state.tail = task.catch(() => undefined);\n\n return task;\n }\n\n /**\n * Records one node and its outcome. Fails if the tab navigates or\n * closes before it finishes, or if it runs past the record timeout.\n */\n private async record(\n state: PageState,\n generation: number,\n request: NodeRequest,\n ): Promise<void> {\n const { page } = state;\n const deadline = startDeadline(\n this.recordTimeoutMs,\n 'Ralph recording timed out.',\n );\n const { wait } = deadline;\n const navigatedAway = () =>\n state.generation !== generation || page.url() !== request.actualUrl;\n\n const check = () => {\n deadline.signal.throwIfAborted();\n if (navigatedAway()) {\n throw new Error('The page navigated before its recording completed.');\n }\n if (page.isClosed()) {\n throw new Error('The page closed before its recording completed.');\n }\n };\n\n try {\n check();\n if (request.trigger === 'url-change') {\n await this.waitForPageToSettle(page, deadline, check);\n }\n\n // Geometry is read on both sides of the screenshot to detect layout drift.\n check();\n const before = await wait(readLayout(page));\n check();\n const image = await wait(takeScreenshot(page, deadline.endsAt));\n check();\n const after = await wait(readLayout(page));\n check();\n\n this.nodes.push({\n ...request,\n status: 'recorded',\n recordedAt: new Date().toISOString(),\n layout: before,\n geometryStable: JSON.stringify(before) === JSON.stringify(after),\n screenshot: await this.saveScreenshot(request.nodeId, image),\n });\n } catch (error) {\n this.nodes.push({\n ...request,\n status: navigatedAway() ? 'superseded' : 'failed',\n reason: error instanceof Error ? error.message : String(error),\n });\n if (request.trigger === 'explicit') {\n throw error;\n }\n } finally {\n deadline.cancel();\n }\n }\n\n /** After a navigation: the DOM, the settle delay, then the `ready` hook. */\n private async waitForPageToSettle(\n page: Page,\n { wait, signal }: Deadline,\n check: () => void,\n ): Promise<void> {\n await wait(\n page.waitForLoadState('domcontentloaded', {\n timeout: this.recordTimeoutMs,\n }),\n );\n await wait(delay(this.settleMs, undefined, { signal }));\n check();\n if (this.options.ready) {\n await wait(this.options.ready(page, signal));\n }\n }\n\n /** Writes the image to the test output and attaches it to the report. */\n private async saveScreenshot(\n nodeId: string,\n image: Screenshot,\n ): Promise<RecordedNode['screenshot']> {\n const path = OUTPUT_DIR + '/' + nodeId + imageExtension(image.contentType);\n const attachmentName = 'ralph-screenshot-' + nodeId;\n\n await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });\n await writeFile(this.testInfo.outputPath(path), image.bytes);\n await this.testInfo.attach(attachmentName, {\n path: this.testInfo.outputPath(path),\n contentType: image.contentType,\n });\n\n return {\n path,\n attachmentName,\n width: image.width,\n height: image.height,\n scale: 'css',\n contentType: image.contentType,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Manifest\n // ---------------------------------------------------------------------------\n\n private pageInfo(pageId: string): PageInfo | undefined {\n return [...this.pages.values()].find(\n (state) => state.info.pageId === pageId,\n )?.info;\n }\n\n private buildManifest(\n nodes: RawNode[],\n failureCount: number,\n ): RawGraphManifest {\n const { testInfo } = this;\n const testPassed = testInfo.status === 'passed';\n\n return {\n formatVersion: 1,\n producer: {\n name: '@ralphralphai/playwright',\n version: packageInfo.version,\n playwrightVersion: playwrightPackage.version,\n },\n runId: this.options.runId ?? process.env['RALPH_RUN_ID'],\n buildId: this.options.buildId ?? process.env['RALPH_BUILD_ID'],\n attemptId: this.attemptId,\n test: {\n id: testInfo.testId,\n titlePath: testInfo.titlePath,\n project: testInfo.project.name,\n retry: testInfo.retry,\n repeatEachIndex: testInfo.repeatEachIndex,\n workerIndex: testInfo.workerIndex,\n parallelIndex: testInfo.parallelIndex,\n shard: testInfo.config.shard,\n // Failed nodes fail the test in `finish`, after this is written.\n status:\n failureCount && testPassed\n ? 'failed'\n : (testInfo.status ?? 'unknown'),\n expectedStatus: testInfo.expectedStatus,\n },\n startedAt: this.startedAt,\n finishedAt: new Date().toISOString(),\n config: this.config,\n pages: [...this.pages.values()].map((state) => state.info),\n nodes,\n complete:\n testPassed &&\n nodes.length > 0 &&\n nodes.every(\n (item) => item.status === 'recorded' && item.geometryStable,\n ),\n };\n }\n\n private async writeManifest(manifest: RawGraphManifest): Promise<void> {\n const file = this.testInfo.outputPath(OUTPUT_DIR, 'raw_graph.json');\n await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });\n await writeFile(file, JSON.stringify(manifest, null, 2) + '\\n');\n await this.testInfo.attach(MANIFEST_ATTACHMENT, {\n path: file,\n contentType: 'application/json',\n });\n }\n\n private async uploadManifest(\n manifest: RawGraphManifest,\n upload: UploadOptions,\n ): Promise<void> {\n const recorded = manifest.nodes.filter(\n (item): item is RecordedNode => item.status === 'recorded',\n );\n const screenshots = [];\n for (const { nodeId, screenshot } of recorded) {\n screenshots.push({\n nodeId,\n contentType: screenshot.contentType,\n bytes: await readFile(this.testInfo.outputPath(screenshot.path)),\n });\n }\n\n const receipt = await uploadRawGraph(manifest, screenshots, upload);\n await this.testInfo.attach('ralph-upload-receipt', {\n body: JSON.stringify(receipt),\n contentType: 'application/json',\n });\n }\n}\n\n// -----------------------------------------------------------------------------\n// Helpers\n// -----------------------------------------------------------------------------\n\nfunction isHttpUrl(url: string): boolean {\n return /^https?:/.test(url);\n}\n\ntype Deadline = {\n signal: AbortSignal;\n /** Epoch milliseconds at which the deadline passes. */\n endsAt: number;\n /** Rejects with the timeout error if `promise` outlives the deadline. */\n wait: <T>(promise: Promise<T>) => Promise<T>;\n cancel: () => void;\n};\n\nfunction startDeadline(timeoutMs: number, message: string): Deadline {\n const controller = new AbortController();\n const { signal } = controller;\n const timer = setTimeout(\n () => controller.abort(new Error(message)),\n timeoutMs,\n );\n const aborted = new Promise<never>((_, reject) => {\n signal.addEventListener('abort', () => reject(signal.reason), {\n once: true,\n });\n });\n // Nothing may be waiting when the deadline passes.\n aborted.catch(() => undefined);\n\n return {\n signal,\n endsAt: Date.now() + timeoutMs,\n wait: (promise) => Promise.race([promise, aborted]),\n cancel: () => clearTimeout(timer),\n };\n}\n\n/**\n * Sets `previousNodeId` on each node: the one before it in the same\n * tab, or for a popup's first node, its opener's latest. Superseded nodes\n * are skipped, since their page state was never shown.\n *\n * `nodes` must be sorted by page, then sequence.\n */\nfunction linkPreviousNodes(\n nodes: RawNode[],\n openerOf: (pageId: string) => string | undefined,\n requestedAtMs: (nodeId: string) => number,\n): void {\n const byPage = new Map<string, RawNode[]>();\n for (const item of nodes) {\n byPage.set(item.pageId, [...(byPage.get(item.pageId) ?? []), item]);\n }\n\n const openerNodeBefore = (item: RawNode) => {\n const openerPageId = openerOf(item.pageId);\n const requestedAt = requestedAtMs(item.nodeId);\n return byPage\n .get(openerPageId ?? '')\n ?.filter((candidate) => requestedAtMs(candidate.nodeId) <= requestedAt)\n .at(-1);\n };\n\n // First the raw chain, superseded nodes included.\n const previous = new Map<RawNode, RawNode>();\n for (const items of byPage.values()) {\n items.forEach((item, index) => {\n const from = index > 0 ? items[index - 1] : openerNodeBefore(item);\n if (from) {\n previous.set(item, from);\n }\n });\n }\n\n // Then skip past superseded ones, e.g. a redirect or a replaced route.\n for (const item of nodes) {\n let from = previous.get(item);\n while (from?.status === 'superseded') {\n from = previous.get(from);\n }\n if (from) {\n item.previousNodeId = from.nodeId;\n }\n }\n}\n","import type {\n PlaywrightTestOptions,\n PlaywrightWorkerOptions,\n Project,\n TestInfo,\n} from '@playwright/test';\nimport type { ScreenSize } from '@ralphralphai/config';\n\nimport { loadConfigSync } from './config';\nimport type { RalphOptions } from './types';\n\ntype RalphProject = Project<\n PlaywrightTestOptions & { ralphOptions: RalphOptions },\n PlaywrightWorkerOptions\n>;\n\nexport const SCREEN_TAG_PREFIX = '@ralph-screen:';\n\nexport type RalphProjectOptions = Pick<\n RalphOptions,\n 'config' | 'configPath'\n> & {\n /** Resolves a relative `configPath`; defaults to the current directory. */\n root?: string;\n};\n\ntype ScreenMetadata = { screenSize: ScreenSize; screenSizes: ScreenSize[] };\n\nexport function screenLabel(size: ScreenSize): string {\n return size.width + 'x' + size.height;\n}\n\n/**\n * Expands `base` into one project per `screenSizes` entry of the Ralph config.\n * Each project's viewport is that screen size.\n */\nexport function ralphProjects(\n base: RalphProject = {},\n options: RalphProjectOptions = {},\n): RalphProject[] {\n const { screenSizes } = loadConfigSync(\n options,\n options.root ?? process.cwd(),\n ).config;\n if (!screenSizes.length) {\n throw new Error('The Ralph config declares no screenSizes.');\n }\n return screenSizes.map((screenSize) => {\n const label = screenLabel(screenSize);\n const ralph: ScreenMetadata = { screenSize, screenSizes };\n return {\n ...base,\n name: base.name ? base.name + ' ' + label : label,\n use: {\n ...base.use,\n viewport: { width: screenSize.width, height: screenSize.height },\n },\n metadata: { ...base.metadata, ralph },\n };\n });\n}\n\n/**\n * Skips a test whose `@ralph-screen:WxH` tags exclude the current project's\n * screen size. Untagged tests run at every size; other projects are untouched.\n */\nexport function applyScreenSelection(testInfo: TestInfo): void {\n const selected = testInfo.tags\n .filter((tag) => tag.startsWith(SCREEN_TAG_PREFIX))\n .map((tag) => tag.slice(SCREEN_TAG_PREFIX.length));\n const metadata = (testInfo.project.metadata as { ralph?: ScreenMetadata })\n .ralph;\n if (!selected.length || !metadata) {\n return;\n }\n const known = metadata.screenSizes.map(screenLabel);\n const unknown = selected.filter((label) => !known.includes(label));\n if (unknown.length) {\n throw new Error(\n 'Unknown Ralph screen size ' +\n unknown.join(', ') +\n '; the config declares ' +\n known.join(', ') +\n '.',\n );\n }\n const current = screenLabel(metadata.screenSize);\n testInfo.skip(\n !selected.includes(current),\n 'Not selected for Ralph screen size ' + current + '.',\n );\n}\n","import path from 'node:path';\n\nimport { test as base } from '@playwright/test';\nimport type {\n PlaywrightTestArgs,\n PlaywrightTestOptions,\n PlaywrightWorkerArgs,\n PlaywrightWorkerOptions,\n TestType,\n} from '@playwright/test';\n\nimport { loadConfig, resolveMode } from './config';\nimport { Recorder } from './recorder';\nimport { applyScreenSelection } from './screens';\nimport type { ConfigSnapshot, Ralph, RalphOptions } from './types';\nimport { resolveUploadOptions } from './upload';\n\nexport { expect, mergeTests } from '@playwright/test';\nexport type { WebRalphConfig } from '@ralphralphai/config';\nexport { createRawGraphBundle, uploadRawGraph } from './upload';\nexport type {\n UploadOptions,\n UploadReceipt,\n RawGraphScreenshot,\n RawGraphBundleIndex,\n} from './upload';\nexport { MANIFEST_ATTACHMENT } from './recorder';\nexport { ralphProjects, SCREEN_TAG_PREFIX } from './screens';\nexport type { RalphProjectOptions } from './screens';\nexport type {\n RawGraphManifest,\n RawNode,\n RecordNodeOptions,\n ConfigSnapshot,\n PageLayout,\n Ralph,\n RalphOptions,\n TrackedElement,\n} from './types';\n\nexport type RalphFixtures = { ralph: Ralph; ralphOptions: RalphOptions };\n\ntype InternalFixtures = {\n _ralphScreen: void;\n _ralphRecorder: Recorder | null;\n};\ntype WorkerFixtures = { _ralphConfigs: Map<string, Promise<ConfigSnapshot>> };\n\nexport const test: TestType<\n PlaywrightTestArgs & PlaywrightTestOptions & RalphFixtures,\n PlaywrightWorkerArgs & PlaywrightWorkerOptions\n> = base.extend<RalphFixtures & InternalFixtures, WorkerFixtures>({\n ralphOptions: [{}, { option: true }],\n _ralphConfigs: [\n // Playwright requires destructuring here to discover fixture dependencies.\n // oxlint-disable-next-line no-empty-pattern\n async ({}, use) => {\n await use(new Map());\n },\n { scope: 'worker' },\n ],\n _ralphScreen: [\n // Playwright requires destructuring here to discover fixture dependencies.\n // oxlint-disable-next-line no-empty-pattern\n async ({}, use, testInfo) => {\n // Screen selection chooses which flows run, so it applies in every mode.\n applyScreenSelection(testInfo);\n await use();\n },\n { auto: true },\n ],\n _ralphRecorder: [\n // _ralphScreen is specified here so that the playwright treat that it is\n // a dependency of this fixture.\n async ({ ralphOptions, _ralphConfigs, _ralphScreen }, use, testInfo) => {\n if (\n !testInfo.tags.includes('@ralph') ||\n resolveMode(ralphOptions) === 'off'\n ) {\n await use(null);\n return;\n }\n\n const upload =\n resolveMode(ralphOptions) === 'upload'\n ? resolveUploadOptions(ralphOptions)\n : undefined;\n\n const root = testInfo.config.configFile\n ? path.dirname(testInfo.config.configFile)\n : process.cwd();\n\n const key = JSON.stringify({\n root,\n config: ralphOptions.config,\n configPath: ralphOptions.configPath,\n });\n\n let pending = _ralphConfigs.get(key);\n if (!pending) {\n pending = loadConfig(ralphOptions, root);\n _ralphConfigs.set(key, pending);\n }\n\n const recorder = new Recorder(\n ralphOptions,\n await pending,\n testInfo,\n upload,\n );\n\n try {\n await use(recorder);\n } finally {\n await recorder.finish();\n }\n },\n { auto: true, timeout: 30_000 },\n ],\n ralph: async ({ _ralphRecorder, ralphOptions }, use) => {\n const checkOptIn = () => {\n if (resolveMode(ralphOptions) !== 'off') {\n throw new Error('Opt this test into Ralph with the @ralph tag.');\n }\n };\n\n await use(\n _ralphRecorder ?? {\n recordNode: async () => {\n checkOptIn();\n },\n flush: async () => {},\n observe: () => {\n checkOptIn();\n },\n },\n );\n },\n context: async ({ context, _ralphRecorder }, use) => {\n _ralphRecorder?.observe(context);\n try {\n await use(context);\n } finally {\n // The base context fixture closes its pages next; pending recordings still need them alive.\n await _ralphRecorder?.stopContext(context);\n }\n },\n});\n"],"mappings":"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"}