@manufac/general-tests 0.5.1 → 0.9.1

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.
Files changed (62) hide show
  1. package/README.md +88 -0
  2. package/dist/config/projects.d.ts +3 -1
  3. package/dist/config/projects.js +4 -2
  4. package/dist/fixtures/index.d.ts +2 -1
  5. package/dist/fixtures/index.js +1 -0
  6. package/dist/schema/index.d.ts +13 -0
  7. package/dist/schema/index.js +7 -0
  8. package/dist/specs/cta-destinations.spec.js +65 -0
  9. package/dist/specs/page-visual-regression.spec.js +68 -0
  10. package/dist/specs/{visual-regression.spec.js → storybook-visual-regression.spec.js} +14 -1
  11. package/dist/specs/utils.d.ts +4 -158
  12. package/dist/specs/utils.js +27 -856
  13. package/package.json +1 -1
  14. package/dist/specs/desktop-only/browser-zoom.spec.js +0 -26
  15. package/dist/specs/mobile-only/ios-safari-toolbar.spec.js +0 -62
  16. package/dist/specs/mobile-only/ios-safe-areas.spec.js +0 -36
  17. package/dist/specs/mobile-only/navigation.spec.d.ts +0 -1
  18. package/dist/specs/mobile-only/navigation.spec.js +0 -40
  19. package/dist/specs/mobile-only/orientation.spec.d.ts +0 -1
  20. package/dist/specs/mobile-only/orientation.spec.js +0 -46
  21. package/dist/specs/mobile-only/readability.spec.d.ts +0 -1
  22. package/dist/specs/mobile-only/readability.spec.js +0 -14
  23. package/dist/specs/mobile-only/soft-keyboard.spec.d.ts +0 -1
  24. package/dist/specs/mobile-only/soft-keyboard.spec.js +0 -43
  25. package/dist/specs/mobile-only/touch-targets.spec.d.ts +0 -1
  26. package/dist/specs/mobile-only/touch-targets.spec.js +0 -12
  27. package/dist/specs/shared/cta.spec.d.ts +0 -1
  28. package/dist/specs/shared/cta.spec.js +0 -199
  29. package/dist/specs/shared/forms/autofill.spec.d.ts +0 -1
  30. package/dist/specs/shared/forms/autofill.spec.js +0 -28
  31. package/dist/specs/shared/forms/double-submit.spec.d.ts +0 -1
  32. package/dist/specs/shared/forms/double-submit.spec.js +0 -25
  33. package/dist/specs/shared/forms/input-edge.spec.d.ts +0 -1
  34. package/dist/specs/shared/forms/input-edge.spec.js +0 -60
  35. package/dist/specs/shared/forms/invalid-input.spec.d.ts +0 -1
  36. package/dist/specs/shared/forms/invalid-input.spec.js +0 -41
  37. package/dist/specs/shared/forms/native-inputs.spec.d.ts +0 -1
  38. package/dist/specs/shared/forms/native-inputs.spec.js +0 -37
  39. package/dist/specs/shared/forms/required-field.spec.d.ts +0 -1
  40. package/dist/specs/shared/forms/required-field.spec.js +0 -39
  41. package/dist/specs/shared/forms/thankyou-confirm.spec.d.ts +0 -1
  42. package/dist/specs/shared/forms/thankyou-confirm.spec.js +0 -34
  43. package/dist/specs/shared/hero-fold.spec.d.ts +0 -1
  44. package/dist/specs/shared/hero-fold.spec.js +0 -40
  45. package/dist/specs/shared/image-quality.spec.d.ts +0 -1
  46. package/dist/specs/shared/image-quality.spec.js +0 -13
  47. package/dist/specs/shared/media-embeds/carousel-slider.spec.d.ts +0 -1
  48. package/dist/specs/shared/media-embeds/carousel-slider.spec.js +0 -31
  49. package/dist/specs/shared/media-embeds/cookie-consent.spec.d.ts +0 -1
  50. package/dist/specs/shared/media-embeds/cookie-consent.spec.js +0 -27
  51. package/dist/specs/shared/media-embeds/third-party-embeds.spec.d.ts +0 -1
  52. package/dist/specs/shared/media-embeds/third-party-embeds.spec.js +0 -27
  53. package/dist/specs/shared/media-embeds/video-playback.spec.d.ts +0 -1
  54. package/dist/specs/shared/media-embeds/video-playback.spec.js +0 -54
  55. package/dist/specs/shared/sticky-elements.spec.d.ts +0 -1
  56. package/dist/specs/shared/sticky-elements.spec.js +0 -13
  57. package/dist/specs/sticky-utils.d.ts +0 -8
  58. package/dist/specs/sticky-utils.js +0 -175
  59. package/dist/specs/visual-regression.spec.d.ts +0 -1
  60. /package/dist/specs/{desktop-only/browser-zoom.spec.d.ts → cta-destinations.spec.d.ts} +0 -0
  61. /package/dist/specs/{mobile-only/ios-safari-toolbar.spec.d.ts → page-visual-regression.spec.d.ts} +0 -0
  62. /package/dist/specs/{mobile-only/ios-safe-areas.spec.d.ts → storybook-visual-regression.spec.d.ts} +0 -0
