@ralphralphai/playwright 2.4.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.4.0";
48
+ var version = "2.5.0";
49
49
  //#endregion
50
50
  //#region src/actions.ts
51
51
  const BINDING = "__ralphAction";
@@ -703,6 +703,8 @@ function keepShown(node, viewportWidth) {
703
703
  return kept === void 0 ? [] : [kept];
704
704
  });
705
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;
706
708
  return node.children === void 0 ? node : {
707
709
  ...node,
708
710
  children
@@ -800,6 +802,179 @@ async function describe(page, child, inherited, insideDynamic) {
800
802
  };
801
803
  }
802
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
803
978
  //#region src/conditions.ts
804
979
  const declared = (declaration = {}) => {
805
980
  const conditions = {};
@@ -861,11 +1036,11 @@ function nodeKey(conditions) {
861
1036
  */
862
1037
  const WEBP_QUALITY = 80;
863
1038
  const WEBP_MAX_DIMENSION = 16383;
864
- async function takeScreenshot(page, deadline) {
1039
+ async function takeScreenshot(page, deadline, frozen = false) {
865
1040
  const options = {
866
1041
  fullPage: true,
867
1042
  scale: "css",
868
- animations: "disabled"
1043
+ animations: frozen ? "allow" : "disabled"
869
1044
  };
870
1045
  const webp = await page.screenshot({
871
1046
  ...options,
@@ -1094,22 +1269,30 @@ var Recorder = class {
1094
1269
  /** Starts recording URL changes in every current and future tab of `context`. */
1095
1270
  observe(context) {
1096
1271
  if (this.closed) throw new Error("The Ralph recorder has finished.");
1097
- 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();
1098
1276
  const onPage = (page) => this.watchPage(page);
1099
- this.contexts.set(context, onPage);
1277
+ this.contexts.set(context, {
1278
+ onPage,
1279
+ ready,
1280
+ freezer
1281
+ });
1100
1282
  this.actions.observe(context);
1101
1283
  context.on("page", onPage);
1102
1284
  for (const page of context.pages()) onPage(page);
1285
+ return ready;
1103
1286
  }
1104
1287
  async recordNode(page, options) {
1105
- this.observe(page.context());
1288
+ await this.observe(page.context());
1106
1289
  const state = this.pages.get(page);
1107
1290
  if (!state) throw new Error("Cannot record a closed browser page.");
1108
1291
  if (this.paused) return;
1109
1292
  await this.enqueueCreateNodeRequest(state, "explicit", options);
1110
1293
  }
1111
1294
  async setNodeConditions(page, declaration) {
1112
- this.observe(page.context());
1295
+ await this.observe(page.context());
1113
1296
  const state = this.pages.get(page);
1114
1297
  if (!state) throw new Error("Cannot tag a closed browser page.");
1115
1298
  const key = Symbol();
@@ -1140,8 +1323,11 @@ var Recorder = class {
1140
1323
  }
1141
1324
  /** Stops watching `context` and waits for its queued recordings. */
1142
1325
  async stopContext(context) {
1143
- const onPage = this.contexts.get(context);
1144
- 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
+ }
1145
1331
  const states = [...this.pages.values()].filter((state) => state.page.context() === context);
1146
1332
  for (const state of states) state.page.off("framenavigated", state.onNavigation);
1147
1333
  await Promise.all(states.map((state) => state.tail));
@@ -1257,6 +1443,7 @@ var Recorder = class {
1257
1443
  };
1258
1444
  try {
1259
1445
  check();
1446
+ await wait(this.contexts.get(page.context()).ready);
1260
1447
  if (request.trigger !== "explicit") await this.waitForPageToSettle(page, deadline, check);
1261
1448
  check();
1262
1449
  const conditions = this.declaredConditions(state);
@@ -1269,18 +1456,27 @@ var Recorder = class {
1269
1456
  warnedNoTracker = true;
1270
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.");
1271
1458
  }
1272
- if (this.options.reduceMotion) {
1459
+ const freezer = this.contexts.get(page.context()).freezer;
1460
+ if (this.options.reduceMotion && !freezer) {
1273
1461
  check();
1274
1462
  await wait(waitForAnimations(page, (deadline.endsAt - Date.now()) / 2));
1275
1463
  }
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 })]);
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();
1284
1480
  checkPage();
1285
1481
  const recorded = {
1286
1482
  ...request,
@@ -1711,4 +1907,4 @@ async function responseDetail(response) {
1711
1907
  //#endregion
1712
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 };
1713
1909
 
1714
- //# sourceMappingURL=upload-CUtC_rnN.mjs.map
1910
+ //# sourceMappingURL=upload-jup_jLlm.mjs.map