@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.
@@ -45,7 +45,7 @@ function snapshot(value) {
45
45
  }
46
46
  //#endregion
47
47
  //#region package.json
48
- var version = "2.3.0";
48
+ var version = "2.5.0";
49
49
  //#endregion
50
50
  //#region src/actions.ts
51
51
  const BINDING = "__ralphAction";
@@ -522,6 +522,54 @@ async function waitForAnimations(page, timeoutMs) {
522
522
  if (!(error instanceof errors.TimeoutError)) throw error;
523
523
  }
524
524
  }
525
+ //#endregion
526
+ //#region src/image_responses.ts
527
+ const IMAGE_HASH_TIMEOUT_MS = 3e4;
528
+ const hashesByPage = /* @__PURE__ */ new WeakMap();
529
+ function sha256(data) {
530
+ return createHash("sha256").update(data).digest("hex");
531
+ }
532
+ /**
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
536
+ * upload.wikimedia.org answer with 429 for a share of them, and the image then
537
+ * reads as replaced. Only the hash is kept, not the body. Entries persist
538
+ * across navigations until another request for the same image replaces them
539
+ * or the page closes.
540
+ */
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;
547
+ const hash = request.response().then(async (response) => {
548
+ return response?.ok() ? sha256(await response.body()) : void 0;
549
+ }).catch(() => void 0);
550
+ for (let redirected = request; redirected !== null; redirected = redirected.redirectedFrom()) hashes.set(redirected.url(), hash);
551
+ });
552
+ page.once("close", () => hashesByPage.delete(page));
553
+ }
554
+ /**
555
+ * The hash from the latest observed request for this URL, across navigations.
556
+ * Undefined when no request was observed, the latest load failed, or it has
557
+ * not finished within the timeout, so an image that never finishes loading
558
+ * cannot stall a snapshot.
559
+ */
560
+ async function loadedImageHash(page, url, timeoutMs = IMAGE_HASH_TIMEOUT_MS) {
561
+ const hash = hashesByPage.get(page)?.get(url.split("#")[0]);
562
+ if (hash === void 0) return;
563
+ let timer;
564
+ const timeout = new Promise((resolve) => {
565
+ timer = setTimeout(resolve, timeoutMs, void 0);
566
+ });
567
+ try {
568
+ return await Promise.race([hash, timeout]);
569
+ } finally {
570
+ clearTimeout(timer);
571
+ }
572
+ }
525
573
  const MAX_ARIA_DEPTH = Math.floor(128 / 2) - 2;
526
574
  const STYLE_PROPERTIES = [
527
575
  "font-family",
@@ -655,6 +703,8 @@ function keepShown(node, viewportWidth) {
655
703
  return kept === void 0 ? [] : [kept];
656
704
  });
657
705
  if (!shown && children.length === 0) return;