@@ -1,863 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SafeAreaZones = exports.KeyboardFields = exports.AutofillFields = exports.InvalidInputCases = exports.FieldValueMappings = exports.FieldInteractions = exports.ValidUserInfo = exports.ContentOverlapSelector = exports.TouchTargetSizeSelector = exports.TappableElementsSelector = exports.ResponsiveConfig = void 0;
4
- exports.describeElement = describeElement;
5
- exports.getScrollMetrics = getScrollMetrics;
6
- exports.scrollThroughPage = scrollThroughPage;
7
- exports.findTruncatedOrOverflowingText = findTruncatedOrOverflowingText;
8
- exports.findOverlappingElements = findOverlappingElements;
9
- exports.findImageSizeIssues = findImageSizeIssues;
10
- exports.isImageUpscaled = isImageUpscaled;
11
- exports.isImageOversized = isImageOversized;
12
- exports.isCutoffAtViewportEdge = isCutoffAtViewportEdge;
13
- exports.findElementsCutoffAtViewportEdge = findElementsCutoffAtViewportEdge;
14
- exports.findUndersizedBodyText = findUndersizedBodyText;
15
- exports.findUndersizedTouchTargets = findUndersizedTouchTargets;
16
- exports.hasHorizontalScrollbar = hasHorizontalScrollbar;
17
- exports.isVideoAutoplaying = isVideoAutoplaying;
18
- exports.isVideoMuted = isVideoMuted;
19
- exports.isFieldRequired = isFieldRequired;
20
- exports.fillDateTimePicker = fillDateTimePicker;
21
- exports.fillFormFields = fillFormFields;
22
- exports.hasVisibleErrorNearField = hasVisibleErrorNearField;
23
- exports.fillOtherRequiredFieldsValidly = fillOtherRequiredFieldsValidly;
24
- exports.getActiveSlideIndex = getActiveSlideIndex;
25
- exports.getTotalSlideCount = getTotalSlideCount;
26
- exports.hasEmptySlideFrame = hasEmptySlideFrame;
27
- exports.goToNextSlideViaArrow = goToNextSlideViaArrow;
28
- exports.getDotCount = getDotCount;
29
- exports.goToSlideViaDot = goToSlideViaDot;
30
- exports.swipeToNextSlide = swipeToNextSlide;
31
- exports.swipeToPreviousSlide = swipeToPreviousSlide;
32
- exports.traverseSlidesForward = traverseSlidesForward;
33
- exports.checkSwipeBackward = checkSwipeBackward;
34
- exports.checkAllDots = checkAllDots;
35
- exports.findSafeAreaOverlaps = findSafeAreaOverlaps;
36
3
  exports.fillField = fillField;
37
4
  exports.hasNativeValidationError = hasNativeValidationError;
5
+ exports.toSnapshotName = toSnapshotName;
6
+ exports.hasHorizontalScrollbar = hasHorizontalScrollbar;
7
+ exports.scrollThroughPage = scrollThroughPage;
8
+ const schema_1 = require("../schema");
38
9
  const test_1 = require("@playwright/test");
