@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.
@@ -45,7 +45,7 @@ function snapshot(value) {
45
45
  }
46
46
  //#endregion
47
47
  //#region package.json
48
- var version = "2.4.0";
48
+ var version = "2.6.0";
49
49
  //#endregion
50
50
  //#region src/actions.ts
51
51
  const BINDING = "__ralphAction";
@@ -629,8 +629,10 @@ async function captureAriaSnapshot(page, { viewport, scroll }) {
629
629
  boxes: true,
630
630
  depth: MAX_ARIA_DEPTH
631
631
  });
632
- const visible = (await Promise.all(snapshot.map((node) => toDocument(page, node, scroll)))).flatMap((node) => {
633
- const kept = keepShown(node, viewport.width);
632
+ const placed = await Promise.all(snapshot.map((node) => toDocument(page, node, scroll)));
633
+ const clips = await readClips(page, placed);
634
+ const visible = placed.flatMap((node) => {
635
+ const kept = keepShown(node, viewport.width, clips, void 0);
634
636
  return kept === void 0 ? [] : [kept];
635
637
  });
636
638
  const described = await Promise.all(visible.map((node) => describe(page, node, {}, false)));
@@ -694,15 +696,134 @@ function isReachable(box, viewportWidth) {
694
696
  function isShown(node, viewportWidth) {
695
697
  return node.ref !== void 0 && isReachable(node.box, viewportWidth);
696
698
  }
697
- function keepShown(node, viewportWidth) {
699
+ function edgesOf(box) {
700
+ return {
701
+ left: box.x,
702
+ top: box.y,
703
+ right: box.x + box.width,
704
+ bottom: box.y + box.height
705
+ };
706
+ }
707
+ function intersect(a, b) {
708
+ if (a === void 0) return b;
709
+ return {
710
+ left: Math.max(a.left, b.left),
711
+ top: Math.max(a.top, b.top),
712
+ right: Math.min(a.right, b.right),
713
+ bottom: Math.min(a.bottom, b.bottom)
714
+ };
715
+ }
716
+ const ROUNDING_SLACK = 1;
717
+ function sticksOut(inner, outer) {
718
+ 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;
719
+ }
720
+ function overlaps(a, b) {
721
+ 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;
722
+ }
723
+ function isOutside(box, clip) {
724
+ return box.x >= clip.right || box.x + box.width <= clip.left || box.y >= clip.bottom || box.y + box.height <= clip.top;
725
+ }
726
+ /**
727
+ * The area each element is clipped to by its ancestors' `overflow`, in
728
+ * document space, by ref.
729
+ *
730
+ * The boxes are not clipped (the rows of a 448px scroll box run thousands of
731
+ * pixels down the page), and reading an element's ancestors is a page round
732
+ * trip. Only an element that may overflow one is read: a root, one sticking
733
+ * out of its parent, or one running into its next sibling. The clipping
734
+ * ancestor itself is often missing from the tree, since Playwright unwraps a
735
+ * nameless wrapper with one child. Every other element takes its parent's
736
+ * clip.
737
+ */
738
+ async function readClips(page, nodes) {
739
+ const candidates = [];
740
+ const visit = (node, parent, next, frame) => {
741
+ if (node.box !== void 0 && (parent === void 0 || sticksOut(node.box, parent) || next?.box !== void 0 && overlaps(node.box, next.box))) candidates.push({
742
+ node,
743
+ frame
744
+ });
745
+ const childFrame = node.role === "iframe" && node.box !== void 0 ? intersect(frame, edgesOf(node.box)) : frame;
746
+ const elements = (node.children ?? []).filter((child) => {
747
+ return typeof child !== "string";
748
+ });
749
+ elements.forEach((child, index) => {
750
+ visit(child, node.box, elements[index + 1], childFrame);
751
+ });
752
+ };
753
+ nodes.forEach((node, index) => {
754
+ visit(node, void 0, nodes[index + 1], void 0);
755
+ });
756
+ const clips = await Promise.all(candidates.map(async ({ node, frame }) => {
757
+ const clip = await readClip(page, node);
758
+ return clip === void 0 || node.ref === void 0 ? [] : [[node.ref, intersect(frame, clip)]];
759
+ }));
760
+ return new Map(clips.flat());
761
+ }
762
+ async function readClip(page, node) {
763
+ if (node.box === void 0 || node.ref === void 0) return;
764
+ let read;
765
+ try {
766
+ read = await page.locator(`aria-ref=${node.ref}`).evaluate((element) => {
767
+ const clip = {
768
+ left: -Infinity,
769
+ top: -Infinity,
770
+ right: Infinity,
771
+ bottom: Infinity
772
+ };
773
+ let position = getComputedStyle(element).position;
774
+ for (let ancestor = element.parentElement; ancestor !== null && ancestor !== document.body && ancestor !== document.documentElement; ancestor = ancestor.parentElement) {
775
+ if (position === "fixed") break;
776
+ const style = getComputedStyle(ancestor);
777
+ if (position === "absolute" && style.position === "static" && style.transform === "none") continue;
778
+ position = style.position;
779
+ const clipsX = style.overflowX !== "visible";
780
+ const clipsY = style.overflowY !== "visible";
781
+ if (!clipsX && !clipsY || style.display === "inline") continue;
782
+ const rect = ancestor.getBoundingClientRect();
783
+ const left = rect.left + ancestor.clientLeft;
784
+ const top = rect.top + ancestor.clientTop;
785
+ if (clipsX) {
786
+ clip.left = Math.max(clip.left, left);
787
+ clip.right = Math.min(clip.right, left + ancestor.clientWidth);
788
+ }
789
+ if (clipsY) {
790
+ clip.top = Math.max(clip.top, top);
791
+ clip.bottom = Math.min(clip.bottom, top + ancestor.clientHeight);
792
+ }
793
+ }
794
+ const own = element.getBoundingClientRect();
795
+ return {
796
+ x: own.x,
797
+ y: own.y,
798
+ clip
799
+ };
800
+ }, void 0, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
801
+ } catch {
802
+ return;
803
+ }
804
+ const dx = node.box.x - read.x;
805
+ const dy = node.box.y - read.y;
806
+ return {
807
+ left: read.clip.left + dx,
808
+ top: read.clip.top + dy,
809
+ right: read.clip.right + dx,
810
+ bottom: read.clip.bottom + dy
811
+ };
812
+ }
813
+ function keepShown(node, viewportWidth, clips, inherited) {
698
814
  if (node.role === "iframe" && node.box !== void 0 && (node.box.width < MIN_IFRAME_SIZE || node.box.height < MIN_IFRAME_SIZE)) return;
815
+ const clip = (node.ref === void 0 ? void 0 : clips.get(node.ref)) ?? inherited;
816
+ if (clip !== void 0 && node.box !== void 0 && isOutside(node.box, clip)) return;
699
817
  const shown = isShown(node, viewportWidth);
818
+ const childClip = node.role === "iframe" && node.box !== void 0 ? intersect(clip, edgesOf(node.box)) : clip;
700
819
  const children = (node.children ?? []).flatMap((child) => {
701
820
  if (typeof child === "string") return shown ? [child] : [];
702
- const kept = keepShown(child, viewportWidth);
821
+ const kept = keepShown(child, viewportWidth, clips, childClip);
703
822
  return kept === void 0 ? [] : [kept];
704
823
  });
705
824
  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;
706
827
  return node.children === void 0 ? node : {
707
828
  ...node,
708
829
  children
@@ -800,6 +921,179 @@ async function describe(page, child, inherited, insideDynamic) {
800
921
  };
801
922
  }
802
923
  //#endregion
924
+ //#region src/animation_observer.ts
925
+ /** Runs in the page. Keeps native animations and DOM changes observable without app hooks. */
926
+ function observeAnimations() {
927
+ const paused = /* @__PURE__ */ new Set();
928
+ const roots = /* @__PURE__ */ new Set();
929
+ let dirty = true;
930
+ const observer = new MutationObserver(() => {
931
+ dirty = true;
932
+ });
933
+ const collect = (root, all) => {
934
+ if (!roots.has(root)) {
935
+ roots.add(root);
936
+ observer.observe(root, {
937
+ subtree: true,
938
+ childList: true,
939
+ attributes: true,
940
+ characterData: true
941
+ });
942
+ dirty = true;
943
+ }
944
+ root.getAnimations().forEach((animation) => all.add(animation));
945
+ for (const element of root.querySelectorAll("*")) if (element.shadowRoot) collect(element.shadowRoot, all);
946
+ };
947
+ return {
948
+ sample(finishFinite) {
949
+ const all = /* @__PURE__ */ new Set();
950
+ collect(document, all);
951
+ let changed = dirty;
952
+ dirty = false;
953
+ for (const animation of all) {
954
+ if (animation.playState !== "running") continue;
955
+ if (finishFinite && animation.playbackRate !== 0 && Number.isFinite(animation.effect?.getComputedTiming().endTime)) animation.finish();
956
+ else {
957
+ animation.pause();
958
+ paused.add(animation);
959
+ }
960
+ changed = true;
961
+ }
962
+ return changed;
963
+ },
964
+ async ready() {
965
+ await Promise.all([...paused].map((animation) => animation.ready));
966
+ },
967
+ resume() {
968
+ observer.disconnect();
969
+ for (const animation of paused) if (animation.playState === "paused") animation.play();
970
+ }
971
+ };
972
+ }
973
+ //#endregion
974
+ //#region src/capture_freezer.ts
975
+ const PAUSE_LEAD_MS = 100;
976
+ const ANIMATION_QUIET_MS = 150;
977
+ const MAX_ANIMATION_SETTLE_MS = 1500;
978
+ /** One owner of the context-wide clock; captures in different tabs queue here. */
979
+ var CaptureFreezer = class {
980
+ context;
981
+ tail = Promise.resolve();
982
+ closed = false;
983
+ constructor(context) {
984
+ this.context = context;
985
+ context.once("close", () => {
986
+ this.closed = true;
987
+ });
988
+ }
989
+ install() {
990
+ 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.");
991
+ return this.context.clock.install();
992
+ }
993
+ async capture(page, signal, capture, deadline = Date.now() + 5e3) {
994
+ const task = this.tail.then(async () => {
995
+ signal.throwIfAborted();
996
+ const animations = /* @__PURE__ */ new Map();
997
+ try {
998
+ await this.settleAnimations(page, animations, signal, deadline);
999
+ await this.pauseClock(page, signal);
1000
+ signal.throwIfAborted();
1001
+ for (let pass = 0; pass < 2; pass++) {
1002
+ await this.sampleAnimations(page, animations, signal, false);
1003
+ await this.waitForAnimationsReady(animations, signal);
1004
+ }
1005
+ return await capture();
1006
+ } finally {
1007
+ try {
1008
+ await this.resumeAnimations(animations);
1009
+ } finally {
1010
+ if (!this.closed) await this.context.clock.resume();
1011
+ }
1012
+ }
1013
+ });
1014
+ this.tail = task.catch(() => void 0);
1015
+ return task;
1016
+ }
1017
+ async settleAnimations(page, animations, signal, deadline) {
1018
+ const now = Date.now();
1019
+ const endsAt = now + Math.min(MAX_ANIMATION_SETTLE_MS, Math.max(0, (deadline - now) / 2));
1020
+ let quietSince = now;
1021
+ while (Date.now() < endsAt) {
1022
+ if (await this.sampleAnimations(page, animations, signal, true)) quietSince = Date.now();
1023
+ if (Date.now() - quietSince >= ANIMATION_QUIET_MS) return;
1024
+ await setTimeout$1(Math.max(0, Math.min(50, endsAt - Date.now())), void 0, { signal }).catch((error) => {
1025
+ signal.throwIfAborted();
1026
+ throw error;
1027
+ });
1028
+ }
1029
+ }
1030
+ async sampleAnimations(page, animations, signal, finishFinite) {
1031
+ let changed = false;
1032
+ for (const frame of page.frames()) {
1033
+ signal.throwIfAborted();
1034
+ try {
1035
+ let handle = animations.get(frame);
1036
+ if (!handle) {
1037
+ handle = await frame.evaluateHandle(observeAnimations);
1038
+ animations.set(frame, handle);
1039
+ }
1040
+ changed = await handle.evaluate((state, finish) => state.sample(finish), finishFinite) || changed;
1041
+ } catch (error) {
1042
+ if (!frame.isDetached()) throw error;
1043
+ }
1044
+ }
1045
+ signal.throwIfAborted();
1046
+ return changed;
1047
+ }
1048
+ async waitForAnimationsReady(animations, signal) {
1049
+ signal.throwIfAborted();
1050
+ let onAbort;
1051
+ const aborted = new Promise((_, reject) => {
1052
+ onAbort = () => reject(signal.reason);
1053
+ signal.addEventListener("abort", onAbort, { once: true });
1054
+ });
1055
+ try {
1056
+ await Promise.race([Promise.all([...animations].map(async ([frame, handle]) => {
1057
+ if (frame.isDetached()) return;
1058
+ try {
1059
+ await handle.evaluate((state) => state.ready());
1060
+ } catch (error) {
1061
+ if (!frame.isDetached()) throw error;
1062
+ }
1063
+ })), aborted]);
1064
+ } finally {
1065
+ signal.removeEventListener("abort", onAbort);
1066
+ }
1067
+ }
1068
+ async resumeAnimations(animations) {
1069
+ const restored = await Promise.allSettled([...animations.values()].map(async (handle) => {
1070
+ try {
1071
+ await handle.evaluate((state) => state.resume());
1072
+ } catch (error) {
1073
+ if (!destroyedContext(error)) throw error;
1074
+ } finally {
1075
+ await handle.dispose();
1076
+ }
1077
+ }));
1078
+ for (const result of restored) if (result.status === "rejected") throw result.reason;
1079
+ }
1080
+ async pauseClock(page, signal) {
1081
+ for (let attempt = 0;; attempt++) {
1082
+ const time = await page.evaluate(() => Date.now());
1083
+ signal.throwIfAborted();
1084
+ try {
1085
+ await this.context.clock.pauseAt(time + PAUSE_LEAD_MS);
1086
+ return;
1087
+ } catch (error) {
1088
+ if (attempt > 0 || !(error instanceof Error) || !error.message.includes("Cannot fast-forward to the past")) throw error;
1089
+ }
1090
+ }
1091
+ }
1092
+ };
1093
+ function destroyedContext(error) {
1094
+ 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);
1095
+ }
1096
+ //#endregion
803
1097
  //#region src/conditions.ts
804
1098
  const declared = (declaration = {}) => {
805
1099
  const conditions = {};
@@ -861,11 +1155,11 @@ function nodeKey(conditions) {
861
1155
  */
862
1156
  const WEBP_QUALITY = 80;
863
1157
  const WEBP_MAX_DIMENSION = 16383;
864
- async function takeScreenshot(page, deadline) {
1158
+ async function takeScreenshot(page, deadline, frozen = false) {
865
1159
  const options = {
866
1160
  fullPage: true,
867
1161
  scale: "css",
868
- animations: "disabled"
1162
+ animations: frozen ? "allow" : "disabled"
869
1163
  };
870
1164
  const webp = await page.screenshot({
871
1165
  ...options,
@@ -1094,22 +1388,30 @@ var Recorder = class {
1094
1388
  /** Starts recording URL changes in every current and future tab of `context`. */
1095
1389
  observe(context) {
1096
1390
  if (this.closed) throw new Error("The Ralph recorder has finished.");
1097
- if (this.contexts.has(context)) return;
1391
+ const existing = this.contexts.get(context);
1392
+ if (existing) return existing.ready;
1393
+ const freezer = this.options.freezeDuringCapture ? new CaptureFreezer(context) : void 0;
1394
+ const ready = freezer?.install() ?? Promise.resolve();
1098
1395
  const onPage = (page) => this.watchPage(page);
1099
- this.contexts.set(context, onPage);
1396
+ this.contexts.set(context, {
1397
+ onPage,
1398
+ ready,
1399
+ freezer
1400
+ });
1100
1401
  this.actions.observe(context);
1101
1402
  context.on("page", onPage);
1102
1403
  for (const page of context.pages()) onPage(page);
1404
+ return ready;
1103
1405
  }
1104
1406
  async recordNode(page, options) {
1105
- this.observe(page.context());
1407
+ await this.observe(page.context());
1106
1408
  const state = this.pages.get(page);
1107
1409
  if (!state) throw new Error("Cannot record a closed browser page.");
1108
1410
  if (this.paused) return;
1109
1411
  await this.enqueueCreateNodeRequest(state, "explicit", options);
1110
1412
  }
1111
1413
  async setNodeConditions(page, declaration) {
1112
- this.observe(page.context());
1414
+ await this.observe(page.context());
1113
1415
  const state = this.pages.get(page);
1114
1416
  if (!state) throw new Error("Cannot tag a closed browser page.");
1115
1417
  const key = Symbol();
@@ -1140,8 +1442,11 @@ var Recorder = class {
1140
1442
  }
1141
1443
  /** Stops watching `context` and waits for its queued recordings. */
1142
1444
  async stopContext(context) {
1143
- const onPage = this.contexts.get(context);
1144
- if (onPage) context.off("page", onPage);
1445
+ const observed = this.contexts.get(context);
1446
+ if (observed) {
1447
+ context.off("page", observed.onPage);
1448
+ await observed.ready;
1449
+ }
1145
1450
  const states = [...this.pages.values()].filter((state) => state.page.context() === context);
1146
1451
  for (const state of states) state.page.off("framenavigated", state.onNavigation);
1147
1452
  await Promise.all(states.map((state) => state.tail));
@@ -1257,6 +1562,7 @@ var Recorder = class {
1257
1562
  };
1258
1563
  try {
1259
1564
  check();
1565
+ await wait(this.contexts.get(page.context()).ready);
1260
1566
  if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
1261
1567
  check();
1262
1568
  const conditions = this.declaredConditions(state);
@@ -1269,18 +1575,27 @@ var Recorder = class {
1269
1575
  warnedNoTracker = true;
1270
1576
  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.");
1271
1577
  }
1272
- if (this.options.reduceMotion) {
1578
+ const freezer = this.contexts.get(page.context()).freezer;
1579
+ if (this.options.reduceMotion && !freezer) {
1273
1580
  check();
1274
1581
  await wait(waitForAnimations(page, (deadline.endsAt - Date.now()) / 2));
1275
1582
  }
1276
- check();
1277
- const before = await wait(readLayout(page));
1278
- check();
1279
- const image = await wait(takeScreenshot(page, deadline.endsAt));
1280
- check();
1281
- const after = await wait(readLayout(page));
1282
- check();
1283
- const snapshot = await Promise.race([captureAriaSnapshot(page, after).catch(() => void 0), setTimeout$1(Math.max(0, deadline.endsAt - Date.now()), void 0, { ref: false })]);
1583
+ const capture = async () => {
1584
+ check();
1585
+ const before = await wait(readLayout(page));
1586
+ check();
1587
+ const image = await wait(takeScreenshot(page, deadline.endsAt, !!freezer));
1588
+ check();
1589
+ const after = await wait(readLayout(page));
1590
+ check();
1591
+ return {
1592
+ before,
1593
+ image,
1594
+ 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 })])
1596
+ };
1597
+ };
1598
+ const { before, image, after, snapshot } = freezer ? await freezer.capture(page, deadline.signal, capture, deadline.endsAt) : await capture();
1284
1599
  checkPage();
1285
1600
  const recorded = {
1286
1601
  ...request,
@@ -1711,4 +2026,4 @@ async function responseDetail(response) {
1711
2026
  //#endregion
1712
2027
  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 };
1713
2028
 
1714
- //# sourceMappingURL=upload-CUtC_rnN.mjs.map
2029
+ //# sourceMappingURL=upload-CGIMWRQM.mjs.map