@ralphralphai/playwright 2.4.0 → 2.6.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.4.0";
73
+ var version = "2.6.0";
74
74
  //#endregion
75
75
  //#region src/actions.ts
76
76
  const BINDING = "__ralphAction";
@@ -654,8 +654,10 @@ async function captureAriaSnapshot(page, { viewport, scroll }) {
654
654
  boxes: true,
655
655
  depth: MAX_ARIA_DEPTH
656
656
  });
657
- const visible = (await Promise.all(snapshot.map((node) => toDocument(page, node, scroll)))).flatMap((node) => {
658
- const kept = keepShown(node, viewport.width);
657
+ const placed = await Promise.all(snapshot.map((node) => toDocument(page, node, scroll)));
658
+ const clips = await readClips(page, placed);
659
+ const visible = placed.flatMap((node) => {
660
+ const kept = keepShown(node, viewport.width, clips, void 0);
659
661
  return kept === void 0 ? [] : [kept];
660
662
  });
661
663
  const described = await Promise.all(visible.map((node) => describe(page, node, {}, false)));
@@ -719,15 +721,134 @@ function isReachable(box, viewportWidth) {
719
721
  function isShown(node, viewportWidth) {
720
722
  return node.ref !== void 0 && isReachable(node.box, viewportWidth);
721
723
  }
722
- function keepShown(node, viewportWidth) {
724
+ function edgesOf(box) {
725
+ return {
726
+ left: box.x,
727
+ top: box.y,
728
+ right: box.x + box.width,
729
+ bottom: box.y + box.height
730
+ };
731
+ }
732
+ function intersect(a, b) {
733
+ if (a === void 0) return b;
734
+ return {
735
+ left: Math.max(a.left, b.left),
736
+ top: Math.max(a.top, b.top),
737
+ right: Math.min(a.right, b.right),
738
+ bottom: Math.min(a.bottom, b.bottom)
739
+ };
740
+ }
741
+ const ROUNDING_SLACK = 1;
742
+ function sticksOut(inner, outer) {
743
+ return inner.x < outer.x - ROUNDING_SLACK || inner.y < outer.y - ROUNDING_SLACK || inner.x + inner.width > outer.x + outer.width + ROUNDING_SLACK || inner.y + inner.height > outer.y + outer.height + ROUNDING_SLACK;
744
+ }
745
+ function overlaps(a, b) {
746
+ return Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > ROUNDING_SLACK && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > ROUNDING_SLACK;
747
+ }
748
+ function isOutside(box, clip) {
749
+ return box.x >= clip.right || box.x + box.width <= clip.left || box.y >= clip.bottom || box.y + box.height <= clip.top;
750
+ }
751
+ /**
752
+ * The area each element is clipped to by its ancestors' `overflow`, in
753
+ * document space, by ref.
754
+ *
755
+ * The boxes are not clipped (the rows of a 448px scroll box run thousands of
756
+ * pixels down the page), and reading an element's ancestors is a page round
757
+ * trip. Only an element that may overflow one is read: a root, one sticking
758
+ * out of its parent, or one running into its next sibling. The clipping
759
+ * ancestor itself is often missing from the tree, since Playwright unwraps a
760
+ * nameless wrapper with one child. Every other element takes its parent's
761
+ * clip.
762
+ */
763
+ async function readClips(page, nodes) {
764
+ const candidates = [];
765
+ const visit = (node, parent, next, frame) => {
766
+ if (node.box !== void 0 && (parent === void 0 || sticksOut(node.box, parent) || next?.box !== void 0 && overlaps(node.box, next.box))) candidates.push({
767
+ node,
768
+ frame
769
+ });
770
+ const childFrame = node.role === "iframe" && node.box !== void 0 ? intersect(frame, edgesOf(node.box)) : frame;
771
+ const elements = (node.children ?? []).filter((child) => {
772
+ return typeof child !== "string";
773
+ });
774
+ elements.forEach((child, index) => {
775
+ visit(child, node.box, elements[index + 1], childFrame);
776
+ });
777
+ };
778
+ nodes.forEach((node, index) => {
779
+ visit(node, void 0, nodes[index + 1], void 0);
780
+ });
781
+ const clips = await Promise.all(candidates.map(async ({ node, frame }) => {
782
+ const clip = await readClip(page, node);
783
+ return clip === void 0 || node.ref === void 0 ? [] : [[node.ref, intersect(frame, clip)]];
784
+ }));
785
+ return new Map(clips.flat());
786
+ }
787
+ async function readClip(page, node) {
788
+ if (node.box === void 0 || node.ref === void 0) return;
789
+ let read;
790
+ try {
791
+ read = await page.locator(`aria-ref=${node.ref}`).evaluate((element) => {
792
+ const clip = {
793
+ left: -Infinity,
794
+ top: -Infinity,
795
+ right: Infinity,
796
+ bottom: Infinity
797
+ };
798
+ let position = getComputedStyle(element).position;
799
+ for (let ancestor = element.parentElement; ancestor !== null && ancestor !== document.body && ancestor !== document.documentElement; ancestor = ancestor.parentElement) {
800
+ if (position === "fixed") break;
801
+ const style = getComputedStyle(ancestor);
802
+ if (position === "absolute" && style.position === "static" && style.transform === "none") continue;
803
+ position = style.position;
804
+ const clipsX = style.overflowX !== "visible";
805
+ const clipsY = style.overflowY !== "visible";
806
+ if (!clipsX && !clipsY || style.display === "inline") continue;
807
+ const rect = ancestor.getBoundingClientRect();
808
+ const left = rect.left + ancestor.clientLeft;
809
+ const top = rect.top + ancestor.clientTop;
810
+ if (clipsX) {
811
+ clip.left = Math.max(clip.left, left);
812
+ clip.right = Math.min(clip.right, left + ancestor.clientWidth);
813
+ }
814
+ if (clipsY) {
815
+ clip.top = Math.max(clip.top, top);
816
+ clip.bottom = Math.min(clip.bottom, top + ancestor.clientHeight);
817
+ }
818
+ }
819
+ const own = element.getBoundingClientRect();
820
+ return {
821
+ x: own.x,
822
+ y: own.y,
823
+ clip
824
+ };
825
+ }, void 0, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
826
+ } catch {
827
+ return;
828
+ }
829
+ const dx = node.box.x - read.x;
830
+ const dy = node.box.y - read.y;
831
+ return {
832
+ left: read.clip.left + dx,
833
+ top: read.clip.top + dy,
834
+ right: read.clip.right + dx,
835
+ bottom: read.clip.bottom + dy
836
+ };
837
+ }
838
+ function keepShown(node, viewportWidth, clips, inherited) {
723
839
  if (node.role === "iframe" && node.box !== void 0 && (node.box.width < MIN_IFRAME_SIZE || node.box.height < MIN_IFRAME_SIZE)) return;
840
+ const clip = (node.ref === void 0 ? void 0 : clips.get(node.ref)) ?? inherited;
841
+ if (clip !== void 0 && node.box !== void 0 && isOutside(node.box, clip)) return;
724
842
  const shown = isShown(node, viewportWidth);
843
+ const childClip = node.role === "iframe" && node.box !== void 0 ? intersect(clip, edgesOf(node.box)) : clip;
725
844
  const children = (node.children ?? []).flatMap((child) => {
726
845
  if (typeof child === "string") return shown ? [child] : [];
727
- const kept = keepShown(child, viewportWidth);
846
+ const kept = keepShown(child, viewportWidth, clips, childClip);
728
847
  return kept === void 0 ? [] : [kept];
729
848
  });
730
849
  if (!shown && children.length === 0) return;
850
+ const [only] = children;
851
+ if (node.role === "generic" && !node.name && (node.children?.length ?? 0) > 1 && children.length === 1 && typeof only !== "string" && only?.ref !== void 0) return only;
731
852
  return node.children === void 0 ? node : {
732
853
  ...node,
733
854
  children
@@ -825,6 +946,179 @@ async function describe(page, child, inherited, insideDynamic) {
825
946
  };
826
947
  }
827
948
  //#endregion
949
+ //#region src/animation_observer.ts
950
+ /** Runs in the page. Keeps native animations and DOM changes observable without app hooks. */
951
+ function observeAnimations() {
952
+ const paused = /* @__PURE__ */ new Set();
953
+ const roots = /* @__PURE__ */ new Set();
954
+ let dirty = true;
955
+ const observer = new MutationObserver(() => {
956
+ dirty = true;
957
+ });
958
+ const collect = (root, all) => {
959
+ if (!roots.has(root)) {
960
+ roots.add(root);
961
+ observer.observe(root, {
962
+ subtree: true,
963
+ childList: true,
964
+ attributes: true,
965
+ characterData: true
966
+ });
967
+ dirty = true;
968
+ }
969
+ root.getAnimations().forEach((animation) => all.add(animation));
970
+ for (const element of root.querySelectorAll("*")) if (element.shadowRoot) collect(element.shadowRoot, all);
971
+ };
972
+ return {
973
+ sample(finishFinite) {
974
+ const all = /* @__PURE__ */ new Set();
975
+ collect(document, all);
976
+ let changed = dirty;
977
+ dirty = false;
978
+ for (const animation of all) {
979
+ if (animation.playState !== "running") continue;
980
+ if (finishFinite && animation.playbackRate !== 0 && Number.isFinite(animation.effect?.getComputedTiming().endTime)) animation.finish();
981
+ else {
982
+ animation.pause();
983
+ paused.add(animation);
984
+ }
985
+ changed = true;
986
+ }
987
+ return changed;
988
+ },
989
+ async ready() {
990
+ await Promise.all([...paused].map((animation) => animation.ready));
991
+ },
992
+ resume() {
993
+ observer.disconnect();
994
+ for (const animation of paused) if (animation.playState === "paused") animation.play();
995
+ }
996
+ };
997
+ }
998
+ //#endregion
999
+ //#region src/capture_freezer.ts
1000
+ const PAUSE_LEAD_MS = 100;
1001
+ const ANIMATION_QUIET_MS = 150;
1002
+ const MAX_ANIMATION_SETTLE_MS = 1500;
1003
+ /** One owner of the context-wide clock; captures in different tabs queue here. */
1004
+ var CaptureFreezer = class {
1005
+ context;
1006
+ tail = Promise.resolve();
1007
+ closed = false;
1008
+ constructor(context) {
1009
+ this.context = context;
1010
+ context.once("close", () => {
1011
+ this.closed = true;
1012
+ });
1013
+ }
1014
+ install() {
1015
+ 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.");
1016
+ return this.context.clock.install();
1017
+ }
1018
+ async capture(page, signal, capture, deadline = Date.now() + 5e3) {
1019
+ const task = this.tail.then(async () => {
1020
+ signal.throwIfAborted();
1021
+ const animations = /* @__PURE__ */ new Map();
1022
+ try {
1023
+ await this.settleAnimations(page, animations, signal, deadline);
1024
+ await this.pauseClock(page, signal);
1025
+ signal.throwIfAborted();
1026
+ for (let pass = 0; pass < 2; pass++) {
1027
+ await this.sampleAnimations(page, animations, signal, false);
1028
+ await this.waitForAnimationsReady(animations, signal);
1029
+ }
1030
+ return await capture();
1031
+ } finally {
1032
+ try {
1033
+ await this.resumeAnimations(animations);
1034
+ } finally {
1035
+ if (!this.closed) await this.context.clock.resume();
1036
+ }
1037
+ }
1038
+ });
1039
+ this.tail = task.catch(() => void 0);
1040
+ return task;
1041
+ }
1042
+ async settleAnimations(page, animations, signal, deadline) {
1043
+ const now = Date.now();
1044
+ const endsAt = now + Math.min(MAX_ANIMATION_SETTLE_MS, Math.max(0, (deadline - now) / 2));
1045
+ let quietSince = now;
1046
+ while (Date.now() < endsAt) {
1047
+ if (await this.sampleAnimations(page, animations, signal, true)) quietSince = Date.now();
1048
+ if (Date.now() - quietSince >= ANIMATION_QUIET_MS) return;
1049
+ await (0, node_timers_promises.setTimeout)(Math.max(0, Math.min(50, endsAt - Date.now())), void 0, { signal }).catch((error) => {
1050
+ signal.throwIfAborted();
1051
+ throw error;
1052
+ });
1053
+ }
1054
+ }
1055
+ async sampleAnimations(page, animations, signal, finishFinite) {
1056
+ let changed = false;
1057
+ for (const frame of page.frames()) {
1058
+ signal.throwIfAborted();
1059
+ try {
1060
+ let handle = animations.get(frame);
1061
+ if (!handle) {
1062
+ handle = await frame.evaluateHandle(observeAnimations);
1063
+ animations.set(frame, handle);
1064
+ }
1065
+ changed = await handle.evaluate((state, finish) => state.sample(finish), finishFinite) || changed;
1066
+ } catch (error) {
1067
+ if (!frame.isDetached()) throw error;
1068
+ }
1069
+ }
1070
+ signal.throwIfAborted();
1071
+ return changed;
1072
+ }
1073
+ async waitForAnimationsReady(animations, signal) {
1074
+ signal.throwIfAborted();
1075
+ let onAbort;
1076
+ const aborted = new Promise((_, reject) => {
1077
+ onAbort = () => reject(signal.reason);
1078
+ signal.addEventListener("abort", onAbort, { once: true });
1079
+ });
1080
+ try {
1081
+ await Promise.race([Promise.all([...animations].map(async ([frame, handle]) => {
1082
+ if (frame.isDetached()) return;
1083
+ try {
1084
+ await handle.evaluate((state) => state.ready());
1085
+ } catch (error) {
1086
+ if (!frame.isDetached()) throw error;
1087
+ }
1088
+ })), aborted]);
1089
+ } finally {
1090
+ signal.removeEventListener("abort", onAbort);
1091
+ }
1092
+ }
1093
+ async resumeAnimations(animations) {
1094
+ const restored = await Promise.allSettled([...animations.values()].map(async (handle) => {
1095
+ try {
1096
+ await handle.evaluate((state) => state.resume());
1097
+ } catch (error) {
1098
+ if (!destroyedContext(error)) throw error;
1099
+ } finally {
1100
+ await handle.dispose();
1101
+ }
1102
+ }));
1103
+ for (const result of restored) if (result.status === "rejected") throw result.reason;
1104
+ }
1105
+ async pauseClock(page, signal) {
1106
+ for (let attempt = 0;; attempt++) {
1107
+ const time = await page.evaluate(() => Date.now());
1108
+ signal.throwIfAborted();
1109
+ try {
1110
+ await this.context.clock.pauseAt(time + PAUSE_LEAD_MS);
1111
+ return;
1112
+ } catch (error) {
1113
+ if (attempt > 0 || !(error instanceof Error) || !error.message.includes("Cannot fast-forward to the past")) throw error;
1114
+ }
1115
+ }
1116
+ }
1117
+ };
1118
+ function destroyedContext(error) {
1119
+ 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);
1120
+ }
1121
+ //#endregion
828
1122
  //#region src/conditions.ts
829
1123
  const declared = (declaration = {}) => {
830
1124
  const conditions = {};
@@ -886,11 +1180,11 @@ function nodeKey(conditions) {
886
1180
  */
887
1181
  const WEBP_QUALITY = 80;
888
1182
  const WEBP_MAX_DIMENSION = 16383;
889
- async function takeScreenshot(page, deadline) {
1183
+ async function takeScreenshot(page, deadline, frozen = false) {
890
1184
  const options = {
891
1185
  fullPage: true,
892
1186
  scale: "css",
893
- animations: "disabled"
1187
+ animations: frozen ? "allow" : "disabled"
894
1188
  };
895
1189
  const webp = await page.screenshot({
896
1190
  ...options,
@@ -1119,22 +1413,30 @@ var Recorder = class {
1119
1413
  /** Starts recording URL changes in every current and future tab of `context`. */
1120
1414
  observe(context) {
1121
1415
  if (this.closed) throw new Error("The Ralph recorder has finished.");
1122
- if (this.contexts.has(context)) return;
1416
+ const existing = this.contexts.get(context);
1417
+ if (existing) return existing.ready;
1418
+ const freezer = this.options.freezeDuringCapture ? new CaptureFreezer(context) : void 0;
1419
+ const ready = freezer?.install() ?? Promise.resolve();
1123
1420
  const onPage = (page) => this.watchPage(page);
1124
- this.contexts.set(context, onPage);
1421
+ this.contexts.set(context, {
1422
+ onPage,
1423
+ ready,
1424
+ freezer
1425
+ });
1125
1426
  this.actions.observe(context);
1126
1427
  context.on("page", onPage);
1127
1428
  for (const page of context.pages()) onPage(page);
1429
+ return ready;
1128
1430
  }
1129
1431
  async recordNode(page, options) {
1130
- this.observe(page.context());
1432
+ await this.observe(page.context());
1131
1433
  const state = this.pages.get(page);
1132
1434
  if (!state) throw new Error("Cannot record a closed browser page.");
1133
1435
  if (this.paused) return;
1134
1436
  await this.enqueueCreateNodeRequest(state, "explicit", options);
1135
1437
  }
1136
1438
  async setNodeConditions(page, declaration) {
1137
- this.observe(page.context());
1439
+ await this.observe(page.context());
1138
1440
  const state = this.pages.get(page);
1139
1441
  if (!state) throw new Error("Cannot tag a closed browser page.");
1140
1442
  const key = Symbol();
@@ -1165,8 +1467,11 @@ var Recorder = class {
1165
1467
  }
1166
1468
  /** Stops watching `context` and waits for its queued recordings. */
1167
1469
  async stopContext(context) {
1168
- const onPage = this.contexts.get(context);
1169
- if (onPage) context.off("page", onPage);
1470
+ const observed = this.contexts.get(context);
1471
+ if (observed) {
1472
+ context.off("page", observed.onPage);
1473
+ await observed.ready;
1474
+ }
1170
1475
  const states = [...this.pages.values()].filter((state) => state.page.context() === context);
1171
1476
  for (const state of states) state.page.off("framenavigated", state.onNavigation);
1172
1477
  await Promise.all(states.map((state) => state.tail));
@@ -1282,6 +1587,7 @@ var Recorder = class {
1282
1587
  };
1283
1588
  try {
1284
1589
  check();
1590
+ await wait(this.contexts.get(page.context()).ready);
1285
1591
  if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
1286
1592
  check();
1287
1593
  const conditions = this.declaredConditions(state);
@@ -1294,18 +1600,27 @@ var Recorder = class {
1294
1600
  warnedNoTracker = true;
1295
1601
  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.");
1296
1602
  }
1297
- if (this.options.reduceMotion) {
1603
+ const freezer = this.contexts.get(page.context()).freezer;
1604
+ if (this.options.reduceMotion && !freezer) {
1298
1605
  check();
1299
1606
  await wait(waitForAnimations(page, (deadline.endsAt - Date.now()) / 2));
1300
1607
  }
1301
- check();
1302
- const before = await wait(readLayout(page));
1303
- check();
1304
- const image = await wait(takeScreenshot(page, deadline.endsAt));
1305
- check();
1306
- const after = await wait(readLayout(page));
1307
- check();
1308
- 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 })]);
1608
+ const capture = async () => {
1609
+ check();
1610
+ const before = await wait(readLayout(page));
1611
+ check();
1612
+ const image = await wait(takeScreenshot(page, deadline.endsAt, !!freezer));
1613
+ check();
1614
+ const after = await wait(readLayout(page));
1615
+ check();
1616
+ return {
1617
+ before,
1618
+ image,
1619
+ after,
1620
+ 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 })])
1621
+ };
1622
+ };
1623
+ const { before, image, after, snapshot } = freezer ? await freezer.capture(page, deadline.signal, capture, deadline.endsAt) : await capture();
1309
1624
  checkPage();
1310
1625
  const recorded = {
1311
1626
  ...request,
@@ -1843,4 +2158,4 @@ Object.defineProperty(exports, "visitAllLinks", {
1843
2158
  }
1844
2159
  });
1845
2160
 
1846
- //# sourceMappingURL=upload-FIxKqKb3.cjs.map
2161
+ //# sourceMappingURL=upload-H-EycbCA.cjs.map