@ralphralphai/playwright 1.9.0 → 2.0.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.
@@ -70,7 +70,7 @@ function snapshot(value) {
70
70
  }
71
71
  //#endregion
72
72
  //#region package.json
73
- var version = "1.9.0";
73
+ var version = "2.0.0";
74
74
  //#endregion
75
75
  //#region src/actions.ts
76
76
  const BINDING = "__ralphAction";
@@ -547,6 +547,192 @@ async function waitForAnimations(page, timeoutMs) {
547
547
  if (!(error instanceof _playwright_test.errors.TimeoutError)) throw error;
548
548
  }
549
549
  }
550
+ const MAX_ARIA_DEPTH = Math.floor(128 / 2) - 2;
551
+ const STYLE_PROPERTIES = [
552
+ "font-family",
553
+ "font-size",
554
+ "font-weight",
555
+ "font-style",
556
+ "color",
557
+ "background-color"
558
+ ];
559
+ const TRANSPARENT = "rgba(0, 0, 0, 0)";
560
+ const ELEMENT_LOOKUP_TIMEOUT_MS = 2e3;
561
+ /**
562
+ * The page's aria tree as JSON, with each element's box in document
563
+ * coordinates, its distinctive styles, and a hash of its image. Undefined when
564
+ * the snapshot is past the server's limits.
565
+ *
566
+ * The page is not scrolled first, because scrolling changes the page the
567
+ * screenshot just showed: sticky headers, lazy loading, scroll-triggered
568
+ * animation.
569
+ */
570
+ async function captureAriaSnapshot(page, { viewport, scroll }) {
571
+ const snapshot = await page.locator("body").ariaSnapshotJSON({
572
+ mode: "ai",
573
+ boxes: true,
574
+ depth: MAX_ARIA_DEPTH
575
+ });
576
+ const visible = (await Promise.all(snapshot.map((node) => toDocument(page, node, scroll)))).flatMap((node) => {
577
+ const kept = keepShown(node, viewport.width);
578
+ return kept === void 0 ? [] : [kept];
579
+ });
580
+ const described = await Promise.all(visible.map((node) => describe(page, node, {})));
581
+ const json = Buffer.from(JSON.stringify(fillMissingRefs(described)));
582
+ return json.byteLength > 4194304 ? void 0 : json;
583
+ }
584
+ /**
585
+ * Moves the viewport boxes Playwright reports into the full-page screenshot's
586
+ * space. This has to happen before `keepShown`, which in viewport space would
587
+ * drop everything scrolled above the viewport, and no later move brings those
588
+ * back.
589
+ *
590
+ * An iframe's descendants are boxed in its own viewport, so they move by
591
+ * where that viewport sits: the iframe's box plus its border and padding.
592
+ */
593
+ async function toDocument(page, node, offset) {
594
+ const box = node.box && {
595
+ ...node.box,
596
+ x: node.box.x + offset.x,
597
+ y: node.box.y + offset.y
598
+ };
599
+ let childOffset = offset;
600
+ if (node.role === "iframe" && box !== void 0) {
601
+ const inset = (node.ref === void 0 ? void 0 : await readContentInset(page, node.ref)) ?? {
602
+ x: 0,
603
+ y: 0
604
+ };
605
+ childOffset = {
606
+ x: box.x + inset.x,
607
+ y: box.y + inset.y
608
+ };
609
+ }
610
+ return {
611
+ ...node,
612
+ ...box && { box },
613
+ ...node.children && { children: await Promise.all(node.children.map((child) => typeof child === "string" ? child : toDocument(page, child, childOffset))) }
614
+ };
615
+ }
616
+ async function readContentInset(page, ref) {
617
+ try {
618
+ return await page.locator(`aria-ref=${ref}`).evaluate((element) => {
619
+ const computed = getComputedStyle(element);
620
+ return {
621
+ x: element.clientLeft + parseFloat(computed.paddingLeft),
622
+ y: element.clientTop + parseFloat(computed.paddingTop)
623
+ };
624
+ }, void 0, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
625
+ } catch {
626
+ return;
627
+ }
628
+ }
629
+ /**
630
+ * In document space, the only boxes no scroll reveals are those past the left
631
+ * or right of the viewport's width (a collapsed sidebar, an off-canvas menu)
632
+ * or above the top of the document.
633
+ */
634
+ function isReachable(box, viewportWidth) {
635
+ if (box === void 0) return false;
636
+ return box.x < viewportWidth && box.x + box.width > 0 && box.y + box.height > 0;
637
+ }
638
+ function isShown(node, viewportWidth) {
639
+ return node.ref !== void 0 && isReachable(node.box, viewportWidth);
640
+ }
641
+ function keepShown(node, viewportWidth) {
642
+ const shown = isShown(node, viewportWidth);
643
+ const children = (node.children ?? []).flatMap((child) => {
644
+ if (typeof child === "string") return shown ? [child] : [];
645
+ const kept = keepShown(child, viewportWidth);
646
+ return kept === void 0 ? [] : [kept];
647
+ });
648
+ if (!shown && children.length === 0) return;
649
+ return node.children === void 0 ? node : {
650
+ ...node,
651
+ children
652
+ };
653
+ }
654
+ function fillMissingRefs(nodes) {
655
+ let generated = 0;
656
+ const fill = (child) => {
657
+ if (typeof child === "string") return child;
658
+ generated += 1;
659
+ const ref = child.ref ?? `n${generated}`;
660
+ return child.children === void 0 ? {
661
+ ...child,
662
+ ref
663
+ } : {
664
+ ...child,
665
+ ref,
666
+ children: child.children.map(fill)
667
+ };
668
+ };
669
+ return nodes.map(fill);
670
+ }
671
+ async function readElement(page, ref, isImage) {
672
+ try {
673
+ return await page.locator(`aria-ref=${ref}`).evaluate((element, { properties, isImage }) => {
674
+ const computed = getComputedStyle(element);
675
+ const style = Object.fromEntries(properties.map((property) => [property, computed.getPropertyValue(property)]));
676
+ if (!isImage) return { style };
677
+ if (element instanceof HTMLImageElement) return {
678
+ style,
679
+ image: { src: element.currentSrc || element.src }
680
+ };
681
+ if (element instanceof SVGElement) return {
682
+ style,
683
+ image: { markup: element.outerHTML }
684
+ };
685
+ const backgroundUrl = /url\("(.+)"\)/.exec(computed.backgroundImage)?.[1];
686
+ return backgroundUrl === void 0 ? { style } : {
687
+ style,
688
+ image: { src: new URL(backgroundUrl, document.baseURI).href }
689
+ };
690
+ }, {
691
+ properties: STYLE_PROPERTIES,
692
+ isImage
693
+ }, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
694
+ } catch {
695
+ return;
696
+ }
697
+ }
698
+ function sha256(data) {
699
+ return (0, node_crypto.createHash)("sha256").update(data).digest("hex");
700
+ }
701
+ async function hashImage(page, image) {
702
+ if ("markup" in image) return sha256(image.markup);
703
+ if (image.src === "") return;
704
+ if (image.src.startsWith("data:")) return sha256(image.src);
705
+ try {
706
+ const response = await page.request.get(image.src);
707
+ try {
708
+ return response.ok() ? sha256(await response.body()) : void 0;
709
+ } finally {
710
+ await response.dispose();
711
+ }
712
+ } catch {
713
+ return;
714
+ }
715
+ }
716
+ function distinctStyle(style, inherited) {
717
+ const distinct = Object.entries(style).filter(([property, value]) => {
718
+ if (property === "background-color" && value === TRANSPARENT) return false;
719
+ return inherited[property] !== value;
720
+ });
721
+ return distinct.length === 0 ? void 0 : Object.fromEntries(distinct);
722
+ }
723
+ async function describe(page, child, inherited) {
724
+ if (typeof child === "string") return child;
725
+ const facts = child.ref === void 0 ? void 0 : await readElement(page, child.ref, child.role === "img");
726
+ const style = facts === void 0 ? void 0 : distinctStyle(facts.style, inherited);
727
+ const imageHash = facts?.image === void 0 ? void 0 : await hashImage(page, facts.image);
728
+ const children = child.children === void 0 ? void 0 : await Promise.all(child.children.map((grandchild) => describe(page, grandchild, facts?.style ?? inherited)));
729
+ return {
730
+ ...child,
731
+ ...style && { style },
732
+ ...imageHash && { imageHash },
733
+ ...children && { children }
734
+ };
735
+ }
550
736
  //#endregion
551
737
  //#region src/conditions.ts
552
738
  const declared = (declaration = {}) => {
@@ -726,6 +912,8 @@ const MANIFEST_ATTACHMENT = "ralph-raw-graph";
726
912
  const REPORTER_ENV = "RALPH_PLAYWRIGHT_REPORTER";
727
913
  /** Prefix of the attachment holding each recorded node's screenshot; the node id follows. */
728
914
  const SCREENSHOT_ATTACHMENT_PREFIX = "ralph-screenshot-";
915
+ /** Prefix of the attachment holding each recorded node's aria snapshot; the node id follows. */
916
+ const ARIA_SNAPSHOT_ATTACHMENT_PREFIX = "ralph-aria-snapshot-";
729
917
  /** Once per worker: a run without the tracker says so once, not per node. */
730
918
  let warnedNoTracker = false;
731
919
  const DEFAULT_SETTLE_MS = 150;
@@ -943,11 +1131,14 @@ var Recorder = class {
943
1131
  const deadline = startDeadline(this.recordTimeoutMs, "Ralph recording timed out.");
944
1132
  const { wait } = deadline;
945
1133
  const navigatedAway = () => state.generation !== generation || page.url() !== request.actualUrl;
946
- const check = () => {
947
- deadline.signal.throwIfAborted();
1134
+ const checkPage = () => {
948
1135
  if (navigatedAway()) throw new Error("The page navigated before its recording completed.");
949
1136
  if (page.isClosed()) throw new Error("The page closed before its recording completed.");
950
1137
  };
1138
+ const check = () => {
1139
+ deadline.signal.throwIfAborted();
1140
+ checkPage();
1141
+ };
951
1142
  try {
952
1143
  check();
953
1144
  if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
@@ -973,6 +1164,8 @@ var Recorder = class {
973
1164
  check();
974
1165
  const after = await wait(readLayout(page));
975
1166
  check();
1167
+ const snapshot = await Promise.race([captureAriaSnapshot(page, after).catch(() => void 0), (0, node_timers_promises.setTimeout)(Math.max(0, deadline.endsAt - Date.now()), void 0, { ref: false })]);
1168
+ checkPage();
976
1169
  const recorded = {
977
1170
  ...request,
978
1171
  ...withConditions(conditions),
@@ -982,7 +1175,8 @@ var Recorder = class {
982
1175
  recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
983
1176
  layout: before,
984
1177
  geometryStable: JSON.stringify(before) === JSON.stringify(after),
985
- screenshot: await this.saveScreenshot(request.nodeId, image)
1178
+ screenshot: await this.saveScreenshot(request.nodeId, image),
1179
+ ...snapshot && { ariaSnapshot: await this.saveAriaSnapshot(request.nodeId, snapshot) }
986
1180
  };
987
1181
  const retaken = sameNode ? this.nodes.findIndex(({ nodeId }) => nodeId === state.lastRecorded?.nodeId) : -1;
988
1182
  if (retaken === -1) {
@@ -1042,6 +1236,21 @@ var Recorder = class {
1042
1236
  contentType: image.contentType
1043
1237
  };
1044
1238
  }
1239
+ /** Writes the snapshot to the test output and attaches it to the report. */
1240
+ async saveAriaSnapshot(nodeId, snapshot) {
1241
+ const path = "ralph/" + nodeId + ".aria.json";
1242
+ const attachmentName = ARIA_SNAPSHOT_ATTACHMENT_PREFIX + nodeId;
1243
+ await (0, node_fs_promises.mkdir)(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
1244
+ await (0, node_fs_promises.writeFile)(this.testInfo.outputPath(path), snapshot);
1245
+ await this.testInfo.attach(attachmentName, {
1246
+ ...this.testInfo.config.preserveOutput === "always" ? { path: this.testInfo.outputPath(path) } : { body: snapshot },
1247
+ contentType: "application/json"
1248
+ });
1249
+ return {
1250
+ path,
1251
+ attachmentName
1252
+ };
1253
+ }
1045
1254
  /** Gives each node the actions taken since the node it was reached from was requested. */
1046
1255
  attachActions(nodes) {
1047
1256
  const byId = new Map(nodes.map((item) => [item.nodeId, item]));
@@ -1176,11 +1385,17 @@ function resolveUploadOptions(options) {
1176
1385
  }
1177
1386
  const RAW_GRAPH_BUNDLE_INDEX = "raw_graph.json";
1178
1387
  const RAW_GRAPH_BUNDLE_IMAGE_DIR = "assets/images";
1388
+ const RAW_GRAPH_BUNDLE_SNAPSHOT_DIR = "assets/snapshots";
1179
1389
  /** Where a node's screenshot sits inside the bundle. */
1180
1390
  function bundleImagePath(nodeId, contentType) {
1181
1391
  if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
1182
1392
  return `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`;
1183
1393
  }
1394
+ /** Where a node's aria snapshot sits inside the bundle. */
1395
+ function bundleSnapshotPath(nodeId) {
1396
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
1397
+ return `${RAW_GRAPH_BUNDLE_SNAPSHOT_DIR}/${nodeId}.json`;
1398
+ }
1184
1399
  const TOO_BIG = "This run is too big to upload to Ralph. Record fewer pages or screen sizes, or split the tests across runs.";
1185
1400
  var TooBigError = class extends Error {
1186
1401
  constructor() {
@@ -1191,23 +1406,33 @@ var TooBigError = class extends Error {
1191
1406
  * Packs the manifest and screenshots into a gzipped tar archive, produced as it
1192
1407
  * is read so the compressed bundle is never held whole:
1193
1408
  *
1194
- * raw_graph.json { manifest, screenshots: [{ nodeId, path }] }
1409
+ * raw_graph.json { manifest, screenshots: [{ nodeId, path }], snapshots: [...] }
1195
1410
  * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
1411
+ * assets/snapshots/<id>.json the node's aria snapshot, when it has one
1196
1412
  *
1197
1413
  * Fails with a `TooBigError` once the compressed bytes pass the limit.
1198
1414
  */
1199
- function rawGraphBundleStream(manifest, screenshots) {
1415
+ function rawGraphBundleStream(manifest, screenshots, snapshots) {
1200
1416
  if (Buffer.byteLength(JSON.stringify(manifest)) > 16777216 || screenshots.length > 5e3 || screenshots.reduce((total, screenshot) => total + screenshot.bytes.byteLength, 0) > 268435456) throw new TooBigError();
1201
1417
  const images = screenshots.map(({ nodeId, contentType, bytes }) => ({
1202
1418
  nodeId,
1203
1419
  path: bundleImagePath(nodeId, contentType),
1204
1420
  bytes
1205
1421
  }));
1422
+ const files = [...images, ...snapshots.map(({ nodeId, bytes }) => ({
1423
+ nodeId,
1424
+ path: bundleSnapshotPath(nodeId),
1425
+ bytes
1426
+ }))];
1206
1427
  const index = {
1207
1428
  manifest,
1208
1429
  screenshots: images.map(({ nodeId, path }) => ({
1209
1430
  nodeId,
1210
1431
  path
1432
+ })),
1433
+ snapshots: snapshots.map(({ nodeId }) => ({
1434
+ nodeId,
1435
+ path: bundleSnapshotPath(nodeId)
1211
1436
  }))
1212
1437
  };
1213
1438
  const attrs = {
@@ -1221,7 +1446,7 @@ function rawGraphBundleStream(manifest, screenshots) {
1221
1446
  name: RAW_GRAPH_BUNDLE_INDEX,
1222
1447
  ...attrs
1223
1448
  }, JSON.stringify(index)).on("error", onEntryError);
1224
- for (const { path, bytes } of images) tar.entry({
1449
+ for (const { path, bytes } of files) tar.entry({
1225
1450
  name: path,
1226
1451
  ...attrs
1227
1452
  }, Buffer.from(bytes)).on("error", onEntryError);
@@ -1238,9 +1463,9 @@ function rawGraphBundleStream(manifest, screenshots) {
1238
1463
  })());
1239
1464
  }
1240
1465
  /** The bundle `uploadRawGraph` streams, whole, for saving or inspecting it. */
1241
- async function createRawGraphBundle(manifest, screenshots) {
1466
+ async function createRawGraphBundle(manifest, screenshots, snapshots = []) {
1242
1467
  const chunks = [];
1243
- for await (const chunk of rawGraphBundleStream(manifest, screenshots)) chunks.push(chunk);
1468
+ for await (const chunk of rawGraphBundleStream(manifest, screenshots, snapshots)) chunks.push(chunk);
1244
1469
  return Buffer.concat(chunks);
1245
1470
  }
1246
1471
  function isLoopback(url) {
@@ -1315,7 +1540,7 @@ async function reserve(options, route, body) {
1315
1540
  * `prepareArtifactUpload` has to have reserved. Ralph processes it after this
1316
1541
  * returns; the result page shows when it is ready.
1317
1542
  */
1318
- async function uploadRawGraph(manifest, screenshots, options) {
1543
+ async function uploadRawGraph(manifest, screenshots, options, snapshots = []) {
1319
1544
  const url = checkedApiUrl(options, `/api/upload/web/apps/${options.appId}/artifact/${encodeURIComponent(manifest.artifactId)}`);
1320
1545
  const storage = process.env["RALPH_UPLOAD_STORAGE"] || "gcs";
1321
1546
  if (storage !== "gcs" && storage !== "local") throw new Error(`RALPH_UPLOAD_STORAGE must be gcs or local, not ${storage}.`);
@@ -1324,7 +1549,7 @@ async function uploadRawGraph(manifest, screenshots, options) {
1324
1549
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("uploadTimeoutMs in the Ralph reporter options must be a positive number of milliseconds.");
1325
1550
  const receipt = await request(url, {
1326
1551
  headers: { "content-type": "application/gzip" },
1327
- body: node_stream.Readable.toWeb(rawGraphBundleStream(manifest, screenshots)),
1552
+ body: node_stream.Readable.toWeb(rawGraphBundleStream(manifest, screenshots, snapshots)),
1328
1553
  duplex: "half"
1329
1554
  }, options, timeoutMs);
1330
1555
  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);
@@ -1404,6 +1629,12 @@ Object.defineProperty(exports, "bundleImagePath", {
1404
1629
  return bundleImagePath;
1405
1630
  }
1406
1631
  });
1632
+ Object.defineProperty(exports, "bundleSnapshotPath", {
1633
+ enumerable: true,
1634
+ get: function() {
1635
+ return bundleSnapshotPath;
1636
+ }
1637
+ });
1407
1638
  Object.defineProperty(exports, "createRawGraphBundle", {
1408
1639
  enumerable: true,
1409
1640
  get: function() {
@@ -1465,4 +1696,4 @@ Object.defineProperty(exports, "version", {
1465
1696
  }
1466
1697
  });
1467
1698
 
1468
- //# sourceMappingURL=upload-BQSCs8By.cjs.map
1699
+ //# sourceMappingURL=upload-BGYOWfj1.cjs.map