@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.
@@ -5,7 +5,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import configPackage from "@ralphralphai/config/package.json" with { type: "json" };
6
6
  import { WebRalphConfigSchema } from "@ralphralphai/config/zod";
7
7
  import { parse, printParseErrorCode } from "jsonc-parser";
8
- import { randomUUID } from "node:crypto";
8
+ import { createHash, randomUUID } from "node:crypto";
9
9
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
10
10
  import playwrightPackage from "@playwright/test/package.json" with { type: "json" };
11
11
  import { Readable } from "node:stream";
@@ -45,7 +45,7 @@ function snapshot(value) {
45
45
  }
46
46
  //#endregion
47
47
  //#region package.json
48
- var version = "1.9.0";
48
+ var version = "2.0.0";
49
49
  //#endregion
50
50
  //#region src/actions.ts
51
51
  const BINDING = "__ralphAction";
@@ -522,6 +522,192 @@ async function waitForAnimations(page, timeoutMs) {
522
522
  if (!(error instanceof errors.TimeoutError)) throw error;
523
523
  }
524
524
  }
525
+ const MAX_ARIA_DEPTH = Math.floor(128 / 2) - 2;
526
+ const STYLE_PROPERTIES = [
527
+ "font-family",
528
+ "font-size",
529
+ "font-weight",
530
+ "font-style",
531
+ "color",
532
+ "background-color"
533
+ ];
534
+ const TRANSPARENT = "rgba(0, 0, 0, 0)";
535
+ const ELEMENT_LOOKUP_TIMEOUT_MS = 2e3;
536
+ /**
537
+ * The page's aria tree as JSON, with each element's box in document
538
+ * coordinates, its distinctive styles, and a hash of its image. Undefined when
539
+ * the snapshot is past the server's limits.
540
+ *
541
+ * The page is not scrolled first, because scrolling changes the page the
542
+ * screenshot just showed: sticky headers, lazy loading, scroll-triggered
543
+ * animation.
544
+ */
545
+ async function captureAriaSnapshot(page, { viewport, scroll }) {
546
+ const snapshot = await page.locator("body").ariaSnapshotJSON({
547
+ mode: "ai",
548
+ boxes: true,
549
+ depth: MAX_ARIA_DEPTH
550
+ });
551
+ const visible = (await Promise.all(snapshot.map((node) => toDocument(page, node, scroll)))).flatMap((node) => {
552
+ const kept = keepShown(node, viewport.width);
553
+ return kept === void 0 ? [] : [kept];
554
+ });
555
+ const described = await Promise.all(visible.map((node) => describe(page, node, {})));
556
+ const json = Buffer.from(JSON.stringify(fillMissingRefs(described)));
557
+ return json.byteLength > 4194304 ? void 0 : json;
558
+ }
559
+ /**
560
+ * Moves the viewport boxes Playwright reports into the full-page screenshot's
561
+ * space. This has to happen before `keepShown`, which in viewport space would
562
+ * drop everything scrolled above the viewport, and no later move brings those
563
+ * back.
564
+ *
565
+ * An iframe's descendants are boxed in its own viewport, so they move by
566
+ * where that viewport sits: the iframe's box plus its border and padding.
567
+ */
568
+ async function toDocument(page, node, offset) {
569
+ const box = node.box && {
570
+ ...node.box,
571
+ x: node.box.x + offset.x,
572
+ y: node.box.y + offset.y
573
+ };
574
+ let childOffset = offset;
575
+ if (node.role === "iframe" && box !== void 0) {
576
+ const inset = (node.ref === void 0 ? void 0 : await readContentInset(page, node.ref)) ?? {
577
+ x: 0,
578
+ y: 0
579
+ };
580
+ childOffset = {
581
+ x: box.x + inset.x,
582
+ y: box.y + inset.y
583
+ };
584
+ }
585
+ return {
586
+ ...node,
587
+ ...box && { box },
588
+ ...node.children && { children: await Promise.all(node.children.map((child) => typeof child === "string" ? child : toDocument(page, child, childOffset))) }
589
+ };
590
+ }
591
+ async function readContentInset(page, ref) {
592
+ try {
593
+ return await page.locator(`aria-ref=${ref}`).evaluate((element) => {
594
+ const computed = getComputedStyle(element);
595
+ return {
596
+ x: element.clientLeft + parseFloat(computed.paddingLeft),
597
+ y: element.clientTop + parseFloat(computed.paddingTop)
598
+ };
599
+ }, void 0, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
600
+ } catch {
601
+ return;
602
+ }
603
+ }
604
+ /**
605
+ * In document space, the only boxes no scroll reveals are those past the left
606
+ * or right of the viewport's width (a collapsed sidebar, an off-canvas menu)
607
+ * or above the top of the document.
608
+ */
609
+ function isReachable(box, viewportWidth) {
610
+ if (box === void 0) return false;
611
+ return box.x < viewportWidth && box.x + box.width > 0 && box.y + box.height > 0;
612
+ }
613
+ function isShown(node, viewportWidth) {
614
+ return node.ref !== void 0 && isReachable(node.box, viewportWidth);
615
+ }
616
+ function keepShown(node, viewportWidth) {
617
+ const shown = isShown(node, viewportWidth);
618
+ const children = (node.children ?? []).flatMap((child) => {
619
+ if (typeof child === "string") return shown ? [child] : [];
620
+ const kept = keepShown(child, viewportWidth);
621
+ return kept === void 0 ? [] : [kept];
622
+ });
623
+ if (!shown && children.length === 0) return;
624
+ return node.children === void 0 ? node : {
625
+ ...node,
626
+ children
627
+ };
628
+ }
629
+ function fillMissingRefs(nodes) {
630
+ let generated = 0;
631
+ const fill = (child) => {
632
+ if (typeof child === "string") return child;
633
+ generated += 1;
634
+ const ref = child.ref ?? `n${generated}`;
635
+ return child.children === void 0 ? {
636
+ ...child,
637
+ ref
638
+ } : {
639
+ ...child,
640
+ ref,
641
+ children: child.children.map(fill)
642
+ };
643
+ };
644
+ return nodes.map(fill);
645
+ }
646
+ async function readElement(page, ref, isImage) {
647
+ try {
648
+ return await page.locator(`aria-ref=${ref}`).evaluate((element, { properties, isImage }) => {
649
+ const computed = getComputedStyle(element);
650
+ const style = Object.fromEntries(properties.map((property) => [property, computed.getPropertyValue(property)]));
651
+ if (!isImage) return { style };
652
+ if (element instanceof HTMLImageElement) return {
653
+ style,
654
+ image: { src: element.currentSrc || element.src }
655
+ };
656
+ if (element instanceof SVGElement) return {
657
+ style,
658
+ image: { markup: element.outerHTML }
659
+ };
660
+ const backgroundUrl = /url\("(.+)"\)/.exec(computed.backgroundImage)?.[1];
661
+ return backgroundUrl === void 0 ? { style } : {
662
+ style,
663
+ image: { src: new URL(backgroundUrl, document.baseURI).href }
664
+ };
665
+ }, {
666
+ properties: STYLE_PROPERTIES,
667
+ isImage
668
+ }, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
669
+ } catch {
670
+ return;
671
+ }
672
+ }
673
+ function sha256(data) {
674
+ return createHash("sha256").update(data).digest("hex");
675
+ }
676
+ async function hashImage(page, image) {
677
+ if ("markup" in image) return sha256(image.markup);
678
+ if (image.src === "") return;
679
+ if (image.src.startsWith("data:")) return sha256(image.src);
680
+ try {
681
+ const response = await page.request.get(image.src);
682
+ try {
683
+ return response.ok() ? sha256(await response.body()) : void 0;
684
+ } finally {
685
+ await response.dispose();
686
+ }
687
+ } catch {
688
+ return;
689
+ }
690
+ }
691
+ function distinctStyle(style, inherited) {
692
+ const distinct = Object.entries(style).filter(([property, value]) => {
693
+ if (property === "background-color" && value === TRANSPARENT) return false;
694
+ return inherited[property] !== value;
695
+ });
696
+ return distinct.length === 0 ? void 0 : Object.fromEntries(distinct);
697
+ }
698
+ async function describe(page, child, inherited) {
699
+ if (typeof child === "string") return child;
700
+ const facts = child.ref === void 0 ? void 0 : await readElement(page, child.ref, child.role === "img");
701
+ const style = facts === void 0 ? void 0 : distinctStyle(facts.style, inherited);
702
+ const imageHash = facts?.image === void 0 ? void 0 : await hashImage(page, facts.image);
703
+ const children = child.children === void 0 ? void 0 : await Promise.all(child.children.map((grandchild) => describe(page, grandchild, facts?.style ?? inherited)));
704
+ return {
705
+ ...child,
706
+ ...style && { style },
707
+ ...imageHash && { imageHash },
708
+ ...children && { children }
709
+ };
710
+ }
525
711
  //#endregion
526
712
  //#region src/conditions.ts
527
713
  const declared = (declaration = {}) => {
@@ -701,6 +887,8 @@ const MANIFEST_ATTACHMENT = "ralph-raw-graph";
701
887
  const REPORTER_ENV = "RALPH_PLAYWRIGHT_REPORTER";
702
888
  /** Prefix of the attachment holding each recorded node's screenshot; the node id follows. */
703
889
  const SCREENSHOT_ATTACHMENT_PREFIX = "ralph-screenshot-";
890
+ /** Prefix of the attachment holding each recorded node's aria snapshot; the node id follows. */
891
+ const ARIA_SNAPSHOT_ATTACHMENT_PREFIX = "ralph-aria-snapshot-";
704
892
  /** Once per worker: a run without the tracker says so once, not per node. */
705
893
  let warnedNoTracker = false;
706
894
  const DEFAULT_SETTLE_MS = 150;
@@ -918,11 +1106,14 @@ var Recorder = class {
918
1106
  const deadline = startDeadline(this.recordTimeoutMs, "Ralph recording timed out.");
919
1107
  const { wait } = deadline;
920
1108
  const navigatedAway = () => state.generation !== generation || page.url() !== request.actualUrl;
921
- const check = () => {
922
- deadline.signal.throwIfAborted();
1109
+ const checkPage = () => {
923
1110
  if (navigatedAway()) throw new Error("The page navigated before its recording completed.");
924
1111
  if (page.isClosed()) throw new Error("The page closed before its recording completed.");
925
1112
  };
1113
+ const check = () => {
1114
+ deadline.signal.throwIfAborted();
1115
+ checkPage();
1116
+ };
926
1117
  try {
927
1118
  check();
928
1119
  if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
@@ -948,6 +1139,8 @@ var Recorder = class {
948
1139
  check();
949
1140
  const after = await wait(readLayout(page));
950
1141
  check();
1142
+ const snapshot = await Promise.race([captureAriaSnapshot(page, after).catch(() => void 0), setTimeout$1(Math.max(0, deadline.endsAt - Date.now()), void 0, { ref: false })]);
1143
+ checkPage();
951
1144
  const recorded = {
952
1145
  ...request,
953
1146
  ...withConditions(conditions),
@@ -957,7 +1150,8 @@ var Recorder = class {
957
1150
  recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
958
1151
  layout: before,
959
1152
  geometryStable: JSON.stringify(before) === JSON.stringify(after),
960
- screenshot: await this.saveScreenshot(request.nodeId, image)
1153
+ screenshot: await this.saveScreenshot(request.nodeId, image),
1154
+ ...snapshot && { ariaSnapshot: await this.saveAriaSnapshot(request.nodeId, snapshot) }
961
1155
  };
962
1156
  const retaken = sameNode ? this.nodes.findIndex(({ nodeId }) => nodeId === state.lastRecorded?.nodeId) : -1;
963
1157
  if (retaken === -1) {
@@ -1017,6 +1211,21 @@ var Recorder = class {
1017
1211
  contentType: image.contentType
1018
1212
  };
1019
1213
  }
1214
+ /** Writes the snapshot to the test output and attaches it to the report. */
1215
+ async saveAriaSnapshot(nodeId, snapshot) {
1216
+ const path = "ralph/" + nodeId + ".aria.json";
1217
+ const attachmentName = ARIA_SNAPSHOT_ATTACHMENT_PREFIX + nodeId;
1218
+ await mkdir(this.testInfo.outputPath(OUTPUT_DIR), { recursive: true });
1219
+ await writeFile(this.testInfo.outputPath(path), snapshot);
1220
+ await this.testInfo.attach(attachmentName, {
1221
+ ...this.testInfo.config.preserveOutput === "always" ? { path: this.testInfo.outputPath(path) } : { body: snapshot },
1222
+ contentType: "application/json"
1223
+ });
1224
+ return {
1225
+ path,
1226
+ attachmentName
1227
+ };
1228
+ }
1020
1229
  /** Gives each node the actions taken since the node it was reached from was requested. */
1021
1230
  attachActions(nodes) {
1022
1231
  const byId = new Map(nodes.map((item) => [item.nodeId, item]));
@@ -1151,11 +1360,17 @@ function resolveUploadOptions(options) {
1151
1360
  }
1152
1361
  const RAW_GRAPH_BUNDLE_INDEX = "raw_graph.json";
1153
1362
  const RAW_GRAPH_BUNDLE_IMAGE_DIR = "assets/images";
1363
+ const RAW_GRAPH_BUNDLE_SNAPSHOT_DIR = "assets/snapshots";
1154
1364
  /** Where a node's screenshot sits inside the bundle. */
1155
1365
  function bundleImagePath(nodeId, contentType) {
1156
1366
  if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
1157
1367
  return `${RAW_GRAPH_BUNDLE_IMAGE_DIR}/${nodeId}${imageExtension(contentType)}`;
1158
1368
  }
1369
+ /** Where a node's aria snapshot sits inside the bundle. */
1370
+ function bundleSnapshotPath(nodeId) {
1371
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(nodeId)) throw new Error(`Ralph node id is not path-safe: ${nodeId}`);
1372
+ return `${RAW_GRAPH_BUNDLE_SNAPSHOT_DIR}/${nodeId}.json`;
1373
+ }
1159
1374
  const TOO_BIG = "This run is too big to upload to Ralph. Record fewer pages or screen sizes, or split the tests across runs.";
1160
1375
  var TooBigError = class extends Error {
1161
1376
  constructor() {
@@ -1166,23 +1381,33 @@ var TooBigError = class extends Error {
1166
1381
  * Packs the manifest and screenshots into a gzipped tar archive, produced as it
1167
1382
  * is read so the compressed bundle is never held whole:
1168
1383
  *
1169
- * raw_graph.json { manifest, screenshots: [{ nodeId, path }] }
1384
+ * raw_graph.json { manifest, screenshots: [{ nodeId, path }], snapshots: [...] }
1170
1385
  * assets/images/<id>.webp raw screenshot bytes (.png when too large for WebP)
1386
+ * assets/snapshots/<id>.json the node's aria snapshot, when it has one
1171
1387
  *
1172
1388
  * Fails with a `TooBigError` once the compressed bytes pass the limit.
1173
1389
  */
1174
- function rawGraphBundleStream(manifest, screenshots) {
1390
+ function rawGraphBundleStream(manifest, screenshots, snapshots) {
1175
1391
  if (Buffer.byteLength(JSON.stringify(manifest)) > 16777216 || screenshots.length > 5e3 || screenshots.reduce((total, screenshot) => total + screenshot.bytes.byteLength, 0) > 268435456) throw new TooBigError();
1176
1392
  const images = screenshots.map(({ nodeId, contentType, bytes }) => ({
1177
1393
  nodeId,
1178
1394
  path: bundleImagePath(nodeId, contentType),
1179
1395
  bytes
1180
1396
  }));
1397
+ const files = [...images, ...snapshots.map(({ nodeId, bytes }) => ({
1398
+ nodeId,
1399
+ path: bundleSnapshotPath(nodeId),
1400
+ bytes
1401
+ }))];
1181
1402
  const index = {
1182
1403
  manifest,
1183
1404
  screenshots: images.map(({ nodeId, path }) => ({
1184
1405
  nodeId,
1185
1406
  path
1407
+ })),
1408
+ snapshots: snapshots.map(({ nodeId }) => ({
1409
+ nodeId,
1410
+ path: bundleSnapshotPath(nodeId)
1186
1411
  }))
1187
1412
  };
1188
1413
  const attrs = {
@@ -1196,7 +1421,7 @@ function rawGraphBundleStream(manifest, screenshots) {
1196
1421
  name: RAW_GRAPH_BUNDLE_INDEX,
1197
1422
  ...attrs
1198
1423
  }, JSON.stringify(index)).on("error", onEntryError);
1199
- for (const { path, bytes } of images) tar.entry({
1424
+ for (const { path, bytes } of files) tar.entry({
1200
1425
  name: path,
1201
1426
  ...attrs
1202
1427
  }, Buffer.from(bytes)).on("error", onEntryError);
@@ -1213,9 +1438,9 @@ function rawGraphBundleStream(manifest, screenshots) {
1213
1438
  })());
1214
1439
  }
1215
1440
  /** The bundle `uploadRawGraph` streams, whole, for saving or inspecting it. */
1216
- async function createRawGraphBundle(manifest, screenshots) {
1441
+ async function createRawGraphBundle(manifest, screenshots, snapshots = []) {
1217
1442
  const chunks = [];
1218
- for await (const chunk of rawGraphBundleStream(manifest, screenshots)) chunks.push(chunk);
1443
+ for await (const chunk of rawGraphBundleStream(manifest, screenshots, snapshots)) chunks.push(chunk);
1219
1444
  return Buffer.concat(chunks);
1220
1445
  }
1221
1446
  function isLoopback(url) {
@@ -1290,7 +1515,7 @@ async function reserve(options, route, body) {
1290
1515
  * `prepareArtifactUpload` has to have reserved. Ralph processes it after this
1291
1516
  * returns; the result page shows when it is ready.
1292
1517
  */
1293
- async function uploadRawGraph(manifest, screenshots, options) {
1518
+ async function uploadRawGraph(manifest, screenshots, options, snapshots = []) {
1294
1519
  const url = checkedApiUrl(options, `/api/upload/web/apps/${options.appId}/artifact/${encodeURIComponent(manifest.artifactId)}`);
1295
1520
  const storage = process.env["RALPH_UPLOAD_STORAGE"] || "gcs";
1296
1521
  if (storage !== "gcs" && storage !== "local") throw new Error(`RALPH_UPLOAD_STORAGE must be gcs or local, not ${storage}.`);
@@ -1299,7 +1524,7 @@ async function uploadRawGraph(manifest, screenshots, options) {
1299
1524
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("uploadTimeoutMs in the Ralph reporter options must be a positive number of milliseconds.");
1300
1525
  const receipt = await request(url, {
1301
1526
  headers: { "content-type": "application/gzip" },
1302
- body: Readable.toWeb(rawGraphBundleStream(manifest, screenshots)),
1527
+ body: Readable.toWeb(rawGraphBundleStream(manifest, screenshots, snapshots)),
1303
1528
  duplex: "half"
1304
1529
  }, options, timeoutMs);
1305
1530
  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);
@@ -1343,6 +1568,6 @@ async function responseDetail(response) {
1343
1568
  return text.length > 200 ? text.slice(0, 200) + "…" : text || void 0;
1344
1569
  }
1345
1570
  //#endregion
1346
- export { registerDeployment as a, uploadRawGraph as c, Recorder as d, version as f, resolveMode as h, prepareArtifactUpload as i, MANIFEST_ATTACHMENT as l, loadConfigSync as m, bundleImagePath as n, resolveUploadOptions as o, loadConfig as p, createRawGraphBundle as r, resultLink as s, RAW_GRAPH_BUNDLE_INDEX as t, REPORTER_ENV as u };
1571
+ export { prepareArtifactUpload as a, resultLink as c, REPORTER_ENV as d, Recorder as f, resolveMode as g, loadConfigSync as h, createRawGraphBundle as i, uploadRawGraph as l, loadConfig as m, bundleImagePath as n, registerDeployment as o, version as p, bundleSnapshotPath as r, resolveUploadOptions as s, RAW_GRAPH_BUNDLE_INDEX as t, MANIFEST_ATTACHMENT as u };
1347
1572
 
1348
- //# sourceMappingURL=upload-BZs6Vqix.mjs.map
1573
+ //# sourceMappingURL=upload--pBJ67uZ.mjs.map