@ralphralphai/playwright 2.3.0 → 2.5.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 = "2.3.0";
73
+ var version = "2.5.0";
74
74
  //#endregion
75
75
  //#region src/actions.ts
76
76
  const BINDING = "__ralphAction";
@@ -547,6 +547,54 @@ async function waitForAnimations(page, timeoutMs) {
547
547
  if (!(error instanceof _playwright_test.errors.TimeoutError)) throw error;
548
548
  }
549
549
  }
550
+ //#endregion
551
+ //#region src/image_responses.ts
552
+ const IMAGE_HASH_TIMEOUT_MS = 3e4;
553
+ const hashesByPage = /* @__PURE__ */ new WeakMap();
554
+ function sha256(data) {
555
+ return (0, node_crypto.createHash)("sha256").update(data).digest("hex");
556
+ }
557
+ /**
558
+ * Hashes every image the page loads, as it loads, so a snapshot hashes the
559
+ * bytes the page showed instead of fetching them a second time. A second fetch
560
+ * of a page's images comes as a burst, which hosts such as
561
+ * upload.wikimedia.org answer with 429 for a share of them, and the image then
562
+ * reads as replaced. Only the hash is kept, not the body. Entries persist
563
+ * across navigations until another request for the same image replaces them
564
+ * or the page closes.
565
+ */
566
+ function watchImageResponses(page) {
567
+ if (hashesByPage.has(page)) return;
568
+ const hashes = /* @__PURE__ */ new Map();
569
+ hashesByPage.set(page, hashes);
570
+ page.on("request", (request) => {
571
+ if (request.resourceType() !== "image") return;
572
+ const hash = request.response().then(async (response) => {
573
+ return response?.ok() ? sha256(await response.body()) : void 0;
574
+ }).catch(() => void 0);
575
+ for (let redirected = request; redirected !== null; redirected = redirected.redirectedFrom()) hashes.set(redirected.url(), hash);
576
+ });
577
+ page.once("close", () => hashesByPage.delete(page));
578
+ }
579
+ /**
580
+ * The hash from the latest observed request for this URL, across navigations.
581
+ * Undefined when no request was observed, the latest load failed, or it has
582
+ * not finished within the timeout, so an image that never finishes loading
583
+ * cannot stall a snapshot.
584
+ */
585
+ async function loadedImageHash(page, url, timeoutMs = IMAGE_HASH_TIMEOUT_MS) {
586
+ const hash = hashesByPage.get(page)?.get(url.split("#")[0]);
587
+ if (hash === void 0) return;
588
+ let timer;
589
+ const timeout = new Promise((resolve) => {
590
+ timer = setTimeout(resolve, timeoutMs, void 0);
591
+ });
592
+ try {
593
+ return await Promise.race([hash, timeout]);
594
+ } finally {
595
+ clearTimeout(timer);
596
+ }
597
+ }
550
598
  const MAX_ARIA_DEPTH = Math.floor(128 / 2) - 2;
551
599
  const STYLE_PROPERTIES = [
552
600
  "font-family",
@@ -680,6 +728,8 @@ function keepShown(node, viewportWidth) {
680
728
  return kept === void 0 ? [] : [kept];
681
729
  });
682
730
  if (!shown && children.length === 0) return;
