@ralphralphai/playwright 2.6.0 → 2.8.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/README.md CHANGED
@@ -254,6 +254,24 @@ Pass an `edgeLabel` to describe the step in your own words, e.g.
254
254
  `ralph.recordNode(page, { name: 'Done', edgeLabel: 'Sign in' })`. A later call
255
255
  without one keeps it.
256
256
 
257
+ ### Excluding elements from snapshots
258
+
259
+ For parts of the page that change on their own, such as ads, chat widgets or
260
+ live counters, list CSS selectors in `ignoreInDiff`:
261
+
262
+ ```ts
263
+ ralphOptions: {
264
+ ignoreInDiff: ['.ad-slot', '#intercom-container', 'live-clock'],
265
+ },
266
+ ```
267
+
268
+ Matched elements and their descendants, including content in open shadow roots
269
+ and slots, are left out of the ARIA snapshot used for diffing. They still appear
270
+ in the screenshot, and the space they take up still moves the elements around
271
+ them. Text that sits inline in a larger element, like a bare `<span>` in a
272
+ paragraph, belongs to that element's text and is not removed. Wrap it in an
273
+ element with a role, or match the whole paragraph.
274
+
257
275
  ### When recording fails
258
276
 
259
277
  - A failed `recordNode` call rejects.
@@ -414,6 +432,7 @@ export default defineConfig<RalphFixtures>({
414
432
  | `ready` | | `(page, signal) => Promise<void>`. Waits for your app before an automatic recording. Honor `signal`. |
415
433
  | `reduceMotion` | `false` | Emulates `prefers-reduced-motion: reduce` and waits for finite animations before recording. |
416
434
  | `freezeDuringCapture` | `false` | Finishes finite browser animations, allows JS-driven DOM changes to settle within a bounded wait, then freezes timers and animations across capture. |
435
+ | `ignoreInDiff` | | CSS selectors for subtrees to omit from the ARIA snapshot. See [Excluding elements from snapshots](#excluding-elements-from-snapshots). |
417
436
  | `strictNodeTags` | `false` | Fails the test when the app reports a tag outside what `ralph.setNodeConditions` declared. |
418
437
  | `ignoreUntaggedCalls` | `false` | Makes `recordNode`, `setNodeConditions`, and `observe` no-ops in untagged tests instead of throwing. |
419
438
 
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_upload = require("./upload-H-EycbCA.cjs");
2
+ const require_upload = require("./upload-KEwXpYxG.cjs");
3
3
  let node_path = require("node:path");
4
4
  node_path = require_upload.__toESM(node_path, 1);
5
5
  let _playwright_test = require("@playwright/test");
package/dist/index.d.mts CHANGED
@@ -18,6 +18,11 @@ type RalphOptions = {
18
18
  */
19
19
  freezeDuringCapture?: boolean;
20
20
  recordTimeoutMs?: number;
21
+ /**
22
+ * CSS selectors for subtrees the diff ignores, such as an ad slot or a chat
23
+ * widget. They are left out of the aria snapshot but stay in the screenshot.
24
+ */
25
+ ignoreInDiff?: string[];
21
26
  ready?: (page: Page, signal: AbortSignal) => Promise<void>;
22
27
  /**
23
28
  * Fail the test when the app reports a tag outside the set the test
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as resolveMode, a as prepareArtifactUpload, f as Recorder, g as loadConfigSync, h as loadConfig, i as createRawGraphBundle, l as uploadRawGraph, p as visitAllLinks, u as MANIFEST_ATTACHMENT } from "./upload-CGIMWRQM.mjs";
1
+ import { _ as resolveMode, a as prepareArtifactUpload, f as Recorder, g as loadConfigSync, h as loadConfig, i as createRawGraphBundle, l as uploadRawGraph, p as visitAllLinks, u as MANIFEST_ATTACHMENT } from "./upload-CcdDM33Z.mjs";
2
2
  import path from "node:path";
3
3
  import { expect, mergeTests, test as test$1 } from "@playwright/test";
4
4
  //#region src/screens.ts
package/dist/reporter.cjs CHANGED
@@ -1,4 +1,4 @@
1
- const require_upload = require("./upload-H-EycbCA.cjs");
1
+ const require_upload = require("./upload-KEwXpYxG.cjs");
2
2
  let node_path = require("node:path");
3
3
  node_path = require_upload.__toESM(node_path, 1);
4
4
  let node_fs_promises = require("node:fs/promises");
package/dist/reporter.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as resolveMode, a as prepareArtifactUpload, c as resultLink, d as REPORTER_ENV, l as uploadRawGraph, m as version, n as bundleImagePath, o as registerDeployment, r as bundleSnapshotPath, s as resolveUploadOptions, t as RAW_GRAPH_BUNDLE_INDEX, u as MANIFEST_ATTACHMENT } from "./upload-CGIMWRQM.mjs";
1
+ import { _ as resolveMode, a as prepareArtifactUpload, c as resultLink, d as REPORTER_ENV, l as uploadRawGraph, m as version, n as bundleImagePath, o as registerDeployment, r as bundleSnapshotPath, s as resolveUploadOptions, t as RAW_GRAPH_BUNDLE_INDEX, u as MANIFEST_ATTACHMENT } from "./upload-CcdDM33Z.mjs";
2
2
  import path from "node:path";
3
3
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { randomUUID } from "node:crypto";
@@ -45,7 +45,7 @@ function snapshot(value) {
45
45
  }
46
46
  //#endregion
47
47
  //#region package.json
48
- var version = "2.6.0";
48
+ var version = "2.8.0";
49
49
  //#endregion
50
50
  //#region src/actions.ts
51
51
  const BINDING = "__ralphAction";
@@ -526,30 +526,57 @@ async function waitForAnimations(page, timeoutMs) {
526
526
  //#region src/image_responses.ts
527
527
  const IMAGE_HASH_TIMEOUT_MS = 3e4;
528
528
  const hashesByPage = /* @__PURE__ */ new WeakMap();
529
+ /**
530
+ * The latest hash per url across all of a context's pages. The HTTP cache is
531
+ * shared by the context, so this is the copy a 304 tells any of them to reuse.
532
+ */
533
+ const hashesByContext = /* @__PURE__ */ new WeakMap();
529
534
  function sha256(data) {
530
535
  return createHash("sha256").update(data).digest("hex");
531
536
  }
532
537
  /**
533
- * Hashes every image the page loads, as it loads, so a snapshot hashes the
534
- * bytes the page showed instead of fetching them a second time. A second fetch
535
- * of a page's images comes as a burst, which hosts such as
538
+ * Hashes every image the context's pages load, as they load, so a snapshot
539
+ * hashes the bytes the page showed instead of fetching them a second time. A
540
+ * second fetch of a page's images comes as a burst, which hosts such as
536
541
  * upload.wikimedia.org answer with 429 for a share of them, and the image then
537
542
  * reads as replaced. Only the hash is kept, not the body. Entries persist
538
543
  * across navigations until another request for the same image replaces them
539
544
  * or the page closes.
545
+ *
546
+ * Watched on the context rather than each page: Playwright reports a popup
547
+ * only once its first navigation has committed, and the images that document
548
+ * requests early are gone by the time a listener can be added to the page.
540
549
  */
541
- function watchImageResponses(page) {
542
- if (hashesByPage.has(page)) return;
543
- const hashes = /* @__PURE__ */ new Map();
544
- hashesByPage.set(page, hashes);
545
- page.on("request", (request) => {
546
- if (request.resourceType() !== "image") return;
550
+ function watchImageResponses(context) {
551
+ if (hashesByContext.has(context)) return;
552
+ const latest = /* @__PURE__ */ new Map();
553
+ hashesByContext.set(context, latest);
554
+ context.on("request", (request) => {
555
+ if (request.resourceType() !== "image" || request.serviceWorker()) return;
556
+ const hashes = pageHashes(request.frame().page());
557
+ const cached = latest.get(request.url());
547
558
  const hash = request.response().then(async (response) => {
559
+ if (response?.status() === 304) return cached;
548
560
  return response?.ok() ? sha256(await response.body()) : void 0;
549
561
  }).catch(() => void 0);
550
- for (let redirected = request; redirected !== null; redirected = redirected.redirectedFrom()) hashes.set(redirected.url(), hash);
562
+ for (const url of redirectChain(request)) {
563
+ hashes.set(url, hash);
564
+ latest.set(url, hash);
565
+ }
551
566
  });
567
+ }
568
+ function pageHashes(page) {
569
+ const existing = hashesByPage.get(page);
570
+ if (existing) return existing;
571
+ const hashes = /* @__PURE__ */ new Map();
572
+ hashesByPage.set(page, hashes);
552
573
  page.once("close", () => hashesByPage.delete(page));
574
+ return hashes;
575
+ }
576
+ function redirectChain(request) {
577
+ const urls = [];
578
+ for (let redirected = request; redirected !== null; redirected = redirected.redirectedFrom()) urls.push(redirected.url());
579
+ return urls;
553
580
  }
554
581
  /**
555
582
  * The hash from the latest observed request for this URL, across navigations.
@@ -623,7 +650,7 @@ const MIN_IFRAME_SIZE = 5;
623
650
  * screenshot just showed: sticky headers, lazy loading, scroll-triggered
624
651
  * animation.
625
652
  */
626
- async function captureAriaSnapshot(page, { viewport, scroll }) {
653
+ async function captureAriaSnapshot(page, { viewport, scroll }, ignore = []) {
627
654
  const snapshot = await page.locator("body").ariaSnapshotJSON({
628
655
  mode: "ai",
629
656
  boxes: true,
@@ -635,7 +662,10 @@ async function captureAriaSnapshot(page, { viewport, scroll }) {
635
662
  const kept = keepShown(node, viewport.width, clips, void 0);
636
663
  return kept === void 0 ? [] : [kept];
637
664
  });
638
- const described = await Promise.all(visible.map((node) => describe(page, node, {}, false)));
665
+ const ignoreSelector = ignore.length === 0 ? void 0 : ignore.join(", ");
666
+ const facts = /* @__PURE__ */ new Map();
667
+ const normalized = (await Promise.all(visible.map((node) => readTree(page, node, ignoreSelector, facts)))).filter((node) => node !== void 0);
668
+ const described = await Promise.all(normalized.map((node) => describe(page, node, {}, false, facts)));
639
669
  const json = Buffer.from(JSON.stringify(fillMissingRefs(described)));
640
670
  return json.byteLength > 4194304 ? void 0 : json;
641
671
  }
@@ -822,12 +852,14 @@ function keepShown(node, viewportWidth, clips, inherited) {
822
852
  return kept === void 0 ? [] : [kept];
823
853
  });
824
854
  if (!shown && children.length === 0) return;
825
- const [only] = children;
826
- if (node.role === "generic" && !node.name && (node.children?.length ?? 0) > 1 && children.length === 1 && typeof only !== "string" && only?.ref !== void 0) return only;
827
- return node.children === void 0 ? node : {
855
+ return loneChild(node, children) ?? (node.children === void 0 ? node : {
828
856
  ...node,
829
857
  children
830
- };
858
+ });
859
+ }
860
+ function loneChild(node, children) {
861
+ const [only] = children;
862
+ if (node.role === "generic" && !node.name && (node.children?.length ?? 0) > 1 && children.length === 1 && typeof only !== "string" && only?.ref !== void 0) return only;
831
863
  }
832
864
  function fillMissingRefs(nodes) {
833
865
  let generated = 0;
@@ -846,34 +878,40 @@ function fillMissingRefs(nodes) {
846
878
  };
847
879
  return nodes.map(fill);
848
880
  }
849
- async function readElement(page, ref, isImage) {
881
+ async function readElement(page, ref, isImage, ignoreSelector) {
850
882
  try {
851
- return await page.locator(`aria-ref=${ref}`).evaluate((element, { properties, isImage, dynamicAttribute }) => {
883
+ return await page.locator(`aria-ref=${ref}`).evaluate((element, { properties, isImage, dynamicAttribute, ignoreSelector }) => {
852
884
  const computed = getComputedStyle(element);
853
885
  const typed = "computedStyleMap" in element ? element.computedStyleMap() : void 0;
854
886
  const style = Object.fromEntries(properties.map((property) => [property, property.startsWith("margin-") && typed?.get(property)?.toString() === "auto" ? "auto" : computed.getPropertyValue(property)]));
855
887
  const dynamic = element.closest(`[${dynamicAttribute}]`) !== null;
856
- if (!isImage) return {
888
+ let ignored = false;
889
+ for (let at = element; ignoreSelector !== void 0 && at !== null && !ignored;) {
890
+ try {
891
+ ignored = at.matches(ignoreSelector);
892
+ } catch {
893
+ break;
894
+ }
895
+ const root = at.getRootNode();
896
+ at = at.assignedSlot ?? at.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
897
+ }
898
+ const facts = {
857
899
  style,
858
- dynamic
900
+ dynamic,
901
+ ignored
859
902
  };
903
+ if (!isImage) return facts;
860
904
  if (element instanceof HTMLImageElement) return {
861
- style,
862
- dynamic,
905
+ ...facts,
863
906
  image: { src: element.currentSrc || element.src }
864
907
  };
865
908
  if (element instanceof SVGElement) return {
866
- style,
867
- dynamic,
909
+ ...facts,
868
910
  image: { markup: element.outerHTML }
869
911
  };
870
912
  const backgroundUrl = /url\("(.+)"\)/.exec(computed.backgroundImage)?.[1];
871
- return backgroundUrl === void 0 ? {
872
- style,
873
- dynamic
874
- } : {
875
- style,
876
- dynamic,
913
+ return backgroundUrl === void 0 ? facts : {
914
+ ...facts,
877
915
  image: { src: new URL(backgroundUrl, document.baseURI).href }
878
916
  };
879
917
  }, {
@@ -883,7 +921,8 @@ async function readElement(page, ref, isImage) {
883
921
  ...isImage ? Object.keys(IMAGE_PROPERTIES) : []
884
922
  ],
885
923
  isImage,
886
- dynamicAttribute: DYNAMIC_ATTRIBUTE
924
+ dynamicAttribute: DYNAMIC_ATTRIBUTE,
925
+ ignoreSelector
887
926
  }, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
888
927
  } catch {
889
928
  return;
@@ -905,13 +944,29 @@ function distinctStyle(style, inherited) {
905
944
  });
906
945
  return distinct.length === 0 ? void 0 : Object.fromEntries(distinct);
907
946
  }
908
- async function describe(page, child, inherited, insideDynamic) {
947
+ async function readTree(page, child, ignoreSelector, factsByRef) {
948
+ if (typeof child === "string") return child;
949
+ const facts = child.ref === void 0 ? void 0 : await readElement(page, child.ref, child.role === "img", ignoreSelector);
950
+ if (facts?.ignored) return;
951
+ if (child.ref !== void 0 && facts !== void 0) factsByRef.set(child.ref, facts);
952
+ const children = child.children === void 0 ? void 0 : (await Promise.all(child.children.map((grandchild) => readTree(page, grandchild, ignoreSelector, factsByRef)))).filter((grandchild) => grandchild !== void 0);
953
+ if (children !== void 0) {
954
+ if (children.length === 0 && (child.ref === void 0 || child.role === "generic" && !child.name && (child.children?.length ?? 0) > 0)) return;
955
+ const only = loneChild(child, children);
956
+ if (only !== void 0) return only;
957
+ }
958
+ return children === void 0 ? child : {
959
+ ...child,
960
+ children
961
+ };
962
+ }
963
+ async function describe(page, child, inherited, insideDynamic, factsByRef) {
909
964
  if (typeof child === "string") return child;
910
- const facts = child.ref === void 0 ? void 0 : await readElement(page, child.ref, child.role === "img");
965
+ const facts = child.ref === void 0 ? void 0 : factsByRef.get(child.ref);
911
966
  const style = facts === void 0 ? void 0 : distinctStyle(facts.style, inherited);
912
967
  const imageHash = facts?.image === void 0 ? void 0 : await hashImage(page, facts.image);
913
968
  const dynamic = facts?.dynamic === true && !insideDynamic;
914
- const children = child.children === void 0 ? void 0 : await Promise.all(child.children.map((grandchild) => describe(page, grandchild, facts?.style ?? inherited, insideDynamic || dynamic)));
969
+ const children = child.children === void 0 ? void 0 : await Promise.all(child.children.map((grandchild) => describe(page, grandchild, facts?.style ?? inherited, insideDynamic || dynamic, factsByRef)));
915
970
  return {
916
971
  ...child,
917
972
  ...style && { style },
@@ -1399,6 +1454,7 @@ var Recorder = class {
1399
1454
  freezer
1400
1455
  });
1401
1456
  this.actions.observe(context);
1457
+ watchImageResponses(context);
1402
1458
  context.on("page", onPage);
1403
1459
  for (const page of context.pages()) onPage(page);
1404
1460
  return ready;
@@ -1499,7 +1555,6 @@ var Recorder = class {
1499
1555
  }
1500
1556
  }).catch(() => void 0);
1501
1557
  page.on("framenavigated", state.onNavigation);
1502
- watchImageResponses(page);
1503
1558
  this.urlChanged(state);
1504
1559
  }
1505
1560
  urlChanged(state) {
@@ -1592,7 +1647,7 @@ var Recorder = class {
1592
1647
  before,
1593
1648
  image,
1594
1649
  after,
1595
- snapshot: await Promise.race([captureAriaSnapshot(page, after).catch(() => void 0), setTimeout$1(Math.max(0, deadline.endsAt - Date.now()), void 0, { ref: false })])
1650
+ snapshot: await Promise.race([captureAriaSnapshot(page, after, this.options.ignoreInDiff).catch(() => void 0), setTimeout$1(Math.max(0, deadline.endsAt - Date.now()), void 0, { ref: false })])
1596
1651
  };
1597
1652
  };
1598
1653
  const { before, image, after, snapshot } = freezer ? await freezer.capture(page, deadline.signal, capture, deadline.endsAt) : await capture();
@@ -2026,4 +2081,4 @@ async function responseDetail(response) {
2026
2081
  //#endregion
2027
2082
  export { resolveMode as _, prepareArtifactUpload as a, resultLink as c, REPORTER_ENV as d, Recorder as f, loadConfigSync as g, loadConfig as h, createRawGraphBundle as i, uploadRawGraph as l, version as m, bundleImagePath as n, registerDeployment as o, visitAllLinks as p, bundleSnapshotPath as r, resolveUploadOptions as s, RAW_GRAPH_BUNDLE_INDEX as t, MANIFEST_ATTACHMENT as u };
2028
2083
 
2029
- //# sourceMappingURL=upload-CGIMWRQM.mjs.map
2084
+ //# sourceMappingURL=upload-CcdDM33Z.mjs.map