@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.
- package/README.md +44 -1
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +9 -1
- package/dist/index.mjs +3 -3
- package/dist/index.mjs.map +1 -1
- package/dist/reporter.cjs +1 -1
- package/dist/reporter.mjs +1 -1
- package/dist/{upload-CRXt3vt1.cjs → upload-D40MqSQa.cjs} +267 -33
- package/dist/upload-D40MqSQa.cjs.map +1 -0
- package/dist/{upload-CEc_qo01.mjs → upload-jup_jLlm.mjs} +267 -33
- package/dist/upload-jup_jLlm.mjs.map +1 -0
- package/package.json +1 -1
- package/dist/upload-CEc_qo01.mjs.map +0 -1
- package/dist/upload-CRXt3vt1.cjs.map +0 -1
|
@@ -70,7 +70,7 @@ function snapshot(value) {
|
|
|
70
70
|
}
|
|
71
71
|
//#endregion
|
|
72
72
|
//#region package.json
|
|
73
|
-
var version = "2.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
1132
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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-
|
|
2042
|
+
//# sourceMappingURL=upload-D40MqSQa.cjs.map
|