731
+ const [only] = children;
732
+ if (node.role === "generic" && !node.name && (node.children?.length ?? 0) > 1 && children.length === 1 && typeof only !== "string" && only?.ref !== void 0) return only;
683
733
  return node.children === void 0 ? node : {
684
734
  ...node,
685
735
  children
@@ -706,7 +756,8 @@ async function readElement(page, ref, isImage) {
706
756
  try {
707
757
  return await page.locator(`aria-ref=${ref}`).evaluate((element, { properties, isImage, dynamicAttribute }) => {
708
758
  const computed = getComputedStyle(element);
709
- const style = Object.fromEntries(properties.map((property) => [property, computed.getPropertyValue(property)]));
759
+ const typed = "computedStyleMap" in element ? element.computedStyleMap() : void 0;
760
+ const style = Object.fromEntries(properties.map((property) => [property, property.startsWith("margin-") && typed?.get(property)?.toString() === "auto" ? "auto" : computed.getPropertyValue(property)]));
710
761
  const dynamic = element.closest(`[${dynamicAttribute}]`) !== null;
711
762
  if (!isImage) return {
712
763
  style,
@@ -744,23 +795,11 @@ async function readElement(page, ref, isImage) {
744
795
  return;
745
796
  }
746
797
  }
747
- function sha256(data) {
748
- return (0, node_crypto.createHash)("sha256").update(data).digest("hex");
749
- }
750
798
  async function hashImage(page, image) {
751
799
  if ("markup" in image) return sha256(image.markup);
752
800
  if (image.src === "") return;
753
801
  if (image.src.startsWith("data:")) return sha256(image.src);
754
- try {
755
- const response = await page.request.get(image.src);
756
- try {
757
- return response.ok() ? sha256(await response.body()) : void 0;
758
- } finally {
759
- await response.dispose();
760
- }
761
- } catch {
762
- return;
763
- }
802
+ return loadedImageHash(page, image.src);
764
803
  }
765
804
  function distinctStyle(style, inherited) {
766
805
  const distinct = Object.entries(style).filter(([property, value]) => {
@@ -788,6 +827,179 @@ async function describe(page, child, inherited, insideDynamic) {
788
827
  };
789
828
  }
790
829
  //#endregion
830
+ //#region src/animation_observer.ts
831
+ /** Runs in the page. Keeps native animations and DOM changes observable without app hooks. */
832
+ function observeAnimations() {
833
+ const paused = /* @__PURE__ */ new Set();
834
+ const roots = /* @__PURE__ */ new Set();
835
+ let dirty = true;
836
+ const observer = new MutationObserver(() => {
837
+ dirty = true;
838
+ });
839
+ const collect = (root, all) => {
840
+ if (!roots.has(root)) {
841
+ roots.add(root);
842
+ observer.observe(root, {
843
+ subtree: true,
844
+ childList: true,
845
+ attributes: true,
846
+ characterData: true
847
+ });
848
+ dirty = true;
849
+ }
850
+ root.getAnimations().forEach((animation) => all.add(animation));
851
+ for (const element of root.querySelectorAll("*")) if (element.shadowRoot) collect(element.shadowRoot, all);
852
+ };
853
+ return {
854
+ sample(finishFinite) {
855
+ const all = /* @__PURE__ */ new Set();
856
+ collect(document, all);
857
+ let changed = dirty;
858
+ dirty = false;
859
+ for (const animation of all) {
860
+ if (animation.playState !== "running") continue;
861
+ if (finishFinite && animation.playbackRate !== 0 && Number.isFinite(animation.effect?.getComputedTiming().endTime)) animation.finish();
862
+ else {
863
+ animation.pause();
864
+ paused.add(animation);
865
+ }
866
+ changed = true;
867
+ }
868
+ return changed;
869
+ },
870
+ async ready() {
871
+ await Promise.all([...paused].map((animation) => animation.ready));
872
+ },
873
+ resume() {
874
+ observer.disconnect();
875
+ for (const animation of paused) if (animation.playState === "paused") animation.play();
876
+ }
877
+ };
878
+ }
879
+ //#endregion
880
+ //#region src/capture_freezer.ts
881
+ const PAUSE_LEAD_MS = 100;
882
+ const ANIMATION_QUIET_MS = 150;
883
+ const MAX_ANIMATION_SETTLE_MS = 1500;
884
+ /** One owner of the context-wide clock; captures in different tabs queue here. */
885
+ var CaptureFreezer = class {
886
+ context;
887
+ tail = Promise.resolve();
888
+ closed = false;
889
+ constructor(context) {
890
+ this.context = context;
891
+ context.once("close", () => {
892
+ this.closed = true;
893
+ });
894
+ }
895
+ install() {
896
+ if (this.context.pages().some((page) => page.url() !== "about:blank")) throw new Error("Ralph freezeDuringCapture requires the clock before page scripts run. Await ralph.observe(context) before creating or navigating pages in a custom context.");
897
+ return this.context.clock.install();
898
+ }
899
+ async capture(page, signal, capture, deadline = Date.now() + 5e3) {
900
+ const task = this.tail.then(async () => {
901
+ signal.throwIfAborted();
902
+ const animations = /* @__PURE__ */ new Map();
903
+ try {
904
+ await this.settleAnimations(page, animations, signal, deadline);
905
+ await this.pauseClock(page, signal);
906
+ signal.throwIfAborted();
907
+ for (let pass = 0; pass < 2; pass++) {
908
+ await this.sampleAnimations(page, animations, signal, false);
909
+ await this.waitForAnimationsReady(animations, signal);
910
+ }
911
+ return await capture();
912
+ } finally {
913
+ try {
914
+ await this.resumeAnimations(animations);
915
+ } finally {
916
+ if (!this.closed) await this.context.clock.resume();
917
+ }
918
+ }
919
+ });
920
+ this.tail = task.catch(() => void 0);
921
+ return task;
922
+ }
923
+ async settleAnimations(page, animations, signal, deadline) {
924
+ const now = Date.now();
925
+ const endsAt = now + Math.min(MAX_ANIMATION_SETTLE_MS, Math.max(0, (deadline - now) / 2));
926
+ let quietSince = now;
927
+ while (Date.now() < endsAt) {
928
+ if (await this.sampleAnimations(page, animations, signal, true)) quietSince = Date.now();
929
+ if (Date.now() - quietSince >= ANIMATION_QUIET_MS) return;
930
+ await (0, node_timers_promises.setTimeout)(Math.max(0, Math.min(50, endsAt - Date.now())), void 0, { signal }).catch((error) => {
931
+ signal.throwIfAborted();
932
+ throw error;
933
+ });
934
+ }
935
+ }
936
+ async sampleAnimations(page, animations, signal, finishFinite) {
937
+ let changed = false;
938
+ for (const frame of page.frames()) {
939
+ signal.throwIfAborted();
940
+ try {
941
+ let handle = animations.get(frame);
942
+ if (!handle) {
943
+ handle = await frame.evaluateHandle(observeAnimations);
944
+ animations.set(frame, handle);
945
+ }
946
+ changed = await handle.evaluate((state, finish) => state.sample(finish), finishFinite) || changed;
947
+ } catch (error) {
948
+ if (!frame.isDetached()) throw error;
949
+ }
950
+ }
951
+ signal.throwIfAborted();
952
+ return changed;
953
+ }
954
+ async waitForAnimationsReady(animations, signal) {
955
+ signal.throwIfAborted();
956
+ let onAbort;
957
+ const aborted = new Promise((_, reject) => {
958
+ onAbort = () => reject(signal.reason);
959
+ signal.addEventListener("abort", onAbort, { once: true });
960
+ });
961
+ try {
962
+ await Promise.race([Promise.all([...animations].map(async ([frame, handle]) => {
963
+ if (frame.isDetached()) return;
964
+ try {
965
+ await handle.evaluate((state) => state.ready());
966
+ } catch (error) {
967
+ if (!frame.isDetached()) throw error;
968
+ }
969
+ })), aborted]);
970
+ } finally {
971
+ signal.removeEventListener("abort", onAbort);
972
+ }
973
+ }
974
+ async resumeAnimations(animations) {
975
+ const restored = await Promise.allSettled([...animations.values()].map(async (handle) => {
976
+ try {
977
+ await handle.evaluate((state) => state.resume());
978
+ } catch (error) {
979
+ if (!destroyedContext(error)) throw error;
980
+ } finally {
981
+ await handle.dispose();
982
+ }
983
+ }));
984
+ for (const result of restored) if (result.status === "rejected") throw result.reason;
985
+ }
986
+ async pauseClock(page, signal) {
987
+ for (let attempt = 0;; attempt++) {
988
+ const time = await page.evaluate(() => Date.now());
989
+ signal.throwIfAborted();
990
+ try {
991
+ await this.context.clock.pauseAt(time + PAUSE_LEAD_MS);
992
+ return;
993
+ } catch (error) {
994
+ if (attempt > 0 || !(error instanceof Error) || !error.message.includes("Cannot fast-forward to the past")) throw error;
995
+ }
996
+ }
997
+ }
998
+ };
999
+ function destroyedContext(error) {
1000
+ return error instanceof Error && /Execution context was destroyed|Cannot find context|Target page, context or browser has been closed|Frame was detached/.test(error.message);
1001
+ }
1002
+ //#endregion
791
1003
  //#region src/conditions.ts
792
1004
  const declared = (declaration = {}) => {
793
1005
  const conditions = {};
@@ -849,11 +1061,11 @@ function nodeKey(conditions) {
849
1061
  */
850
1062
  const WEBP_QUALITY = 80;
851
1063
  const WEBP_MAX_DIMENSION = 16383;
852
- async function takeScreenshot(page, deadline) {
1064
+ async function takeScreenshot(page, deadline, frozen = false) {
853
1065
  const options = {
854
1066
  fullPage: true,
855
1067
  scale: "css",
856
- animations: "disabled"
1068
+ animations: frozen ? "allow" : "disabled"
857
1069
  };
858
1070
  const webp = await page.screenshot({
859
1071
  ...options,
@@ -1082,22 +1294,30 @@ var Recorder = class {
1082
1294
  /** Starts recording URL changes in every current and future tab of `context`. */
1083
1295
  observe(context) {
1084
1296
  if (this.closed) throw new Error("The Ralph recorder has finished.");
1085
- if (this.contexts.has(context)) return;
1297
+ const existing = this.contexts.get(context);
1298
+ if (existing) return existing.ready;
1299
+ const freezer = this.options.freezeDuringCapture ? new CaptureFreezer(context) : void 0;
1300
+ const ready = freezer?.install() ?? Promise.resolve();
1086
1301
  const onPage = (page) => this.watchPage(page);
1087
- this.contexts.set(context, onPage);
1302
+ this.contexts.set(context, {
1303
+ onPage,
1304
+ ready,
1305
+ freezer
1306
+ });
1088
1307
  this.actions.observe(context);
1089
1308
  context.on("page", onPage);
1090
1309
  for (const page of context.pages()) onPage(page);
1310
+ return ready;
1091
1311
  }
1092
1312
  async recordNode(page, options) {
1093
- this.observe(page.context());
1313
+ await this.observe(page.context());
1094
1314
  const state = this.pages.get(page);
1095
1315
  if (!state) throw new Error("Cannot record a closed browser page.");
1096
1316
  if (this.paused) return;
1097
1317
  await this.enqueueCreateNodeRequest(state, "explicit", options);
1098
1318
  }
1099
1319
  async setNodeConditions(page, declaration) {
1100
- this.observe(page.context());
1320
+ await this.observe(page.context());
1101
1321
  const state = this.pages.get(page);
1102
1322
  if (!state) throw new Error("Cannot tag a closed browser page.");
1103
1323
  const key = Symbol();
@@ -1128,8 +1348,11 @@ var Recorder = class {
1128
1348
  }
1129
1349
  /** Stops watching `context` and waits for its queued recordings. */
1130
1350
  async stopContext(context) {
1131
- const onPage = this.contexts.get(context);
1132
- if (onPage) context.off("page", onPage);
1351
+ const observed = this.contexts.get(context);
1352
+ if (observed) {
1353
+ context.off("page", observed.onPage);
1354
+ await observed.ready;
1355
+ }
1133
1356
  const states = [...this.pages.values()].filter((state) => state.page.context() === context);
1134
1357
  for (const state of states) state.page.off("framenavigated", state.onNavigation);
1135
1358
  await Promise.all(states.map((state) => state.tail));
@@ -1182,6 +1405,7 @@ var Recorder = class {
1182
1405
  }
1183
1406
  }).catch(() => void 0);
1184
1407
  page.on("framenavigated", state.onNavigation);
1408
+ watchImageResponses(page);
1185
1409
  this.urlChanged(state);
1186
1410
  }
1187
1411
  urlChanged(state) {
@@ -1244,6 +1468,7 @@ var Recorder = class {
1244
1468
  };
1245
1469
  try {
1246
1470
  check();
1471
+ await wait(this.contexts.get(page.context()).ready);
1247
1472
  if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
1248
1473
  check();
1249
1474
  const conditions = this.declaredConditions(state);
@@ -1256,18 +1481,27 @@ var Recorder = class {
1256
1481
  warnedNoTracker = true;
1257
1482
  console.warn("Ralph: this page runs no tracker, so the node conditions the test declares cannot be checked against the tags the app reports. Production events must carry the same tags, or these nodes will match no visitors.");
1258
1483
  }
1259
- if (this.options.reduceMotion) {
1484
+ const freezer = this.contexts.get(page.context()).freezer;
1485
+ if (this.options.reduceMotion && !freezer) {
1260
1486
  check();
1261
1487
  await wait(waitForAnimations(page, (deadline.endsAt - Date.now()) / 2));
1262
1488
  }
1263
- check();
1264
- const before = await wait(readLayout(page));
1265
- check();
1266
- const image = await wait(takeScreenshot(page, deadline.endsAt));
1267
- check();
1268
- const after = await wait(readLayout(page));
1269
- check();
1270
- 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 })]);
1489
+ const capture = async () => {
1490
+ check();
1491
+ const before = await wait(readLayout(page));
1492
+ check();
1493
+ const image = await wait(takeScreenshot(page, deadline.endsAt, !!freezer));
1494
+ check();
1495
+ const after = await wait(readLayout(page));
1496
+ check();
1497
+ return {
1498
+ before,
1499
+ image,
1500
+ after,
1501
+ 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 })])
1502
+ };
1503
+ };
1504
+ const { before, image, after, snapshot } = freezer ? await freezer.capture(page, deadline.signal, capture, deadline.endsAt) : await capture();
1271
1505
  checkPage();
1272
1506
  const recorded = {
1273
1507
  ...request,
@@ -1805,4 +2039,4 @@ Object.defineProperty(exports, "visitAllLinks", {
1805
2039
  }
1806
2040
  });
1807
2041
 
1808
- //# sourceMappingURL=upload-CRXt3vt1.cjs.map
2042
+ //# sourceMappingURL=upload-D40MqSQa.cjs.map