706
+ const [only] = children;
707
+ if (node.role === "generic" && !node.name && (node.children?.length ?? 0) > 1 && children.length === 1 && typeof only !== "string" && only?.ref !== void 0) return only;
658
708
  return node.children === void 0 ? node : {
659
709
  ...node,
660
710
  children
@@ -681,7 +731,8 @@ async function readElement(page, ref, isImage) {
681
731
  try {
682
732
  return await page.locator(`aria-ref=${ref}`).evaluate((element, { properties, isImage, dynamicAttribute }) => {
683
733
  const computed = getComputedStyle(element);
684
- const style = Object.fromEntries(properties.map((property) => [property, computed.getPropertyValue(property)]));
734
+ const typed = "computedStyleMap" in element ? element.computedStyleMap() : void 0;
735
+ const style = Object.fromEntries(properties.map((property) => [property, property.startsWith("margin-") && typed?.get(property)?.toString() === "auto" ? "auto" : computed.getPropertyValue(property)]));
685
736
  const dynamic = element.closest(`[${dynamicAttribute}]`) !== null;
686
737
  if (!isImage) return {
687
738
  style,
@@ -719,23 +770,11 @@ async function readElement(page, ref, isImage) {
719
770
  return;
720
771
  }
721
772
  }
722
- function sha256(data) {
723
- return createHash("sha256").update(data).digest("hex");
724
- }
725
773
  async function hashImage(page, image) {
726
774
  if ("markup" in image) return sha256(image.markup);
727
775
  if (image.src === "") return;
728
776
  if (image.src.startsWith("data:")) return sha256(image.src);
729
- try {
730
- const response = await page.request.get(image.src);
731
- try {
732
- return response.ok() ? sha256(await response.body()) : void 0;
733
- } finally {
734
- await response.dispose();
735
- }
736
- } catch {
737
- return;
738
- }
777
+ return loadedImageHash(page, image.src);
739
778
  }
740
779
  function distinctStyle(style, inherited) {
741
780
  const distinct = Object.entries(style).filter(([property, value]) => {
@@ -763,6 +802,179 @@ async function describe(page, child, inherited, insideDynamic) {
763
802
  };
764
803
  }
765
804
  //#endregion
805
+ //#region src/animation_observer.ts
806
+ /** Runs in the page. Keeps native animations and DOM changes observable without app hooks. */
807
+ function observeAnimations() {
808
+ const paused = /* @__PURE__ */ new Set();
809
+ const roots = /* @__PURE__ */ new Set();
810
+ let dirty = true;
811
+ const observer = new MutationObserver(() => {
812
+ dirty = true;
813
+ });
814
+ const collect = (root, all) => {
815
+ if (!roots.has(root)) {
816
+ roots.add(root);
817
+ observer.observe(root, {
818
+ subtree: true,
819
+ childList: true,
820
+ attributes: true,
821
+ characterData: true
822
+ });
823
+ dirty = true;
824
+ }
825
+ root.getAnimations().forEach((animation) => all.add(animation));
826
+ for (const element of root.querySelectorAll("*")) if (element.shadowRoot) collect(element.shadowRoot, all);
827
+ };
828
+ return {
829
+ sample(finishFinite) {
830
+ const all = /* @__PURE__ */ new Set();
831
+ collect(document, all);
832
+ let changed = dirty;
833
+ dirty = false;
834
+ for (const animation of all) {
835
+ if (animation.playState !== "running") continue;
836
+ if (finishFinite && animation.playbackRate !== 0 && Number.isFinite(animation.effect?.getComputedTiming().endTime)) animation.finish();
837
+ else {
838
+ animation.pause();
839
+ paused.add(animation);
840
+ }
841
+ changed = true;
842
+ }
843
+ return changed;
844
+ },
845
+ async ready() {
846
+ await Promise.all([...paused].map((animation) => animation.ready));
847
+ },
848
+ resume() {
849
+ observer.disconnect();
850
+ for (const animation of paused) if (animation.playState === "paused") animation.play();
851
+ }
852
+ };
853
+ }
854
+ //#endregion
855
+ //#region src/capture_freezer.ts
856
+ const PAUSE_LEAD_MS = 100;
857
+ const ANIMATION_QUIET_MS = 150;
858
+ const MAX_ANIMATION_SETTLE_MS = 1500;
859
+ /** One owner of the context-wide clock; captures in different tabs queue here. */
860
+ var CaptureFreezer = class {
861
+ context;
862
+ tail = Promise.resolve();
863
+ closed = false;
864
+ constructor(context) {
865
+ this.context = context;
866
+ context.once("close", () => {
867
+ this.closed = true;
868
+ });
869
+ }
870
+ install() {
871
+ 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.");
872
+ return this.context.clock.install();
873
+ }
874
+ async capture(page, signal, capture, deadline = Date.now() + 5e3) {
875
+ const task = this.tail.then(async () => {
876
+ signal.throwIfAborted();
877
+ const animations = /* @__PURE__ */ new Map();
878
+ try {
879
+ await this.settleAnimations(page, animations, signal, deadline);
880
+ await this.pauseClock(page, signal);
881
+ signal.throwIfAborted();
882
+ for (let pass = 0; pass < 2; pass++) {
883
+ await this.sampleAnimations(page, animations, signal, false);
884
+ await this.waitForAnimationsReady(animations, signal);
885
+ }
886
+ return await capture();
887
+ } finally {
888
+ try {
889
+ await this.resumeAnimations(animations);
890
+ } finally {
891
+ if (!this.closed) await this.context.clock.resume();
892
+ }
893
+ }
894
+ });
895
+ this.tail = task.catch(() => void 0);
896
+ return task;
897
+ }
898
+ async settleAnimations(page, animations, signal, deadline) {
899
+ const now = Date.now();
900
+ const endsAt = now + Math.min(MAX_ANIMATION_SETTLE_MS, Math.max(0, (deadline - now) / 2));
901
+ let quietSince = now;
902
+ while (Date.now() < endsAt) {
903
+ if (await this.sampleAnimations(page, animations, signal, true)) quietSince = Date.now();
904
+ if (Date.now() - quietSince >= ANIMATION_QUIET_MS) return;
905
+ await setTimeout$1(Math.max(0, Math.min(50, endsAt - Date.now())), void 0, { signal }).catch((error) => {
906
+ signal.throwIfAborted();
907
+ throw error;
908
+ });
909
+ }
910
+ }
911
+ async sampleAnimations(page, animations, signal, finishFinite) {
912
+ let changed = false;
913
+ for (const frame of page.frames()) {
914
+ signal.throwIfAborted();
915
+ try {
916
+ let handle = animations.get(frame);
917
+ if (!handle) {
918
+ handle = await frame.evaluateHandle(observeAnimations);
919
+ animations.set(frame, handle);
920
+ }
921
+ changed = await handle.evaluate((state, finish) => state.sample(finish), finishFinite) || changed;
922
+ } catch (error) {
923
+ if (!frame.isDetached()) throw error;
924
+ }
925
+ }
926
+ signal.throwIfAborted();
927
+ return changed;
928
+ }
929
+ async waitForAnimationsReady(animations, signal) {
930
+ signal.throwIfAborted();
931
+ let onAbort;
932
+ const aborted = new Promise((_, reject) => {
933
+ onAbort = () => reject(signal.reason);
934
+ signal.addEventListener("abort", onAbort, { once: true });
935
+ });
936
+ try {
937
+ await Promise.race([Promise.all([...animations].map(async ([frame, handle]) => {
938
+ if (frame.isDetached()) return;
939
+ try {
940
+ await handle.evaluate((state) => state.ready());
941
+ } catch (error) {
942
+ if (!frame.isDetached()) throw error;
943
+ }
944
+ })), aborted]);
945
+ } finally {
946
+ signal.removeEventListener("abort", onAbort);
947
+ }
948
+ }
949
+ async resumeAnimations(animations) {
950
+ const restored = await Promise.allSettled([...animations.values()].map(async (handle) => {
951
+ try {
952
+ await handle.evaluate((state) => state.resume());
953
+ } catch (error) {
954
+ if (!destroyedContext(error)) throw error;
955
+ } finally {
956
+ await handle.dispose();
957
+ }
958
+ }));
959
+ for (const result of restored) if (result.status === "rejected") throw result.reason;
960
+ }
961
+ async pauseClock(page, signal) {
962
+ for (let attempt = 0;; attempt++) {
963
+ const time = await page.evaluate(() => Date.now());
964
+ signal.throwIfAborted();
965
+ try {
966
+ await this.context.clock.pauseAt(time + PAUSE_LEAD_MS);
967
+ return;
968
+ } catch (error) {
969
+ if (attempt > 0 || !(error instanceof Error) || !error.message.includes("Cannot fast-forward to the past")) throw error;
970
+ }
971
+ }
972
+ }
973
+ };
974
+ function destroyedContext(error) {
975
+ 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);
976
+ }
977
+ //#endregion
766
978
  //#region src/conditions.ts
767
979
  const declared = (declaration = {}) => {
768
980
  const conditions = {};
@@ -824,11 +1036,11 @@ function nodeKey(conditions) {
824
1036
  */
825
1037
  const WEBP_QUALITY = 80;
826
1038
  const WEBP_MAX_DIMENSION = 16383;
827
- async function takeScreenshot(page, deadline) {
1039
+ async function takeScreenshot(page, deadline, frozen = false) {
828
1040
  const options = {
829
1041
  fullPage: true,
830
1042
  scale: "css",
831
- animations: "disabled"
1043
+ animations: frozen ? "allow" : "disabled"
832
1044
  };
833
1045
  const webp = await page.screenshot({
834
1046
  ...options,
@@ -1057,22 +1269,30 @@ var Recorder = class {
1057
1269
  /** Starts recording URL changes in every current and future tab of `context`. */
1058
1270
  observe(context) {
1059
1271
  if (this.closed) throw new Error("The Ralph recorder has finished.");
1060
- if (this.contexts.has(context)) return;
1272
+ const existing = this.contexts.get(context);
1273
+ if (existing) return existing.ready;
1274
+ const freezer = this.options.freezeDuringCapture ? new CaptureFreezer(context) : void 0;
1275
+ const ready = freezer?.install() ?? Promise.resolve();
1061
1276
  const onPage = (page) => this.watchPage(page);
1062
- this.contexts.set(context, onPage);
1277
+ this.contexts.set(context, {
1278
+ onPage,
1279
+ ready,
1280
+ freezer
1281
+ });
1063
1282
  this.actions.observe(context);
1064
1283
  context.on("page", onPage);
1065
1284
  for (const page of context.pages()) onPage(page);
1285
+ return ready;
1066
1286
  }
1067
1287
  async recordNode(page, options) {
1068
- this.observe(page.context());
1288
+ await this.observe(page.context());
1069
1289
  const state = this.pages.get(page);
1070
1290
  if (!state) throw new Error("Cannot record a closed browser page.");
1071
1291
  if (this.paused) return;
1072
1292
  await this.enqueueCreateNodeRequest(state, "explicit", options);
1073
1293
  }
1074
1294
  async setNodeConditions(page, declaration) {
1075
- this.observe(page.context());
1295
+ await this.observe(page.context());
1076
1296
  const state = this.pages.get(page);
1077
1297
  if (!state) throw new Error("Cannot tag a closed browser page.");
1078
1298
  const key = Symbol();
@@ -1103,8 +1323,11 @@ var Recorder = class {
1103
1323
  }
1104
1324
  /** Stops watching `context` and waits for its queued recordings. */
1105
1325
  async stopContext(context) {
1106
- const onPage = this.contexts.get(context);
1107
- if (onPage) context.off("page", onPage);
1326
+ const observed = this.contexts.get(context);
1327
+ if (observed) {
1328
+ context.off("page", observed.onPage);
1329
+ await observed.ready;
1330
+ }
1108
1331
  const states = [...this.pages.values()].filter((state) => state.page.context() === context);
1109
1332
  for (const state of states) state.page.off("framenavigated", state.onNavigation);
1110
1333
  await Promise.all(states.map((state) => state.tail));
@@ -1157,6 +1380,7 @@ var Recorder = class {
1157
1380
  }
1158
1381
  }).catch(() => void 0);
1159
1382
  page.on("framenavigated", state.onNavigation);
1383
+ watchImageResponses(page);
1160
1384
  this.urlChanged(state);
1161
1385
  }
1162
1386
  urlChanged(state) {
@@ -1219,6 +1443,7 @@ var Recorder = class {
1219
1443
  };
1220
1444
  try {
1221
1445
  check();
1446
+ await wait(this.contexts.get(page.context()).ready);
1222
1447
  if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
1223
1448
  check();
1224
1449
  const conditions = this.declaredConditions(state);
@@ -1231,18 +1456,27 @@ var Recorder = class {
1231
1456
  warnedNoTracker = true;
1232
1457
  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.");
1233
1458
  }
1234
- if (this.options.reduceMotion) {
1459
+ const freezer = this.contexts.get(page.context()).freezer;
1460
+ if (this.options.reduceMotion && !freezer) {
1235
1461
  check();
1236
1462
  await wait(waitForAnimations(page, (deadline.endsAt - Date.now()) / 2));
1237
1463
  }
1238
- check();
1239
- const before = await wait(readLayout(page));
1240
- check();
1241
- const image = await wait(takeScreenshot(page, deadline.endsAt));
1242
- check();
1243
- const after = await wait(readLayout(page));
1244
- check();
1245
- const snapshot = await Promise.race([captureAriaSnapshot(page, after).catch(() => void 0), setTimeout$1(Math.max(0, deadline.endsAt - Date.now()), void 0, { ref: false })]);
1464
+ const capture = async () => {
1465
+ check();
1466
+ const before = await wait(readLayout(page));
1467
+ check();
1468
+ const image = await wait(takeScreenshot(page, deadline.endsAt, !!freezer));
1469
+ check();
1470
+ const after = await wait(readLayout(page));
1471
+ check();
1472
+ return {
1473
+ before,
1474
+ image,
1475
+ after,
1476
+ snapshot: await Promise.race([captureAriaSnapshot(page, after).catch(() => void 0), setTimeout$1(Math.max(0, deadline.endsAt - Date.now()), void 0, { ref: false })])
1477
+ };
1478
+ };
1479
+ const { before, image, after, snapshot } = freezer ? await freezer.capture(page, deadline.signal, capture, deadline.endsAt) : await capture();
1246
1480
  checkPage();
1247
1481
  const recorded = {
1248
1482
  ...request,
@@ -1673,4 +1907,4 @@ async function responseDetail(response) {
1673
1907
  //#endregion
1674
1908
  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 };
1675
1909
 
1676
- //# sourceMappingURL=upload-CEc_qo01.mjs.map
1910
+ //# sourceMappingURL=upload-jup_jLlm.mjs.map