@manufac/general-tests 0.0.1-alpha → 0.0.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.
- package/dist/specs/desktop-only/browser-zoom.spec.js +13 -5
- package/dist/specs/mobile-only/ios-safari-toolbar.spec.d.ts +1 -0
- package/dist/specs/mobile-only/ios-safari-toolbar.spec.js +62 -0
- package/dist/specs/mobile-only/ios-safe-areas.spec.d.ts +1 -0
- package/dist/specs/mobile-only/ios-safe-areas.spec.js +36 -0
- package/dist/specs/mobile-only/navigation.spec.js +17 -7
- package/dist/specs/mobile-only/orientation.spec.js +9 -6
- package/dist/specs/mobile-only/soft-keyboard.spec.js +11 -2
- package/dist/specs/mobile-only/touch-targets.spec.js +1 -1
- package/dist/specs/shared/forms/autofill.spec.js +15 -3
- package/dist/specs/shared/forms/double-submit.spec.js +1 -15
- package/dist/specs/shared/forms/input-edge.spec.js +15 -5
- package/dist/specs/shared/forms/invalid-input.spec.js +30 -11
- package/dist/specs/shared/forms/native-inputs.spec.d.ts +1 -0
- package/dist/specs/shared/forms/native-inputs.spec.js +37 -0
- package/dist/specs/shared/forms/required-field.spec.js +7 -2
- package/dist/specs/shared/forms/thankyou-confirm.spec.js +3 -4
- package/dist/specs/shared/hero-fold.spec.js +24 -5
- package/dist/specs/shared/landing-page.spec.js +69 -0
- package/dist/specs/shared/media-embeds/third-party-embeds.spec.js +5 -2
- package/dist/specs/sticky-utils.d.ts +0 -5
- package/dist/specs/sticky-utils.js +49 -32
- package/dist/specs/utils.d.ts +55 -15
- package/dist/specs/utils.js +357 -63
- package/package.json +1 -1
package/dist/specs/utils.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.KeyboardFields = exports.AutofillFields = exports.InvalidInputCases = exports.
|
|
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
4
|
exports.describeElement = describeElement;
|
|
5
5
|
exports.getScrollMetrics = getScrollMetrics;
|
|
6
6
|
exports.scrollThroughPage = scrollThroughPage;
|
|
7
7
|
exports.findTruncatedOrOverflowingText = findTruncatedOrOverflowingText;
|
|
8
8
|
exports.findOverlappingElements = findOverlappingElements;
|
|
9
|
-
exports.findOverlappingTapTargets = findOverlappingTapTargets;
|
|
10
9
|
exports.findImageSizeIssues = findImageSizeIssues;
|
|
11
10
|
exports.isImageUpscaled = isImageUpscaled;
|
|
12
11
|
exports.isImageOversized = isImageOversized;
|
|
@@ -15,10 +14,11 @@ exports.findElementsCutoffAtViewportEdge = findElementsCutoffAtViewportEdge;
|
|
|
15
14
|
exports.findUndersizedBodyText = findUndersizedBodyText;
|
|
16
15
|
exports.findUndersizedTouchTargets = findUndersizedTouchTargets;
|
|
17
16
|
exports.hasHorizontalScrollbar = hasHorizontalScrollbar;
|
|
18
|
-
exports.findCollapsedEmbeds = findCollapsedEmbeds;
|
|
19
17
|
exports.isVideoAutoplaying = isVideoAutoplaying;
|
|
20
18
|
exports.isVideoMuted = isVideoMuted;
|
|
21
|
-
exports.
|
|
19
|
+
exports.isFieldRequired = isFieldRequired;
|
|
20
|
+
exports.fillDateTimePicker = fillDateTimePicker;
|
|
21
|
+
exports.fillFormFields = fillFormFields;
|
|
22
22
|
exports.hasVisibleErrorNearField = hasVisibleErrorNearField;
|
|
23
23
|
exports.fillOtherRequiredFieldsValidly = fillOtherRequiredFieldsValidly;
|
|
24
24
|
exports.getActiveSlideIndex = getActiveSlideIndex;
|
|
@@ -32,14 +32,15 @@ exports.swipeToPreviousSlide = swipeToPreviousSlide;
|
|
|
32
32
|
exports.traverseSlidesForward = traverseSlidesForward;
|
|
33
33
|
exports.checkSwipeBackward = checkSwipeBackward;
|
|
34
34
|
exports.checkAllDots = checkAllDots;
|
|
35
|
+
exports.findSafeAreaOverlaps = findSafeAreaOverlaps;
|
|
35
36
|
const test_1 = require("@playwright/test");
|
|
36
37
|
exports.ResponsiveConfig = {
|
|
37
38
|
ScrollSteps: 10,
|
|
38
39
|
ScrollStepDelayMs: 100,
|
|
39
40
|
ViewPortWidth: 1440,
|
|
40
41
|
ViewPortHeight: 900,
|
|
41
|
-
|
|
42
|
-
ZoomSettleDelayMs:
|
|
42
|
+
ZoomFactor: 2,
|
|
43
|
+
ZoomSettleDelayMs: 300,
|
|
43
44
|
UpscaleTolerance: 1.05,
|
|
44
45
|
OversizeTolerance: 2,
|
|
45
46
|
ScrollDriftTolerance: 0.15,
|
|
@@ -47,6 +48,7 @@ exports.ResponsiveConfig = {
|
|
|
47
48
|
PinFlickerTolerancePX: 2,
|
|
48
49
|
MinTouchTargetSizePX: 44,
|
|
49
50
|
KeyboardOcclusionRatio: 0.4,
|
|
51
|
+
CutoffTolerancePX: 5,
|
|
50
52
|
};
|
|
51
53
|
// Formats a descriptor captured inside page.evaluate (see findElementsCutoffAtViewportEdge and
|
|
52
54
|
// findUndersizedBodyText) into a readable tag selector, e.g. <p.hero.subtitle>.
|
|
@@ -113,13 +115,17 @@ async function findTruncatedOrOverflowingText(page) {
|
|
|
113
115
|
const elements = Array.from(document.querySelectorAll("p, li, td, th, dt, dd, blockquote, figcaption, h1, h2, h3, h4, h5, h6"));
|
|
114
116
|
for (const element of elements) {
|
|
115
117
|
const text = element.textContent.trim();
|
|
116
|
-
if (text.length
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
if (text.length > 0) {
|
|
119
|
+
const style = window.getComputedStyle(element);
|
|
120
|
+
const isEllipsisTruncated = style.textOverflow === "ellipsis" && style.overflow !== "visible";
|
|
121
|
+
const isOverflowingHorizontally = element.scrollWidth > element.clientWidth + 1;
|
|
122
|
+
// Ellipsis truncation is a deliberate, visually-indicated design pattern (card previews,
|
|
123
|
+
// single-line labels) — not a bug. Only flag genuinely broken cases: text overflowing
|
|
124
|
+
// WITHOUT any truncation handling (spilling out unstyled), which is always unintentional.
|
|
125
|
+
const isUnhandledOverflow = isOverflowingHorizontally && !isEllipsisTruncated;
|
|
126
|
+
if (isUnhandledOverflow) {
|
|
127
|
+
broken.push(`<${element.tagName.toLowerCase()} class="${element.className}"> overflow=${style.overflow}, textOverflow=${style.textOverflow}: "${text.slice(0, 40)}"`);
|
|
128
|
+
}
|
|
123
129
|
}
|
|
124
130
|
}
|
|
125
131
|
return broken;
|
|
@@ -127,9 +133,6 @@ async function findTruncatedOrOverflowingText(page) {
|
|
|
127
133
|
}
|
|
128
134
|
async function findOverlappingElements(page, containerSelector) {
|
|
129
135
|
const overlaps = await page.evaluate((selector) => {
|
|
130
|
-
// Only elements at least partially visible in the viewport are candidates — off-canvas nav
|
|
131
|
-
// drawers, upcoming carousel slides, and similar patterns commonly overlap internally while
|
|
132
|
-
// hidden off-screen, and that's not a bug (mirrors the check in findElementsCutoffAtViewportEdge).
|
|
133
136
|
function isVisibleInViewport(element) {
|
|
134
137
|
const rect = element.getBoundingClientRect();
|
|
135
138
|
const hasDimensions = rect.width > 0 && rect.height > 0;
|
|
@@ -137,25 +140,75 @@ async function findOverlappingElements(page, containerSelector) {
|
|
|
137
140
|
const verticalOverlap = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
|
|
138
141
|
return hasDimensions && horizontalOverlap > 0 && verticalOverlap > 0;
|
|
139
142
|
}
|
|
143
|
+
// An element clipped by an ancestor's overflow:hidden/scroll/auto (e.g. a closed mobile
|
|
144
|
+
// drawer/menu duplicate) is intentionally hidden from view — not a real visual overlap.
|
|
145
|
+
// Defined inside this evaluate callback (rather than at module scope) since page.evaluate
|
|
146
|
+
// serializes only the callback itself — outer Node-side function references aren't
|
|
147
|
+
// available once this runs in the browser.
|
|
148
|
+
function isClippedByAncestorOverflow(element) {
|
|
149
|
+
const rect = element.getBoundingClientRect();
|
|
150
|
+
let parent = element.parentElement;
|
|
151
|
+
let isClipped = false;
|
|
152
|
+
while (parent !== null && !isClipped) {
|
|
153
|
+
const parentStyle = window.getComputedStyle(parent);
|
|
154
|
+
const isOverflowClipped = ["hidden", "scroll", "auto"].includes(parentStyle.overflowX) ||
|
|
155
|
+
["hidden", "scroll", "auto"].includes(parentStyle.overflowY);
|
|
156
|
+
if (isOverflowClipped) {
|
|
157
|
+
const parentRect = parent.getBoundingClientRect();
|
|
158
|
+
const isOutsideParentBounds = rect.right <= parentRect.left ||
|
|
159
|
+
rect.left >= parentRect.right ||
|
|
160
|
+
rect.bottom <= parentRect.top ||
|
|
161
|
+
rect.top >= parentRect.bottom;
|
|
162
|
+
if (isOutsideParentBounds) {
|
|
163
|
+
isClipped = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
parent = parent.parentElement;
|
|
167
|
+
}
|
|
168
|
+
return isClipped;
|
|
169
|
+
}
|
|
140
170
|
const conflicts = [];
|
|
141
|
-
const elements = Array.from(document.querySelectorAll(selector))
|
|
171
|
+
const elements = Array.from(document.querySelectorAll(selector))
|
|
172
|
+
.filter(isVisibleInViewport)
|
|
173
|
+
.filter((element) => {
|
|
174
|
+
return !isClippedByAncestorOverflow(element);
|
|
175
|
+
});
|
|
176
|
+
// Checks the element itself and its ancestors: a fixed/sticky container's own children are
|
|
177
|
+
// normally position:static/relative, but they still visually pin to the viewport along with
|
|
178
|
+
// it, so the pinning has to be detected up the ancestor chain, not just on the element itself.
|
|
179
|
+
function isPinnedToViewport(element) {
|
|
180
|
+
let current = element;
|
|
181
|
+
let isPinned = false;
|
|
182
|
+
while (current !== null && !isPinned) {
|
|
183
|
+
const { position } = window.getComputedStyle(current);
|
|
184
|
+
isPinned = position === "fixed" || position === "sticky";
|
|
185
|
+
current = current.parentElement;
|
|
186
|
+
}
|
|
187
|
+
return isPinned;
|
|
188
|
+
}
|
|
142
189
|
for (let i = 0; i < elements.length; i++) {
|
|
143
190
|
for (let index = i + 1; index < elements.length; index++) {
|
|
144
191
|
const a = elements[i];
|
|
145
192
|
const b = elements[index];
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
193
|
+
const isNested = a.contains(b) || b.contains(a);
|
|
194
|
+
// A fixed/sticky element (or something inside one) sitting over whatever content has
|
|
195
|
+
// scrolled beneath it is expected, inherent behavior — not a layout bug. Only flag
|
|
196
|
+
// overlaps between two elements that are both in normal flow (or both pinned, which
|
|
197
|
+
// would indicate a real stacking conflict between two pinned elements).
|
|
198
|
+
const isExpectedPinnedOverlap = isPinnedToViewport(a) !== isPinnedToViewport(b);
|
|
199
|
+
if (!isNested && !isExpectedPinnedOverlap) {
|
|
200
|
+
const rectA = a.getBoundingClientRect();
|
|
201
|
+
const rectB = b.getBoundingClientRect();
|
|
202
|
+
const overlapWidth = Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left);
|
|
203
|
+
const overlapHeight = Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top);
|
|
204
|
+
if (overlapWidth > 0 && overlapHeight > 0) {
|
|
205
|
+
conflicts.push({
|
|
206
|
+
elementA: { tagName: a.tagName, className: a.className },
|
|
207
|
+
elementB: { tagName: b.tagName, className: b.className },
|
|
208
|
+
overlapWidth,
|
|
209
|
+
overlapHeight,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
159
212
|
}
|
|
160
213
|
}
|
|
161
214
|
}
|
|
@@ -165,9 +218,6 @@ async function findOverlappingElements(page, containerSelector) {
|
|
|
165
218
|
return `${overlap.elementA.tagName} overlaps ${overlap.elementB.tagName} by ${overlap.overlapWidth.toFixed(0)}x${overlap.overlapHeight.toFixed(0)}px`;
|
|
166
219
|
});
|
|
167
220
|
}
|
|
168
|
-
async function findOverlappingTapTargets(page) {
|
|
169
|
-
return findOverlappingElements(page, exports.TappableElementsSelector);
|
|
170
|
-
}
|
|
171
221
|
// Content elements worth checking for overlap after a layout-affecting change (e.g. orientation
|
|
172
222
|
// change): tappable elements, text-bearing elements, and images.
|
|
173
223
|
exports.ContentOverlapSelector = [
|
|
@@ -228,14 +278,22 @@ function isImageOversized(image) {
|
|
|
228
278
|
// Cut off means straddling the viewport edge (partially visible), not positioned entirely
|
|
229
279
|
// outside it — off-canvas nav drawers, upcoming carousel slides, and visually-hidden
|
|
230
280
|
// screen-reader text are intentionally placed fully outside the viewport and are not bugs.
|
|
281
|
+
// Vertical straddling is only flagged for fixed/sticky elements: normal in-flow content
|
|
282
|
+
// straddles the top/bottom edge constantly while scrolling, which is expected, not a bug.
|
|
231
283
|
// Pure geometry (no DOM APIs), so it runs in Node against a rect snapshot taken in the browser.
|
|
232
|
-
function isCutoffAtViewportEdge(
|
|
284
|
+
function isCutoffAtViewportEdge(snapshot, viewport) {
|
|
285
|
+
const { rect } = snapshot;
|
|
233
286
|
const hasDimensions = rect.width > 0 && rect.height > 0;
|
|
234
287
|
const horizontalOverlap = Math.min(rect.right, viewport.width) - Math.max(rect.left, 0);
|
|
288
|
+
const isHorizontallyClipped = horizontalOverlap > 0 &&
|
|
289
|
+
(rect.left < -exports.ResponsiveConfig.CutoffTolerancePX ||
|
|
290
|
+
rect.right > viewport.width + exports.ResponsiveConfig.CutoffTolerancePX);
|
|
235
291
|
const verticalOverlap = Math.min(rect.bottom, viewport.height) - Math.max(rect.top, 0);
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
292
|
+
const isVerticallyClipped = snapshot.isFixedOrSticky &&
|
|
293
|
+
verticalOverlap > 0 &&
|
|
294
|
+
(rect.top < -exports.ResponsiveConfig.CutoffTolerancePX ||
|
|
295
|
+
rect.bottom > viewport.height + exports.ResponsiveConfig.CutoffTolerancePX);
|
|
296
|
+
return hasDimensions && (isHorizontallyClipped || isVerticallyClipped);
|
|
239
297
|
}
|
|
240
298
|
async function findElementsCutoffAtViewportEdge(page, containerSelector = "body *") {
|
|
241
299
|
const { elements, viewport } = await page.evaluate((selector) => {
|
|
@@ -243,10 +301,41 @@ async function findElementsCutoffAtViewportEdge(page, containerSelector = "body
|
|
|
243
301
|
const style = window.getComputedStyle(element);
|
|
244
302
|
return style.display !== "none" && style.visibility !== "hidden" && Number.parseFloat(style.opacity) !== 0;
|
|
245
303
|
}
|
|
304
|
+
// An element clipped by an ancestor's overflow:hidden/scroll/auto (e.g. a closed
|
|
305
|
+
// drawer/menu duplicate) is intentionally hidden from view — not a real cutoff.
|
|
306
|
+
// Defined inside this evaluate callback (rather than at module scope) since page.evaluate
|
|
307
|
+
// serializes only the callback itself — outer Node-side function references aren't
|
|
308
|
+
// available once this runs in the browser.
|
|
309
|
+
function isClippedByAncestorOverflow(element) {
|
|
310
|
+
const rect = element.getBoundingClientRect();
|
|
311
|
+
let parent = element.parentElement;
|
|
312
|
+
let isClipped = false;
|
|
313
|
+
while (parent !== null && !isClipped) {
|
|
314
|
+
const parentStyle = window.getComputedStyle(parent);
|
|
315
|
+
const isOverflowClipped = ["hidden", "scroll", "auto"].includes(parentStyle.overflowX) ||
|
|
316
|
+
["hidden", "scroll", "auto"].includes(parentStyle.overflowY);
|
|
317
|
+
if (isOverflowClipped) {
|
|
318
|
+
const parentRect = parent.getBoundingClientRect();
|
|
319
|
+
const isOutsideParentBounds = rect.right <= parentRect.left ||
|
|
320
|
+
rect.left >= parentRect.right ||
|
|
321
|
+
rect.bottom <= parentRect.top ||
|
|
322
|
+
rect.top >= parentRect.bottom;
|
|
323
|
+
if (isOutsideParentBounds) {
|
|
324
|
+
isClipped = true;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
parent = parent.parentElement;
|
|
328
|
+
}
|
|
329
|
+
return isClipped;
|
|
330
|
+
}
|
|
246
331
|
const renderedElements = Array.from(document.querySelectorAll(selector))
|
|
247
332
|
.filter(isRenderedElement)
|
|
333
|
+
.filter((element) => {
|
|
334
|
+
return !isClippedByAncestorOverflow(element);
|
|
335
|
+
})
|
|
248
336
|
.map((element) => {
|
|
249
337
|
const rect = element.getBoundingClientRect();
|
|
338
|
+
const style = window.getComputedStyle(element);
|
|
250
339
|
return {
|
|
251
340
|
tagName: element.tagName,
|
|
252
341
|
className: element instanceof HTMLElement ? element.className : "",
|
|
@@ -258,13 +347,14 @@ async function findElementsCutoffAtViewportEdge(page, containerSelector = "body
|
|
|
258
347
|
width: rect.width,
|
|
259
348
|
height: rect.height,
|
|
260
349
|
},
|
|
350
|
+
isFixedOrSticky: style.position === "fixed" || style.position === "sticky",
|
|
261
351
|
};
|
|
262
352
|
});
|
|
263
353
|
return { elements: renderedElements, viewport: { width: window.innerWidth, height: window.innerHeight } };
|
|
264
354
|
}, containerSelector);
|
|
265
355
|
return elements
|
|
266
356
|
.filter((element) => {
|
|
267
|
-
return isCutoffAtViewportEdge(element
|
|
357
|
+
return isCutoffAtViewportEdge(element, viewport);
|
|
268
358
|
})
|
|
269
359
|
.map(describeElement);
|
|
270
360
|
}
|
|
@@ -319,20 +409,6 @@ async function hasHorizontalScrollbar(page) {
|
|
|
319
409
|
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
|
320
410
|
});
|
|
321
411
|
}
|
|
322
|
-
async function findCollapsedEmbeds(page) {
|
|
323
|
-
return page.evaluate(() => {
|
|
324
|
-
const embeds = Array.from(document.querySelectorAll("iframe, embed, object"));
|
|
325
|
-
return embeds
|
|
326
|
-
.filter((embed) => {
|
|
327
|
-
const rect = embed.getBoundingClientRect();
|
|
328
|
-
return rect.width === 0 || rect.height === 0;
|
|
329
|
-
})
|
|
330
|
-
.map((embed) => {
|
|
331
|
-
const rect = embed.getBoundingClientRect();
|
|
332
|
-
return `<${embed.tagName.toLowerCase()}> collapsed to ${rect.width.toFixed(0)}x${rect.height.toFixed(0)}px`;
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
412
|
async function isVideoAutoplaying(video) {
|
|
337
413
|
const isPaused = await video.evaluate((element) => {
|
|
338
414
|
return element.paused;
|
|
@@ -348,20 +424,110 @@ exports.ValidUserInfo = {
|
|
|
348
424
|
ValidTestName: "Jane Doe",
|
|
349
425
|
ValidTestEmail: "test@test.com",
|
|
350
426
|
ValidTestPhone: "9876543210",
|
|
427
|
+
ValidTestCompany: "Acme Corp",
|
|
428
|
+
ValidTestProjectDescription: "This is a sample project description used for automated contact form testing.",
|
|
429
|
+
ValidTestPreferredDate: "2026-09-15",
|
|
430
|
+
ValidTestPreferredTime: "10:00 AM",
|
|
351
431
|
LongNameCharacterCount: 250,
|
|
352
432
|
EmojiTestName: "Jane 😀 Doe",
|
|
353
433
|
PaddedTestEmail: " test@test.com ",
|
|
434
|
+
InvalidTestEmail: "not-an-email",
|
|
435
|
+
};
|
|
436
|
+
async function isFieldRequired(fieldLocator) {
|
|
437
|
+
const requiredAttribute = await fieldLocator.getAttribute("required");
|
|
438
|
+
const ariaRequired = await fieldLocator.getAttribute("aria-required");
|
|
439
|
+
const isMarkedRequired = requiredAttribute !== null || ariaRequired === "true";
|
|
440
|
+
return isMarkedRequired;
|
|
441
|
+
}
|
|
442
|
+
exports.FieldInteractions = {
|
|
443
|
+
Fill: "fill",
|
|
444
|
+
Datetime: "datetime",
|
|
354
445
|
};
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
446
|
+
/**
|
|
447
|
+
* Maps a detected field (by its label) to the valid test value used
|
|
448
|
+
* to fill it. Presence here does NOT imply the field is required —
|
|
449
|
+
* required-ness is determined dynamically per field via
|
|
450
|
+
* `isFieldRequired`. Email is the only field that is always required.
|
|
451
|
+
*/
|
|
452
|
+
exports.FieldValueMappings = [
|
|
453
|
+
{ label: /name/i, validValue: exports.ValidUserInfo.ValidTestName, interaction: exports.FieldInteractions.Fill },
|
|
454
|
+
{ label: /email/i, validValue: exports.ValidUserInfo.ValidTestEmail, interaction: exports.FieldInteractions.Fill },
|
|
455
|
+
{ label: /phone/i, validValue: exports.ValidUserInfo.ValidTestPhone, interaction: exports.FieldInteractions.Fill },
|
|
456
|
+
{ label: /company/i, validValue: exports.ValidUserInfo.ValidTestCompany, interaction: exports.FieldInteractions.Fill },
|
|
457
|
+
{
|
|
458
|
+
label: /project|description/i,
|
|
459
|
+
validValue: exports.ValidUserInfo.ValidTestProjectDescription,
|
|
460
|
+
interaction: exports.FieldInteractions.Fill,
|
|
461
|
+
},
|
|
462
|
+
{ label: /date/i, validValue: exports.ValidUserInfo.ValidTestPreferredDate, interaction: exports.FieldInteractions.Datetime },
|
|
463
|
+
{ label: /time/i, validValue: exports.ValidUserInfo.ValidTestPreferredTime, interaction: exports.FieldInteractions.Datetime },
|
|
359
464
|
];
|
|
360
|
-
|
|
361
|
-
|
|
465
|
+
// Some date/time fields are plain inputs (a direct .fill() works); others are picker widgets that
|
|
466
|
+
// open a popup and reject typed text. This tries the plain-input path first, and only falls back
|
|
467
|
+
// to interacting with a popup if the value didn't actually take. The fallback uses WAI-ARIA roles
|
|
468
|
+
// (grid/gridcell for calendar days, spinbutton for numeric steppers) rather than any specific UI
|
|
469
|
+
// library's class names, since this package runs against apps that don't all use the same one —
|
|
470
|
+
// it degrades to a no-op if neither the plain-input path nor these ARIA patterns match anything.
|
|
471
|
+
async function fillDateTimePicker(locator, fallbackValue) {
|
|
472
|
+
await locator.fill(fallbackValue).catch(() => {
|
|
473
|
+
return undefined;
|
|
474
|
+
});
|
|
475
|
+
const filledValue = await locator.inputValue().catch(() => {
|
|
476
|
+
return "";
|
|
477
|
+
});
|
|
478
|
+
if (filledValue.length === 0) {
|
|
479
|
+
await locator.click();
|
|
480
|
+
const page = locator.page();
|
|
481
|
+
const dayCells = page.locator('[role="gridcell"]:not([aria-disabled="true"])');
|
|
482
|
+
const dayCellCount = await dayCells.count();
|
|
483
|
+
for (let index = 0; index < dayCellCount; index++) {
|
|
484
|
+
const cell = dayCells.nth(index);
|
|
485
|
+
if (await cell.isVisible()) {
|
|
486
|
+
await cell.click();
|
|
487
|
+
await page.waitForTimeout(300);
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const spinbuttons = page.locator('[role="spinbutton"]');
|
|
492
|
+
const spinbuttonCount = await spinbuttons.count();
|
|
493
|
+
for (let index = 0; index < spinbuttonCount; index++) {
|
|
494
|
+
const button = spinbuttons.nth(index);
|
|
495
|
+
// Guards against the element detaching mid-animation, same as the day-cell loop above.
|
|
496
|
+
const isStillPresent = await button.isVisible().catch(() => {
|
|
497
|
+
return false;
|
|
498
|
+
});
|
|
499
|
+
if (isStillPresent) {
|
|
500
|
+
await button.click();
|
|
501
|
+
await page.keyboard.press("ArrowUp");
|
|
502
|
+
await page.waitForTimeout(100);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const confirmButton = page.getByRole("button", { name: /submit|confirm|apply|done|ok/i });
|
|
506
|
+
const confirmButtonCount = await confirmButton.count();
|
|
507
|
+
let isConfirmed = false;
|
|
508
|
+
for (let index = 0; index < confirmButtonCount; index++) {
|
|
509
|
+
const button = confirmButton.nth(index);
|
|
510
|
+
if (await button.isVisible()) {
|
|
511
|
+
await button.click();
|
|
512
|
+
isConfirmed = true;
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (!isConfirmed) {
|
|
517
|
+
await page.keyboard.press("Escape");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function fillFormFields(page) {
|
|
522
|
+
await Promise.all(exports.FieldValueMappings.map(async (field) => {
|
|
362
523
|
const locator = page.getByLabel(field.label);
|
|
363
524
|
if ((await locator.count()) > 0) {
|
|
364
|
-
|
|
525
|
+
if (field.interaction === exports.FieldInteractions.Datetime) {
|
|
526
|
+
await fillDateTimePicker(locator.first(), field.validValue);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
await locator.first().fill(field.validValue);
|
|
530
|
+
}
|
|
365
531
|
}
|
|
366
532
|
}));
|
|
367
533
|
}
|
|
@@ -378,7 +544,13 @@ async function hasVisibleErrorNearField(fieldLocator) {
|
|
|
378
544
|
const adjacentError = fieldLocator.page().locator(`[data-error-for="${fieldID}"]`);
|
|
379
545
|
isAdjacentErrorVisible = await adjacentError.isVisible();
|
|
380
546
|
}
|
|
381
|
-
|
|
547
|
+
// Native browser validation (e.g. the built-in "Please fill out this field" bubble triggered
|
|
548
|
+
// by the `required` attribute) isn't a queryable DOM node, so it can't be caught by the checks
|
|
549
|
+
// above — but the field's own validity state still reflects it.
|
|
550
|
+
const isNativelyInvalid = await fieldLocator.evaluate((element) => {
|
|
551
|
+
return !element.validity.valid && element.validationMessage !== "";
|
|
552
|
+
});
|
|
553
|
+
return isDescribedByErrorVisible || isAdjacentErrorVisible || isNativelyInvalid;
|
|
382
554
|
}
|
|
383
555
|
exports.InvalidInputCases = [
|
|
384
556
|
{ fieldLabel: /email/i, invalidValue: "notanemail", expectedErrorText: /enter a valid email address/i },
|
|
@@ -388,9 +560,26 @@ exports.InvalidInputCases = [
|
|
|
388
560
|
{ fieldLabel: /phone/i, invalidValue: "12", expectedErrorText: /enter a valid phone number/i },
|
|
389
561
|
];
|
|
390
562
|
async function fillOtherRequiredFieldsValidly(page, skipLabel) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
563
|
+
await Promise.all(exports.FieldValueMappings.map(async (field) => {
|
|
564
|
+
const isSkippedField = field.label.source === skipLabel.source;
|
|
565
|
+
const locator = page.getByLabel(field.label);
|
|
566
|
+
let isFieldPresent = false;
|
|
567
|
+
if (!isSkippedField) {
|
|
568
|
+
isFieldPresent = (await locator.count()) > 0;
|
|
569
|
+
}
|
|
570
|
+
let isRequired = false;
|
|
571
|
+
if (isFieldPresent) {
|
|
572
|
+
isRequired = await isFieldRequired(locator.first());
|
|
573
|
+
}
|
|
574
|
+
if (isRequired) {
|
|
575
|
+
if (field.interaction === exports.FieldInteractions.Datetime) {
|
|
576
|
+
await fillDateTimePicker(locator.first(), field.validValue);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
await locator.first().fill(field.validValue);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}));
|
|
394
583
|
}
|
|
395
584
|
exports.AutofillFields = [
|
|
396
585
|
{ label: /name/i, expectedAutocomplete: /name/i },
|
|
@@ -527,3 +716,108 @@ async function checkAllDots(carousel, carouselIndex) {
|
|
|
527
716
|
}
|
|
528
717
|
}
|
|
529
718
|
}
|
|
719
|
+
exports.SafeAreaZones = {
|
|
720
|
+
Top: "top",
|
|
721
|
+
Bottom: "bottom",
|
|
722
|
+
Left: "left",
|
|
723
|
+
Right: "right",
|
|
724
|
+
};
|
|
725
|
+
function getPadding(val) {
|
|
726
|
+
let numericValue = 0;
|
|
727
|
+
const parsed = Number.parseFloat(val);
|
|
728
|
+
if (!Number.isNaN(parsed)) {
|
|
729
|
+
numericValue = parsed;
|
|
730
|
+
}
|
|
731
|
+
return numericValue;
|
|
732
|
+
}
|
|
733
|
+
function isVisible(element) {
|
|
734
|
+
let visible;
|
|
735
|
+
const style = window.getComputedStyle(element);
|
|
736
|
+
if (style.display === "none" || style.visibility === "hidden" || Number.parseFloat(style.opacity) === 0) {
|
|
737
|
+
visible = false;
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
const rect = element.getBoundingClientRect();
|
|
741
|
+
const hasDimensions = rect.width > 0 && rect.height > 0;
|
|
742
|
+
const horizontalOverlap = Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0);
|
|
743
|
+
const verticalOverlap = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
|
|
744
|
+
visible = hasDimensions && horizontalOverlap > 0 && verticalOverlap > 0;
|
|
745
|
+
}
|
|
746
|
+
return visible;
|
|
747
|
+
}
|
|
748
|
+
async function findSafeAreaOverlaps(page) {
|
|
749
|
+
const contentSelector = `${exports.TappableElementsSelector}, p, h1, h2, h3, h4, h5, h6, li, span`;
|
|
750
|
+
return page.evaluate(({ selector }) => {
|
|
751
|
+
// Dynamically measure actual safe area insets from the browser environment
|
|
752
|
+
const div = document.createElement("div");
|
|
753
|
+
div.style.paddingTop = "env(safe-area-inset-top, 0px)";
|
|
754
|
+
div.style.paddingRight = "env(safe-area-inset-right, 0px)";
|
|
755
|
+
div.style.paddingBottom = "env(safe-area-inset-bottom, 0px)";
|
|
756
|
+
div.style.paddingLeft = "env(safe-area-inset-left, 0px)";
|
|
757
|
+
div.style.position = "absolute";
|
|
758
|
+
div.style.visibility = "hidden";
|
|
759
|
+
document.body.appendChild(div);
|
|
760
|
+
const computedStyle = window.getComputedStyle(div);
|
|
761
|
+
const { paddingTop } = computedStyle;
|
|
762
|
+
const { paddingRight } = computedStyle;
|
|
763
|
+
const { paddingBottom } = computedStyle;
|
|
764
|
+
const { paddingLeft } = computedStyle;
|
|
765
|
+
document.body.removeChild(div);
|
|
766
|
+
const insets = {
|
|
767
|
+
top: getPadding(paddingTop),
|
|
768
|
+
right: getPadding(paddingRight),
|
|
769
|
+
bottom: getPadding(paddingBottom),
|
|
770
|
+
left: getPadding(paddingLeft),
|
|
771
|
+
};
|
|
772
|
+
const overlaps = [];
|
|
773
|
+
const elements = Array.from(document.querySelectorAll(selector));
|
|
774
|
+
const visibleElements = elements.filter(isVisible);
|
|
775
|
+
for (const element of visibleElements) {
|
|
776
|
+
// Skip elements that are likely just layout containers or backgrounds if they don't have direct text
|
|
777
|
+
// (Though the selector restricts to text/tappable elements mostly)
|
|
778
|
+
const rect = element.getBoundingClientRect();
|
|
779
|
+
const style = window.getComputedStyle(element);
|
|
780
|
+
const pl = getPadding(style.paddingLeft);
|
|
781
|
+
const pr = getPadding(style.paddingRight);
|
|
782
|
+
const pt = getPadding(style.paddingTop);
|
|
783
|
+
const pb = getPadding(style.paddingBottom);
|
|
784
|
+
const contentLeft = rect.left + pl;
|
|
785
|
+
const contentRight = rect.right - pr;
|
|
786
|
+
const contentTop = rect.top + pt;
|
|
787
|
+
const contentBottom = rect.bottom - pb;
|
|
788
|
+
const viewportWidth = window.innerWidth;
|
|
789
|
+
const viewportHeight = window.innerHeight;
|
|
790
|
+
const isFixedOrSticky = style.position === "fixed" || style.position === "sticky";
|
|
791
|
+
const zones = [];
|
|
792
|
+
// Check if element content intersects with top safe area
|
|
793
|
+
if (insets.top > 0 && contentTop < insets.top && contentBottom > 0) {
|
|
794
|
+
zones.push("top");
|
|
795
|
+
}
|
|
796
|
+
// Check if element content intersects with bottom safe area
|
|
797
|
+
if (insets.bottom > 0 && contentBottom > viewportHeight - insets.bottom && contentTop < viewportHeight) {
|
|
798
|
+
// Normal static elements naturally scroll through the bottom safe area,
|
|
799
|
+
// so only flag fixed or sticky elements that overlap it on load.
|
|
800
|
+
if (isFixedOrSticky) {
|
|
801
|
+
zones.push("bottom");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// Check if element content intersects with left safe area
|
|
805
|
+
if (insets.left > 0 && contentLeft < insets.left && contentRight > 0) {
|
|
806
|
+
zones.push("left");
|
|
807
|
+
}
|
|
808
|
+
// Check if element content intersects with right safe area
|
|
809
|
+
if (insets.right > 0 && contentRight > viewportWidth - insets.right && contentLeft < viewportWidth) {
|
|
810
|
+
zones.push("right");
|
|
811
|
+
}
|
|
812
|
+
for (const zone of zones) {
|
|
813
|
+
overlaps.push({
|
|
814
|
+
tagName: element.tagName,
|
|
815
|
+
className: element.className,
|
|
816
|
+
text: element.textContent.trim().slice(0, 40),
|
|
817
|
+
overlapZone: zone,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return overlaps;
|
|
822
|
+
}, { selector: contentSelector });
|
|
823
|
+
}
|