39
- exports.ResponsiveConfig = {
40
- ScrollSteps: 10,
41
- ScrollStepDelayMs: 100,
42
- ViewPortWidth: 1440,
43
- ViewPortHeight: 900,
44
- ZoomFactor: 2,
45
- ZoomSettleDelayMs: 300,
46
- UpscaleTolerance: 1.05,
47
- OversizeTolerance: 2,
48
- ScrollDriftTolerance: 0.15,
49
- MinBodyTextFontSizePX: 12,
50
- PinFlickerTolerancePX: 2,
51
- MinTouchTargetSizePX: 44,
52
- KeyboardOcclusionRatio: 0.4,
53
- CutoffTolerancePX: 5,
54
- };
55
- // Formats a descriptor captured inside page.evaluate (see findElementsCutoffAtViewportEdge and
56
- // findUndersizedBodyText) into a readable tag selector, e.g. <p.hero.subtitle>.
57
- function describeElement(descriptor) {
58
- const rawClassName = descriptor.className.trim();
59
- const className = rawClassName.length > 0 ? `.${rawClassName.replace(/\s+/g, ".")}` : "";
60
- return `<${descriptor.tagName.toLowerCase()}${className}>`;
61
- }
62
- /**
63
- * References for summary and details elements:
64
- * - https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary
65
- * - https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/details
66
- */
67
- const TappableElementsList = [
68
- "a",
69
- "button",
70
- "select",
71
- "summary",
72
- 'input[type="submit"]',
73
- 'input[type="button"]',
74
- 'input[type="reset"]',
75
- 'input[type="checkbox"]',
76
- 'input[type="radio"]',
77
- "label[for]",
78
- '[role="button"]',
79
- '[role="link"]',
80
- '[role="tab"]',
81
- '[role="menuitem"]',
82
- '[role="option"]',
83
- '[role="switch"]',
84
- '[role="checkbox"]',
85
- '[role="radio"]',
86
- '[tabindex="0"]',
87
- ];
88
- exports.TappableElementsSelector = TappableElementsList.join(", ");
89
- // Native checkbox/radio inputs render at a small, fixed OS size regardless of page styling — their
90
- // real tappable target is normally the paired <label for="...">, already covered separately here.
91
- // Excluded from the size check (but not the overlap check) so a standard HTML form doesn't fail
92
- // on every single page just for using unstyled checkboxes/radios.
93
- const SizeExemptTappableSelectors = new Set(['input[type="checkbox"]', 'input[type="radio"]']);
94
- exports.TouchTargetSizeSelector = TappableElementsList.filter((selector) => {
95
- return !SizeExemptTappableSelectors.has(selector);
96
- }).join(", ");
97
- async function getScrollMetrics(page) {
98
- return page.evaluate(() => {
99
- return {
100
- scrollHeight: document.documentElement.scrollHeight,
101
- scrollY: window.scrollY,
102
- clientHeight: document.documentElement.clientHeight,
103
- };
104
- });
105
- }
106
- async function scrollThroughPage(page) {
107
- const { scrollHeight } = await getScrollMetrics(page);
108
- const scrollStepDelta = Math.floor(scrollHeight / exports.ResponsiveConfig.ScrollSteps);
109
- for (let step = 0; step <= exports.ResponsiveConfig.ScrollSteps; step++) {
110
- await page.mouse.wheel(0, scrollStepDelta);
111
- await page.waitForTimeout(exports.ResponsiveConfig.ScrollStepDelayMs);
112
- }
113
- }
114
- async function findTruncatedOrOverflowingText(page) {
115
- return page.evaluate(() => {
116
- const broken = [];
117
- const elements = Array.from(document.querySelectorAll("p, li, td, th, dt, dd, blockquote, figcaption, h1, h2, h3, h4, h5, h6"));
118
- for (const element of elements) {
119
- const text = element.textContent.trim();
120
- if (text.length > 0) {
121
- const style = window.getComputedStyle(element);
122
- const isEllipsisTruncated = style.textOverflow === "ellipsis" && style.overflow !== "visible";
123
- const isOverflowingHorizontally = element.scrollWidth > element.clientWidth + 1;
124
- // Ellipsis truncation is a deliberate, visually-indicated design pattern (card previews,
125
- // single-line labels) — not a bug. Only flag genuinely broken cases: text overflowing
126
- // WITHOUT any truncation handling (spilling out unstyled), which is always unintentional.
127
- const isUnhandledOverflow = isOverflowingHorizontally && !isEllipsisTruncated;
128
- if (isUnhandledOverflow) {
129
- broken.push(`<${element.tagName.toLowerCase()} class="${element.className}"> overflow=${style.overflow}, textOverflow=${style.textOverflow}: "${text.slice(0, 40)}"`);
130
- }
131
- }
132
- }
133
- return broken;
134
- });
135
- }
136
- async function findOverlappingElements(page, containerSelector) {
137
- const overlaps = await page.evaluate((selector) => {
138
- function isVisibleInViewport(element) {
139
- const rect = element.getBoundingClientRect();
140
- const hasDimensions = rect.width > 0 && rect.height > 0;
141
- const horizontalOverlap = Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0);
142
- const verticalOverlap = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
143
- return hasDimensions && horizontalOverlap > 0 && verticalOverlap > 0;
144
- }
145
- // An element clipped by an ancestor's overflow:hidden/scroll/auto (e.g. a closed mobile
146
- // drawer/menu duplicate) is intentionally hidden from view — not a real visual overlap.
147
- // Defined inside this evaluate callback (rather than at module scope) since page.evaluate
148
- // serializes only the callback itself — outer Node-side function references aren't
149
- // available once this runs in the browser.
150
- function isClippedByAncestorOverflow(element) {
151
- const rect = element.getBoundingClientRect();
152
- let parent = element.parentElement;
153
- let isClipped = false;
154
- while (parent !== null && !isClipped) {
155
- const parentStyle = window.getComputedStyle(parent);
156
- const isOverflowClipped = ["hidden", "scroll", "auto"].includes(parentStyle.overflowX) ||
157
- ["hidden", "scroll", "auto"].includes(parentStyle.overflowY);
158
- if (isOverflowClipped) {
159
- const parentRect = parent.getBoundingClientRect();
160
- const isOutsideParentBounds = rect.right <= parentRect.left ||
161
- rect.left >= parentRect.right ||
162
- rect.bottom <= parentRect.top ||
163
- rect.top >= parentRect.bottom;
164
- if (isOutsideParentBounds) {
165
- isClipped = true;
166
- }
167
- }
168
- parent = parent.parentElement;
169
- }
170
- return isClipped;
171
- }
172
- const conflicts = [];
173
- const elements = Array.from(document.querySelectorAll(selector))
174
- .filter(isVisibleInViewport)
175
- .filter((element) => {
176
- return !isClippedByAncestorOverflow(element);
177
- });
178
- // Checks the element itself and its ancestors: a fixed/sticky container's own children are
179
- // normally position:static/relative, but they still visually pin to the viewport along with
180
- // it, so the pinning has to be detected up the ancestor chain, not just on the element itself.
181
- function isPinnedToViewport(element) {
182
- let current = element;
183
- let isPinned = false;
184
- while (current !== null && !isPinned) {
185
- const { position } = window.getComputedStyle(current);
186
- isPinned = position === "fixed" || position === "sticky";
187
- current = current.parentElement;
188
- }
189
- return isPinned;
190
- }
191
- for (let i = 0; i < elements.length; i++) {
192
- for (let index = i + 1; index < elements.length; index++) {
193
- const a = elements[i];
194
- const b = elements[index];
195
- const isNested = a.contains(b) || b.contains(a);
196
- // A fixed/sticky element (or something inside one) sitting over whatever content has
197
- // scrolled beneath it is expected, inherent behavior — not a layout bug. Only flag
198
- // overlaps between two elements that are both in normal flow (or both pinned, which
199
- // would indicate a real stacking conflict between two pinned elements).
200
- const isExpectedPinnedOverlap = isPinnedToViewport(a) !== isPinnedToViewport(b);
201
- if (!isNested && !isExpectedPinnedOverlap) {
202
- const rectA = a.getBoundingClientRect();
203
- const rectB = b.getBoundingClientRect();
204
- const overlapWidth = Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left);
205
- const overlapHeight = Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top);
206
- if (overlapWidth > 0 && overlapHeight > 0) {
207
- conflicts.push({
208
- elementA: { tagName: a.tagName, className: a.className },
209
- elementB: { tagName: b.tagName, className: b.className },
210
- overlapWidth,
211
- overlapHeight,
212
- });
213
- }
214
- }
215
- }
216
- }
217
- return conflicts;
218
- }, containerSelector);
219
- return overlaps.map((overlap) => {
220
- return `${overlap.elementA.tagName} overlaps ${overlap.elementB.tagName} by ${overlap.overlapWidth.toFixed(0)}x${overlap.overlapHeight.toFixed(0)}px`;
221
- });
222
- }
223
- // Content elements worth checking for overlap after a layout-affecting change (e.g. orientation
224
- // change): tappable elements, text-bearing elements, and images.
225
- exports.ContentOverlapSelector = [
226
- exports.TappableElementsSelector,
227
- "p",
228
- "li",
229
- "td",
230
- "th",
231
- "dt",
232
- "dd",
233
- "blockquote",
234
- "figcaption",
235
- "h1",
236
- "h2",
237
- "h3",
238
- "h4",
239
- "h5",
240
- "h6",
241
- "img",
242
- ].join(", ");
243
- async function findImageSizeIssues(page) {
244
- return page.evaluate(() => {
245
- const images = Array.from(document.querySelectorAll("img"));
246
- const issues = images
247
- .map((image) => {
248
- let issue = null;
249
- const rect = image.getBoundingClientRect();
250
- const isRendered = rect.width > 0 && rect.height > 0;
251
- const hasNaturalSize = image.naturalWidth > 0 && image.naturalHeight > 0;
252
- if (isRendered && hasNaturalSize) {
253
- issue = {
254
- src: image.currentSrc,
255
- naturalWidth: image.naturalWidth,
256
- naturalHeight: image.naturalHeight,
257
- renderedWidth: rect.width,
258
- renderedHeight: rect.height,
259
- devicePixelRatio: window.devicePixelRatio,
260
- };
261
- }
262
- return issue;
263
- })
264
- .filter((entry) => {
265
- return entry !== null;
266
- });
267
- return issues;
268
- });
269
- }
270
- function isImageUpscaled(image) {
271
- const isWidthUpscaled = image.renderedWidth * image.devicePixelRatio > image.naturalWidth * exports.ResponsiveConfig.UpscaleTolerance;
272
- const isHeightUpscaled = image.renderedHeight * image.devicePixelRatio > image.naturalHeight * exports.ResponsiveConfig.UpscaleTolerance;
273
- return isWidthUpscaled || isHeightUpscaled;
274
- }
275
- function isImageOversized(image) {
276
- const isWidthOversized = image.naturalWidth > image.renderedWidth * image.devicePixelRatio * exports.ResponsiveConfig.OversizeTolerance;
277
- const isHeightOversized = image.naturalHeight > image.renderedHeight * image.devicePixelRatio * exports.ResponsiveConfig.OversizeTolerance;
278
- return isWidthOversized || isHeightOversized;
279
- }
280
- // Cut off means straddling the viewport edge (partially visible), not positioned entirely
281
- // outside it — off-canvas nav drawers, upcoming carousel slides, and visually-hidden
282
- // screen-reader text are intentionally placed fully outside the viewport and are not bugs.
283
- // Vertical straddling is only flagged for fixed/sticky elements: normal in-flow content
284
- // straddles the top/bottom edge constantly while scrolling, which is expected, not a bug.
285
- // Pure geometry (no DOM APIs), so it runs in Node against a rect snapshot taken in the browser.
286
- function isCutoffAtViewportEdge(snapshot, viewport) {
287
- const { rect } = snapshot;
288
- const hasDimensions = rect.width > 0 && rect.height > 0;
289
- const horizontalOverlap = Math.min(rect.right, viewport.width) - Math.max(rect.left, 0);
290
- const isHorizontallyClipped = horizontalOverlap > 0 &&
291
- (rect.left < -exports.ResponsiveConfig.CutoffTolerancePX ||
292
- rect.right > viewport.width + exports.ResponsiveConfig.CutoffTolerancePX);
293
- const verticalOverlap = Math.min(rect.bottom, viewport.height) - Math.max(rect.top, 0);
294
- const isVerticallyClipped = snapshot.isFixedOrSticky &&
295
- verticalOverlap > 0 &&
296
- (rect.top < -exports.ResponsiveConfig.CutoffTolerancePX ||
297
- rect.bottom > viewport.height + exports.ResponsiveConfig.CutoffTolerancePX);
298
- return hasDimensions && (isHorizontallyClipped || isVerticallyClipped);
299
- }
300
- async function findElementsCutoffAtViewportEdge(page, containerSelector) {
301
- const { elements, viewport } = await page.evaluate((selector) => {
302
- function isRenderedElement(element) {
303
- const style = window.getComputedStyle(element);
304
- return style.display !== "none" && style.visibility !== "hidden" && Number.parseFloat(style.opacity) !== 0;
305
- }
306
- // An element clipped by an ancestor's overflow:hidden/scroll/auto (e.g. a closed
307
- // drawer/menu duplicate) is intentionally hidden from view — not a real cutoff.
308
- // Defined inside this evaluate callback (rather than at module scope) since page.evaluate
309
- // serializes only the callback itself — outer Node-side function references aren't
310
- // available once this runs in the browser.
311
- function isClippedByAncestorOverflow(element) {
312
- const rect = element.getBoundingClientRect();
313
- let parent = element.parentElement;
314
- let isClipped = false;
315
- while (parent !== null && !isClipped) {
316
- const parentStyle = window.getComputedStyle(parent);
317
- const isOverflowClipped = ["hidden", "scroll", "auto"].includes(parentStyle.overflowX) ||
318
- ["hidden", "scroll", "auto"].includes(parentStyle.overflowY);
319
- if (isOverflowClipped) {
320
- const parentRect = parent.getBoundingClientRect();
321
- const isOutsideParentBounds = rect.right <= parentRect.left ||
322
- rect.left >= parentRect.right ||
323
- rect.bottom <= parentRect.top ||
324
- rect.top >= parentRect.bottom;
325
- if (isOutsideParentBounds) {
326
- isClipped = true;
327
- }
328
- }
329
- parent = parent.parentElement;
330
- }
331
- return isClipped;
332
- }
333
- const renderedElements = Array.from(document.querySelectorAll(selector ?? "body *"))
334
- .filter(isRenderedElement)
335
- .filter((element) => {
336
- return !isClippedByAncestorOverflow(element);
337
- })
338
- .map((element) => {
339
- const rect = element.getBoundingClientRect();
340
- const style = window.getComputedStyle(element);
341
- return {
342
- tagName: element.tagName,
343
- className: element instanceof HTMLElement ? element.className : "",
344
- rect: {
345
- left: rect.left,
346
- right: rect.right,
347
- top: rect.top,
348
- bottom: rect.bottom,
349
- width: rect.width,
350
- height: rect.height,
351
- },
352
- isFixedOrSticky: style.position === "fixed" || style.position === "sticky",
353
- };
354
- });
355
- return { elements: renderedElements, viewport: { width: window.innerWidth, height: window.innerHeight } };
356
- }, containerSelector);
357
- return elements
358
- .filter((element) => {
359
- return isCutoffAtViewportEdge(element, viewport);
360
- })
361
- .map(describeElement);
362
- }
363
- // Returns a descriptor (not the element itself) rather than returning it directly: page.evaluate's
364
- // return value is JSON-serialized across the browser/Node boundary, and a DOM element has no own
365
- // enumerable properties, so it would come back as an empty object instead of a usable reference.
366
- async function findUndersizedBodyText(page) {
367
- const undersizedElements = await page.evaluate((minFontSizePX) => {
368
- const elements = Array.from(document.querySelectorAll("p, li, td, th, dt, dd, blockquote, figcaption"));
369
- return elements.reduce((undersized, element) => {
370
- const text = element.textContent.trim();
371
- const fontSizePX = Number.parseFloat(window.getComputedStyle(element).fontSize);
372
- if (text.length > 0 && fontSizePX < minFontSizePX) {
373
- undersized.push({ tagName: element.tagName, className: element.className, fontSizePX });
374
- }
375
- return undersized;
376
- }, []);
377
- }, exports.ResponsiveConfig.MinBodyTextFontSizePX);
378
- return undersizedElements.map((element) => {
379
- return `${describeElement(element)} (${String(element.fontSizePX)}px)`;
380
- });
381
- }
382
- async function findUndersizedTouchTargets(page, containerSelector) {
383
- const undersizedElements = await page.evaluate(({ selector, minSizePX }) => {
384
- function isRendered(element) {
385
- const style = window.getComputedStyle(element);
386
- return style.display !== "none" && style.visibility !== "hidden" && Number.parseFloat(style.opacity) !== 0;
387
- }
388
- return Array.from(document.querySelectorAll(selector))
389
- .filter(isRendered)
390
- .map((element) => {
391
- const rect = element.getBoundingClientRect();
392
- return {
393
- tagName: element.tagName,
394
- className: element.className,
395
- width: rect.width,
396
- height: rect.height,
397
- text: element.textContent.trim().slice(0, 40),
398
- };
399
- })
400
- .filter((entry) => {
401
- const hasDimensions = entry.width > 0 && entry.height > 0;
402
- return hasDimensions && (entry.width < minSizePX || entry.height < minSizePX);
403
- });
404
- }, { selector: containerSelector, minSizePX: exports.ResponsiveConfig.MinTouchTargetSizePX });
405
- return undersizedElements.map((element) => {
406
- return `${describeElement(element)} is ${element.width.toFixed(0)}x${element.height.toFixed(0)}px: "${element.text}"`;
407
- });
408
- }
409
- async function hasHorizontalScrollbar(page) {
410
- return page.evaluate(() => {
411
- return document.documentElement.scrollWidth > document.documentElement.clientWidth;
412
- });
413
- }
414
- async function isVideoAutoplaying(video) {
415
- const isPaused = await video.evaluate((element) => {
416
- return element.paused;
417
- });
418
- return !isPaused;
419
- }
420
- async function isVideoMuted(video) {
421
- return video.evaluate((element) => {
422
- return element.muted;
423
- });
424
- }
425
- exports.ValidUserInfo = {
426
- ValidTestName: "Jane Doe",
427
- ValidTestEmail: "test@test.com",
428
- ValidTestPhone: "9876543210",
429
- ValidTestCompany: "Acme Corp",
430
- ValidTestProjectDescription: "This is a sample project description used for automated contact form testing.",
431
- ValidTestPreferredDate: "2026-09-15",
432
- ValidTestPreferredTime: "10:00 AM",
433
- LongNameCharacterCount: 250,
434
- EmojiTestName: "Jane 😀 Doe",
435
- PaddedTestEmail: " test@test.com ",
436
- InvalidTestEmail: "not-an-email",
437
- };
438
- async function isFieldRequired(fieldLocator) {
439
- const requiredAttribute = await fieldLocator.getAttribute("required");
440
- const ariaRequired = await fieldLocator.getAttribute("aria-required");
441
- const isMarkedRequired = requiredAttribute !== null || ariaRequired === "true";
442
- return isMarkedRequired;
443
- }
444
- exports.FieldInteractions = {
445
- Fill: "fill",
446
- Datetime: "datetime",
447
- };
448
- /**
449
- * Maps a detected field (by its label) to the valid test value used
450
- * to fill it. Presence here does NOT imply the field is required —
451
- * required-ness is determined dynamically per field via
452
- * `isFieldRequired`. Email is the only field that is always required.
453
- */
454
- exports.FieldValueMappings = [
455
- { label: /name/i, validValue: exports.ValidUserInfo.ValidTestName, interaction: exports.FieldInteractions.Fill },
456
- { label: /email/i, validValue: exports.ValidUserInfo.ValidTestEmail, interaction: exports.FieldInteractions.Fill },
457
- { label: /phone/i, validValue: exports.ValidUserInfo.ValidTestPhone, interaction: exports.FieldInteractions.Fill },
458
- { label: /company/i, validValue: exports.ValidUserInfo.ValidTestCompany, interaction: exports.FieldInteractions.Fill },
459
- {
460
- label: /project|description/i,
461
- validValue: exports.ValidUserInfo.ValidTestProjectDescription,
462
- interaction: exports.FieldInteractions.Fill,
463
- },
464
- { label: /date/i, validValue: exports.ValidUserInfo.ValidTestPreferredDate, interaction: exports.FieldInteractions.Datetime },
465
- { label: /time/i, validValue: exports.ValidUserInfo.ValidTestPreferredTime, interaction: exports.FieldInteractions.Datetime },
466
- ];
467
- // Some date/time fields are plain inputs; others are picker widgets that open a popup and
468
- // reject typed text. Tries .fill() first, falling back to ARIA roles (grid/gridcell, spinbutton)
469
- // if the value didn't take, since apps vary in UI library. No-ops if nothing matches.
470
- //
471
- // Mantine's DateTimePicker doesn't use role=gridcell for its calendar days (just plain <button>
472
- // elements labeled by day number), so the day selector also falls back to any enabled button
473
- // with a 1-2 digit label.
474
- async function fillDateTimePicker(locator, fallbackValue) {
475
- await locator.fill(fallbackValue).catch(() => {
476
- return undefined;
477
- });
478
- const filledValue = await locator.inputValue().catch(() => {
479
- return "";
480
- });
481
- if (filledValue.length === 0) {
482
- await locator.click();
483
- const page = locator.page();
484
- // Give the popup a moment to finish laying out before interacting — clicking immediately
485
- // can hit a day cell while another control (e.g. an AM/PM toggle) is still animating into
486
- // place on top of it, intercepting the click.
487
- await page.waitForTimeout(300);
488
- // Scope the numeric-button fallback to an open popup/dialog when one exists, rather than the
489
- // whole page, so it can't accidentally match an unrelated numeric button elsewhere (e.g. a
490
- // pagination control or step counter).
491
- const popup = page.locator('[role="dialog"], [role="listbox"], [aria-modal="true"]').first();
492
- const hasPopup = (await popup.count()) > 0;
493
- const numericDayButtons = (hasPopup ? popup : page)
494
- .locator("button:not([disabled]):not([aria-disabled='true'])")
495
- .filter({ hasText: /^\d{1,2}$/ });
496
- const dayCells = page.locator('[role="gridcell"]:not([aria-disabled="true"])').or(numericDayButtons);
497
- const dayCellCount = await dayCells.count();
498
- for (let index = 0; index < dayCellCount; index++) {
499
- const cell = dayCells.nth(index);
500
- if (await cell.isVisible()) {
501
- await cell.click();
502
- await page.waitForTimeout(300);
503
- break;
504
- }
505
- }
506
- const spinbuttons = page.locator('[role="spinbutton"]');
507
- const spinbuttonCount = await spinbuttons.count();
508
- for (let index = 0; index < spinbuttonCount; index++) {
509
- const button = spinbuttons.nth(index);
510
- // Guards against the element detaching mid-animation, same as the day-cell loop above.
511
- const isStillPresent = await button.isVisible().catch(() => {
512
- return false;
513
- });
514
- if (isStillPresent) {
515
- await button.click();
516
- await page.keyboard.press("ArrowUp");
517
- await page.waitForTimeout(100);
518
- }
519
- }
520
- const confirmButton = page.getByRole("button", { name: /submit|confirm|apply|done|ok/i });
521
- const confirmButtonCount = await confirmButton.count();
522
- let isConfirmed = false;
523
- for (let index = 0; index < confirmButtonCount; index++) {
524
- const button = confirmButton.nth(index);
525
- if (await button.isVisible()) {
526
- await button.click();
527
- isConfirmed = true;
528
- break;
529
- }
530
- }
531
- if (!isConfirmed) {
532
- await page.keyboard.press("Escape");
533
- }
534
- }
535
- }
536
- async function fillFormFields(page) {
537
- // Filled sequentially, not concurrently — see the same note in fillOtherRequiredFieldsValidly:
538
- // a popup-based widget open in one field can lose focus and close mid-interaction if another
539
- // field is filled at the same time.
540
- for (const field of exports.FieldValueMappings) {
541
- const locator = page.getByLabel(field.label);
542
- if ((await locator.count()) > 0) {
543
- if (field.interaction === exports.FieldInteractions.Datetime) {
544
- await fillDateTimePicker(locator.first(), field.validValue);
545
- }
546
- else {
547
- await locator.first().fill(field.validValue);
548
- }
549
- }
550
- }
551
- }
552
- async function hasVisibleErrorNearField(fieldLocator) {
553
- const fieldID = await fieldLocator.getAttribute("id");
554
- const describedBy = await fieldLocator.getAttribute("aria-describedby");
555
- let isDescribedByErrorVisible = false;
556
- if (describedBy !== null) {
557
- const describedByLocator = fieldLocator.page().locator(`#${describedBy}`);
558
- isDescribedByErrorVisible = await describedByLocator.isVisible();
559
- }
560
- let isAdjacentErrorVisible = false;
561
- if (fieldID !== null) {
562
- const adjacentError = fieldLocator.page().locator(`[data-error-for="${fieldID}"]`);
563
- isAdjacentErrorVisible = await adjacentError.isVisible();
564
- }
565
- // Native browser validation (e.g. the built-in "Please fill out this field" bubble triggered
566
- // by the `required` attribute) isn't a queryable DOM node, so it can't be caught by the checks
567
- // above — but the field's own validity state still reflects it.
568
- const isNativelyInvalid = await fieldLocator.evaluate((element) => {
569
- return !element.validity.valid && element.validationMessage !== "";
570
- });
571
- return isDescribedByErrorVisible || isAdjacentErrorVisible || isNativelyInvalid;
572
- }
573
- exports.InvalidInputCases = [
574
- { fieldLabel: /email/i, invalidValue: "notanemail", expectedErrorText: /enter a valid email address/i },
575
- { fieldLabel: /email/i, invalidValue: "test@", expectedErrorText: /enter a valid email address/i },
576
- { fieldLabel: /email/i, invalidValue: "@test.com", expectedErrorText: /enter a valid email address/i },
577
- { fieldLabel: /phone/i, invalidValue: "abc", expectedErrorText: /enter a valid phone number/i },
578
- { fieldLabel: /phone/i, invalidValue: "12", expectedErrorText: /enter a valid phone number/i },
579
- ];
580
- async function fillOtherRequiredFieldsValidly(page, skipLabel) {
581
- const skipLocator = page.getByLabel(skipLabel);
582
- const skipFieldCount = await skipLocator.count();
583
- // Some combined widgets (e.g. a single "Preferred Time & Date" picker) match more than one
584
- // FieldValueMappings label at once — comparing by element id, not just label text, catches
585
- // that case so filling the "other" field doesn't silently fill the one under test too.
586
- const skipFieldID = skipFieldCount > 0 ? await skipLocator.first().getAttribute("id") : null;
587
- // Filled sequentially, not concurrently: a popup-based widget (like a date/time picker) opens
588
- // on focus and closes on blur, so filling a different field at the same time can steal focus
589
- // and close it mid-interaction, detaching whatever this was about to click inside it.
590
- for (const field of exports.FieldValueMappings) {
591
- const isSkippedField = field.label.source === skipLabel.source;
592
- const locator = page.getByLabel(field.label);
593
- let isFieldPresent = false;
594
- if (!isSkippedField) {
595
- isFieldPresent = (await locator.count()) > 0;
596
- }
597
- let isSameElementAsSkipped = false;
598
- if (isFieldPresent && skipFieldID !== null) {
599
- const fieldID = await locator.first().getAttribute("id");
600
- isSameElementAsSkipped = fieldID !== null && fieldID === skipFieldID;
601
- }
602
- // Email is always treated as required (see the FieldValueMappings doc comment above),
603
- // regardless of whether it carries a native `required`/`aria-required` attribute — the
604
- // same rule the isEmailField check in required-field.spec.ts applies for its own skip
605
- // decision, so filling here needs to agree with it or both can end up empty at once.
606
- const isEmailField = field.label.source === "email";
607
- let isRequired = false;
608
- if (isFieldPresent && !isSameElementAsSkipped) {
609
- isRequired = isEmailField || (await isFieldRequired(locator.first()));
610
- }
611
- if (isRequired) {
612
- if (field.interaction === exports.FieldInteractions.Datetime) {
613
- await fillDateTimePicker(locator.first(), field.validValue);
614
- }
615
- else {
616
- await locator.first().fill(field.validValue);
617
- }
618
- }
619
- }
620
- }
621
- exports.AutofillFields = [
622
- { label: /name/i, expectedAutocomplete: /name/i },
623
- { label: /email/i, expectedAutocomplete: /email/i },
624
- { label: /phone/i, expectedAutocomplete: /tel/i },
625
- ];
626
- exports.KeyboardFields = [
627
- { label: /email/i, expectedInputMode: /email/i },
628
- { label: /phone/i, expectedInputMode: /tel|numeric/i },
629
- ];
630
- const SlideSelector = '[data-slide], [role="group"], .slide';
631
- const DotSelector = '[role="tab"], .dot, [data-dot]';
632
- async function getActiveSlideIndex(carousel) {
633
- return carousel.evaluate((element, selector) => {
634
- const slides = Array.from(element.querySelectorAll(selector));
635
- return slides.findIndex((slide) => {
636
- return slide.getAttribute("aria-hidden") !== "true";
637
- });
638
- }, SlideSelector);
639
- }
640
- async function getTotalSlideCount(carousel) {
641
- return carousel.evaluate((element, selector) => {
642
- return element.querySelectorAll(selector).length;
643
- }, SlideSelector);
644
- }
645
- async function hasEmptySlideFrame(carousel) {
646
- return carousel.evaluate((element, selector) => {
647
- const visibleSlides = Array.from(element.querySelectorAll(selector)).filter((slide) => {
648
- return slide.getAttribute("aria-hidden") !== "true";
649
- });
650
- const isEveryVisibleSlideBlank = visibleSlides.every((slide) => {
651
- return slide.textContent.trim() === "";
652
- });
653
- return visibleSlides.length === 0 || isEveryVisibleSlideBlank;
654
- }, SlideSelector);
655
- }
656
- async function goToNextSlideViaArrow(carousel) {
657
- const nextArrow = carousel.getByRole("button", { name: /next/i });
658
- const arrowCount = await nextArrow.count();
659
- let didAdvance = false;
660
- if (arrowCount > 0) {
661
- const isEnabled = await nextArrow.isEnabled();
662
- if (isEnabled) {
663
- await nextArrow.click();
664
- didAdvance = true;
665
- }
666
- }
667
- return didAdvance;
668
- }
669
- async function getDotCount(carousel) {
670
- return carousel.locator(DotSelector).count();
671
- }
672
- async function goToSlideViaDot(carousel, dotIndex) {
673
- const dot = carousel.locator(DotSelector).nth(dotIndex);
674
- const isEnabled = await dot.isEnabled();
675
- if (isEnabled) {
676
- await dot.click();
677
- }
678
- return isEnabled;
679
- }
680
- const SlideDirection = {
681
- Forward: "forward",
682
- Backward: "backward",
683
- };
684
- // Playwright has no built-in touch-swipe gesture, but a mouse drag across most of the carousel's
685
- // width is functionally equivalent for carousels that respond to pointer events (which cover both
686
- // mouse drag and touch swipe), and is more portable across browser engines than dispatching
687
- // synthetic touch events directly.
688
- async function dragCarousel(carousel, direction) {
689
- const box = await carousel.boundingBox();
690
- const didDrag = box !== null;
691
- if (box !== null) {
692
- const page = carousel.page();
693
- const y = box.y + box.height / 2;
694
- const isForward = direction === SlideDirection.Forward;
695
- const fromX = isForward ? box.x + box.width * 0.8 : box.x + box.width * 0.2;
696
- const toX = isForward ? box.x + box.width * 0.2 : box.x + box.width * 0.8;
697
- await page.mouse.move(fromX, y);
698
- await page.mouse.down();
699
- await page.mouse.move(toX, y, { steps: 10 });
700
- await page.mouse.up();
701
- }
702
- return didDrag;
703
- }
704
- async function swipeToNextSlide(carousel) {
705
- return dragCarousel(carousel, SlideDirection.Forward);
706
- }
707
- async function swipeToPreviousSlide(carousel) {
708
- return dragCarousel(carousel, SlideDirection.Backward);
709
- }
710
- // Advances through every slide via arrow (desktop) or swipe (mobile), asserting the frame is
711
- // never blank along the way. Stops early once it either loops back to the start or gets stuck on
712
- // the same slide (a non-looping carousel that has reached its last slide).
713
- async function traverseSlidesForward(carousel, carouselIndex, isMobile) {
714
- const totalSlides = await getTotalSlideCount(carousel);
715
- const startIndex = await getActiveSlideIndex(carousel);
716
- let previousIndex = startIndex;
717
- // One extra advance beyond the actual slide count, to also exercise the loop/stop behavior at
718
- // the end rather than just reaching the last slide.
719
- const maxAdvances = totalSlides + 1;
720
- let shouldContinue = true;
721
- for (let advance = 0; advance < maxAdvances && shouldContinue; advance++) {
722
- const didAdvance = isMobile ? await swipeToNextSlide(carousel) : await goToNextSlideViaArrow(carousel);
723
- if (didAdvance) {
724
- const isEmptyFrame = await hasEmptySlideFrame(carousel);
725
- (0, test_1.expect)(isEmptyFrame, `Carousel at index ${carouselIndex.toString()}: Slide frame was blank after ${String(advance + 1)} ${isMobile ? "swipes" : "arrow clicks"}`).toBe(false);
726
- const currentIndex = await getActiveSlideIndex(carousel);
727
- const hasLoopedBackToStart = currentIndex === startIndex && previousIndex !== startIndex;
728
- const hasStoppedAdvancing = currentIndex === previousIndex;
729
- if (hasLoopedBackToStart || hasStoppedAdvancing) {
730
- shouldContinue = false;
731
- }
732
- previousIndex = currentIndex;
733
- }
734
- else {
735
- shouldContinue = false;
736
- }
737
- }
738
- }
739
- async function checkSwipeBackward(carousel, carouselIndex) {
740
- const didSwipeBack = await swipeToPreviousSlide(carousel);
741
- if (didSwipeBack) {
742
- const isEmptyFrameAfterSwipeBack = await hasEmptySlideFrame(carousel);
743
- (0, test_1.expect)(isEmptyFrameAfterSwipeBack, `Carousel at index ${carouselIndex.toString()}: Slide frame was blank after swiping backward`).toBe(false);
744
- }
745
- }
746
- async function checkAllDots(carousel, carouselIndex) {
747
- const dotCount = await getDotCount(carousel);
748
- for (let dotIndex = 0; dotIndex < dotCount; dotIndex++) {
749
- const didGoToDot = await goToSlideViaDot(carousel, dotIndex);
750
- if (didGoToDot) {
751
- const isEmptyFrameAfterDot = await hasEmptySlideFrame(carousel);
752
- (0, test_1.expect)(isEmptyFrameAfterDot, `Carousel at index ${carouselIndex.toString()}: Slide frame was blank after clicking dot ${dotIndex.toString()}`).toBe(false);
753
- }
754
- }
755
- }
756
- exports.SafeAreaZones = {
757
- Top: "top",
758
- Bottom: "bottom",
759
- Left: "left",
760
- Right: "right",
761
- };
762
- function getPadding(val) {
763
- let numericValue = 0;
764
- const parsed = Number.parseFloat(val);
765
- if (!Number.isNaN(parsed)) {
766
- numericValue = parsed;
767
- }
768
- return numericValue;
769
- }
770
- function isVisible(element) {
771
- let visible;
772
- const style = window.getComputedStyle(element);
773
- if (style.display === "none" || style.visibility === "hidden" || Number.parseFloat(style.opacity) === 0) {
774
- visible = false;
775
- }
776
- else {
777
- const rect = element.getBoundingClientRect();
778
- const hasDimensions = rect.width > 0 && rect.height > 0;
779
- const horizontalOverlap = Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0);
780
- const verticalOverlap = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
781
- visible = hasDimensions && horizontalOverlap > 0 && verticalOverlap > 0;
782
- }
783
- return visible;
784
- }
785
- async function findSafeAreaOverlaps(page) {
786
- const contentSelector = `${exports.TappableElementsSelector}, p, h1, h2, h3, h4, h5, h6, li, span`;
787
- return page.evaluate(({ selector }) => {
788
- // Dynamically measure actual safe area insets from the browser environment
789
- const div = document.createElement("div");
790
- div.style.paddingTop = "env(safe-area-inset-top, 0px)";
791
- div.style.paddingRight = "env(safe-area-inset-right, 0px)";
792
- div.style.paddingBottom = "env(safe-area-inset-bottom, 0px)";
793
- div.style.paddingLeft = "env(safe-area-inset-left, 0px)";
794
- div.style.position = "absolute";
795
- div.style.visibility = "hidden";
796
- document.body.appendChild(div);
797
- const computedStyle = window.getComputedStyle(div);
798
- const { paddingTop } = computedStyle;
799
- const { paddingRight } = computedStyle;
800
- const { paddingBottom } = computedStyle;
801
- const { paddingLeft } = computedStyle;
802
- document.body.removeChild(div);
803
- const insets = {
804
- top: getPadding(paddingTop),
805
- right: getPadding(paddingRight),
806
- bottom: getPadding(paddingBottom),
807
- left: getPadding(paddingLeft),
808
- };
809
- const overlaps = [];
810
- const elements = Array.from(document.querySelectorAll(selector));
811
- const visibleElements = elements.filter(isVisible);
812
- for (const element of visibleElements) {
813
- // Skip elements that are likely just layout containers or backgrounds if they don't have direct text
814
- // (Though the selector restricts to text/tappable elements mostly)
815
- const rect = element.getBoundingClientRect();
816
- const style = window.getComputedStyle(element);
817
- const pl = getPadding(style.paddingLeft);
818
- const pr = getPadding(style.paddingRight);
819
- const pt = getPadding(style.paddingTop);
820
- const pb = getPadding(style.paddingBottom);
821
- const contentLeft = rect.left + pl;
822
- const contentRight = rect.right - pr;
823
- const contentTop = rect.top + pt;
824
- const contentBottom = rect.bottom - pb;
825
- const viewportWidth = window.innerWidth;
826
- const viewportHeight = window.innerHeight;
827
- const isFixedOrSticky = style.position === "fixed" || style.position === "sticky";
828
- const zones = [];
829
- // Check if element content intersects with top safe area
830
- if (insets.top > 0 && contentTop < insets.top && contentBottom > 0) {
831
- zones.push("top");
832
- }
833
- // Check if element content intersects with bottom safe area
834
- if (insets.bottom > 0 && contentBottom > viewportHeight - insets.bottom && contentTop < viewportHeight) {
835
- // Normal static elements naturally scroll through the bottom safe area,
836
- // so only flag fixed or sticky elements that overlap it on load.
837
- if (isFixedOrSticky) {
838
- zones.push("bottom");
839
- }
840
- }
841
- // Check if element content intersects with left safe area
842
- if (insets.left > 0 && contentLeft < insets.left && contentRight > 0) {
843
- zones.push("left");
844
- }
845
- // Check if element content intersects with right safe area
846
- if (insets.right > 0 && contentRight > viewportWidth - insets.right && contentLeft < viewportWidth) {
847
- zones.push("right");
848
- }
849
- for (const zone of zones) {
850
- overlaps.push({
851
- tagName: element.tagName,
852
- className: element.className,
853
- text: element.textContent.trim().slice(0, 40),
854
- overlapZone: zone,
855
- });
856
- }
857
- }
858
- return overlaps;
859
- }, { selector: contentSelector });
860
- }
861
10
  const MonthNames = [
862
11
  "January",
863
12
  "February",
@@ -872,6 +21,8 @@ const MonthNames = [
872
21
  "November",
873
22
  "December",
874
23
  ];
24
+ const ScrollSteps = 10;
25
+ const ScrollStepDelayMs = 100;
875
26
  async function fillDatetime({ field, locator, page }) {
876
27
  // A fresh page load already leaves the picker empty; nothing to clear or open for.
877
28
  if (field.validValue != null && field.validValue !== "") {
@@ -893,7 +44,7 @@ async function fillDatetime({ field, locator, page }) {
893
44
  }
894
45
  async function fillField({ field, locator, page }) {
895
46
  const fieldLocator = locator.getByTestId(field.fieldID);
896
- if (field.interaction === exports.FieldInteractions.Datetime) {
47
+ if (field.interaction === schema_1.FieldInteractions.Datetime) {
897
48
  await fillDatetime({
898
49
  field,
899
50
  locator: fieldLocator,
@@ -916,3 +67,23 @@ async function hasNativeValidationError(fieldLocator) {
916
67
  !element.checkValidity());
917
68
  });
918
69
  }
70
+ // Turns a page path into a filesystem-safe snapshot name, e.g. "/" -> "home", "/pricing" -> "pricing".
71
+ function toSnapshotName(targetPage) {
72
+ const trimmed = targetPage.replace(/^\/+|\/+$/g, "");
73
+ return trimmed.length > 0 ? trimmed.replace(/\//g, "-") : "home";
74
+ }
75
+ async function hasHorizontalScrollbar(page) {
76
+ return page.evaluate(() => {
77
+ return document.documentElement.scrollWidth > document.documentElement.clientWidth;
78
+ });
79
+ }
80
+ async function scrollThroughPage(page) {
81
+ const scrollHeight = await page.evaluate(() => {
82
+ return document.documentElement.scrollHeight;
83
+ });
84
+ const scrollStepDelta = Math.floor(scrollHeight / ScrollSteps);
85
+ for (let step = 0; step <= ScrollSteps; step++) {
86
+ await page.mouse.wheel(0, scrollStepDelta);
87
+ await page.waitForTimeout(ScrollStepDelayMs);
88
+ }
89
+ }