@manufac/general-tests 0.5.0 → 0.9.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 +88 -0
- package/dist/config/projects.d.ts +3 -1
- package/dist/config/projects.js +4 -2
- package/dist/fixtures/index.d.ts +2 -1
- package/dist/fixtures/index.js +1 -0
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/index.js +7 -0
- package/dist/specs/cta-destinations.spec.js +65 -0
- package/dist/specs/page-visual-regression.spec.js +68 -0
- package/dist/specs/{visual-regression.spec.js → storybook-visual-regression.spec.js} +7 -0
- package/dist/specs/utils.d.ts +4 -158
- package/dist/specs/utils.js +31 -857
- package/package.json +1 -1
- package/dist/specs/desktop-only/browser-zoom.spec.js +0 -26
- package/dist/specs/mobile-only/ios-safari-toolbar.spec.js +0 -62
- package/dist/specs/mobile-only/ios-safe-areas.spec.js +0 -36
- package/dist/specs/mobile-only/navigation.spec.d.ts +0 -1
- package/dist/specs/mobile-only/navigation.spec.js +0 -40
- package/dist/specs/mobile-only/orientation.spec.d.ts +0 -1
- package/dist/specs/mobile-only/orientation.spec.js +0 -46
- package/dist/specs/mobile-only/readability.spec.d.ts +0 -1
- package/dist/specs/mobile-only/readability.spec.js +0 -14
- package/dist/specs/mobile-only/soft-keyboard.spec.d.ts +0 -1
- package/dist/specs/mobile-only/soft-keyboard.spec.js +0 -43
- package/dist/specs/mobile-only/touch-targets.spec.d.ts +0 -1
- package/dist/specs/mobile-only/touch-targets.spec.js +0 -12
- package/dist/specs/shared/cta.spec.d.ts +0 -1
- package/dist/specs/shared/cta.spec.js +0 -199
- package/dist/specs/shared/forms/autofill.spec.d.ts +0 -1
- package/dist/specs/shared/forms/autofill.spec.js +0 -28
- package/dist/specs/shared/forms/double-submit.spec.d.ts +0 -1
- package/dist/specs/shared/forms/double-submit.spec.js +0 -25
- package/dist/specs/shared/forms/input-edge.spec.d.ts +0 -1
- package/dist/specs/shared/forms/input-edge.spec.js +0 -60
- package/dist/specs/shared/forms/invalid-input.spec.d.ts +0 -1
- package/dist/specs/shared/forms/invalid-input.spec.js +0 -41
- package/dist/specs/shared/forms/native-inputs.spec.d.ts +0 -1
- package/dist/specs/shared/forms/native-inputs.spec.js +0 -37
- package/dist/specs/shared/forms/required-field.spec.d.ts +0 -1
- package/dist/specs/shared/forms/required-field.spec.js +0 -39
- package/dist/specs/shared/forms/thankyou-confirm.spec.d.ts +0 -1
- package/dist/specs/shared/forms/thankyou-confirm.spec.js +0 -34
- package/dist/specs/shared/hero-fold.spec.d.ts +0 -1
- package/dist/specs/shared/hero-fold.spec.js +0 -40
- package/dist/specs/shared/image-quality.spec.d.ts +0 -1
- package/dist/specs/shared/image-quality.spec.js +0 -13
- package/dist/specs/shared/media-embeds/carousel-slider.spec.d.ts +0 -1
- package/dist/specs/shared/media-embeds/carousel-slider.spec.js +0 -31
- package/dist/specs/shared/media-embeds/cookie-consent.spec.d.ts +0 -1
- package/dist/specs/shared/media-embeds/cookie-consent.spec.js +0 -27
- package/dist/specs/shared/media-embeds/third-party-embeds.spec.d.ts +0 -1
- package/dist/specs/shared/media-embeds/third-party-embeds.spec.js +0 -27
- package/dist/specs/shared/media-embeds/video-playback.spec.d.ts +0 -1
- package/dist/specs/shared/media-embeds/video-playback.spec.js +0 -54
- package/dist/specs/shared/sticky-elements.spec.d.ts +0 -1
- package/dist/specs/shared/sticky-elements.spec.js +0 -13
- package/dist/specs/sticky-utils.d.ts +0 -8
- package/dist/specs/sticky-utils.js +0 -175
- package/dist/specs/visual-regression.spec.d.ts +0 -1
- /package/dist/specs/{desktop-only/browser-zoom.spec.d.ts → cta-destinations.spec.d.ts} +0 -0
- /package/dist/specs/{mobile-only/ios-safari-toolbar.spec.d.ts → page-visual-regression.spec.d.ts} +0 -0
- /package/dist/specs/{mobile-only/ios-safe-areas.spec.d.ts → storybook-visual-regression.spec.d.ts} +0 -0
package/dist/specs/utils.js
CHANGED
|
@@ -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 ===
|
|
47
|
+
if (field.interaction === schema_1.FieldInteractions.Datetime) {
|
|
897
48
|
await fillDatetime({
|
|
898
49
|
field,
|
|
899
50
|
locator: fieldLocator,
|
|
@@ -910,6 +61,29 @@ async function fillField({ field, locator, page }) {
|
|
|
910
61
|
*/
|
|
911
62
|
async function hasNativeValidationError(fieldLocator) {
|
|
912
63
|
return await fieldLocator.evaluate((element) => {
|
|
913
|
-
return element instanceof HTMLInputElement
|
|
64
|
+
return ((element instanceof HTMLInputElement ||
|
|
65
|
+
element instanceof HTMLTextAreaElement ||
|
|
66
|
+
element instanceof HTMLSelectElement) &&
|
|
67
|
+
!element.checkValidity());
|
|
68
|
+
});
|
|
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;
|
|
914
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
|
+
}
|
|
915
89
|
}
|