@manufac/general-tests 0.0.1-alpha
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 +58 -0
- package/dist/config/projects.d.ts +41 -0
- package/dist/config/projects.js +37 -0
- package/dist/fixtures/index.d.ts +5 -0
- package/dist/fixtures/index.js +27 -0
- package/dist/schema/index.d.ts +5 -0
- package/dist/schema/index.js +7 -0
- package/dist/specs/desktop-only/browser-zoom.spec.d.ts +1 -0
- package/dist/specs/desktop-only/browser-zoom.spec.js +18 -0
- package/dist/specs/mobile-only/navigation.spec.d.ts +1 -0
- package/dist/specs/mobile-only/navigation.spec.js +30 -0
- package/dist/specs/mobile-only/orientation.spec.d.ts +1 -0
- package/dist/specs/mobile-only/orientation.spec.js +43 -0
- package/dist/specs/mobile-only/readability.spec.d.ts +1 -0
- package/dist/specs/mobile-only/readability.spec.js +14 -0
- package/dist/specs/mobile-only/soft-keyboard.spec.d.ts +1 -0
- package/dist/specs/mobile-only/soft-keyboard.spec.js +34 -0
- package/dist/specs/mobile-only/touch-targets.spec.d.ts +1 -0
- package/dist/specs/mobile-only/touch-targets.spec.js +12 -0
- package/dist/specs/shared/cta.spec.d.ts +1 -0
- package/dist/specs/shared/cta.spec.js +199 -0
- package/dist/specs/shared/forms/autofill.spec.d.ts +1 -0
- package/dist/specs/shared/forms/autofill.spec.js +16 -0
- package/dist/specs/shared/forms/double-submit.spec.d.ts +1 -0
- package/dist/specs/shared/forms/double-submit.spec.js +39 -0
- package/dist/specs/shared/forms/input-edge.spec.d.ts +1 -0
- package/dist/specs/shared/forms/input-edge.spec.js +46 -0
- package/dist/specs/shared/forms/invalid-input.spec.d.ts +1 -0
- package/dist/specs/shared/forms/invalid-input.spec.js +24 -0
- package/dist/specs/shared/forms/required-field.spec.d.ts +1 -0
- package/dist/specs/shared/forms/required-field.spec.js +27 -0
- package/dist/specs/shared/forms/thankyou-confirm.spec.d.ts +1 -0
- package/dist/specs/shared/forms/thankyou-confirm.spec.js +25 -0
- package/dist/specs/shared/hero-fold.spec.d.ts +1 -0
- package/dist/specs/shared/hero-fold.spec.js +21 -0
- package/dist/specs/shared/image-quality.spec.d.ts +1 -0
- package/dist/specs/shared/image-quality.spec.js +13 -0
- package/dist/specs/shared/landing-page.spec.d.ts +1 -0
- package/dist/specs/shared/landing-page.spec.js +84 -0
- package/dist/specs/shared/media-embeds/carousel-slider.spec.d.ts +1 -0
- package/dist/specs/shared/media-embeds/carousel-slider.spec.js +31 -0
- package/dist/specs/shared/media-embeds/cookie-consent.spec.d.ts +1 -0
- package/dist/specs/shared/media-embeds/cookie-consent.spec.js +27 -0
- package/dist/specs/shared/media-embeds/third-party-embeds.spec.d.ts +1 -0
- package/dist/specs/shared/media-embeds/third-party-embeds.spec.js +24 -0
- package/dist/specs/shared/media-embeds/video-playback.spec.d.ts +1 -0
- package/dist/specs/shared/media-embeds/video-playback.spec.js +54 -0
- package/dist/specs/shared/sticky-elements.spec.d.ts +1 -0
- package/dist/specs/shared/sticky-elements.spec.js +13 -0
- package/dist/specs/sticky-utils.d.ts +13 -0
- package/dist/specs/sticky-utils.js +158 -0
- package/dist/specs/utils.d.ts +119 -0
- package/dist/specs/utils.js +529 -0
- package/package.json +30 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Locator, Page } from "@playwright/test";
|
|
2
|
+
export declare const ResponsiveConfig: {
|
|
3
|
+
readonly ScrollSteps: 10;
|
|
4
|
+
readonly ScrollStepDelayMs: 100;
|
|
5
|
+
readonly ViewPortWidth: 1440;
|
|
6
|
+
readonly ViewPortHeight: 900;
|
|
7
|
+
readonly ZoomLevel: "200%";
|
|
8
|
+
readonly ZoomSettleDelayMs: 200;
|
|
9
|
+
readonly UpscaleTolerance: 1.05;
|
|
10
|
+
readonly OversizeTolerance: 2;
|
|
11
|
+
readonly ScrollDriftTolerance: 0.15;
|
|
12
|
+
readonly MinBodyTextFontSizePX: 12;
|
|
13
|
+
readonly PinFlickerTolerancePX: 2;
|
|
14
|
+
readonly MinTouchTargetSizePX: 44;
|
|
15
|
+
readonly KeyboardOcclusionRatio: 0.4;
|
|
16
|
+
};
|
|
17
|
+
export interface ScrollMetrics {
|
|
18
|
+
scrollHeight: number;
|
|
19
|
+
scrollY: number;
|
|
20
|
+
clientHeight: number;
|
|
21
|
+
}
|
|
22
|
+
export interface Viewport {
|
|
23
|
+
name: string;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ElementDescriptor {
|
|
28
|
+
tagName: string;
|
|
29
|
+
className: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function describeElement(descriptor: ElementDescriptor): string;
|
|
32
|
+
export interface ElementOverlap {
|
|
33
|
+
elementA: ElementDescriptor;
|
|
34
|
+
elementB: ElementDescriptor;
|
|
35
|
+
overlapWidth: number;
|
|
36
|
+
overlapHeight: number;
|
|
37
|
+
}
|
|
38
|
+
export declare const TappableElementsSelector: string;
|
|
39
|
+
export declare const TouchTargetSizeSelector: string;
|
|
40
|
+
export declare function getScrollMetrics(page: Page): Promise<ScrollMetrics>;
|
|
41
|
+
export declare function scrollThroughPage(page: Page): Promise<void>;
|
|
42
|
+
export declare function findTruncatedOrOverflowingText(page: Page): Promise<string[]>;
|
|
43
|
+
export declare function findOverlappingElements(page: Page, containerSelector: string): Promise<string[]>;
|
|
44
|
+
export declare function findOverlappingTapTargets(page: Page): Promise<string[]>;
|
|
45
|
+
export declare const ContentOverlapSelector: string;
|
|
46
|
+
export interface ImageSizeIssue {
|
|
47
|
+
src: string;
|
|
48
|
+
naturalWidth: number;
|
|
49
|
+
naturalHeight: number;
|
|
50
|
+
renderedWidth: number;
|
|
51
|
+
renderedHeight: number;
|
|
52
|
+
devicePixelRatio: number;
|
|
53
|
+
}
|
|
54
|
+
export declare function findImageSizeIssues(page: Page): Promise<ImageSizeIssue[]>;
|
|
55
|
+
export declare function isImageUpscaled(image: ImageSizeIssue): boolean;
|
|
56
|
+
export declare function isImageOversized(image: ImageSizeIssue): boolean;
|
|
57
|
+
export interface ElementRect {
|
|
58
|
+
left: number;
|
|
59
|
+
right: number;
|
|
60
|
+
top: number;
|
|
61
|
+
bottom: number;
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
}
|
|
65
|
+
export interface ViewportSize {
|
|
66
|
+
width: number;
|
|
67
|
+
height: number;
|
|
68
|
+
}
|
|
69
|
+
export declare function isCutoffAtViewportEdge(rect: ElementRect, viewport: ViewportSize): boolean;
|
|
70
|
+
export declare function findElementsCutoffAtViewportEdge(page: Page, containerSelector?: string): Promise<string[]>;
|
|
71
|
+
export declare function findUndersizedBodyText(page: Page): Promise<string[]>;
|
|
72
|
+
export declare function findUndersizedTouchTargets(page: Page, containerSelector: string): Promise<string[]>;
|
|
73
|
+
export declare function hasHorizontalScrollbar(page: Page): Promise<boolean>;
|
|
74
|
+
export declare function findCollapsedEmbeds(page: Page): Promise<string[]>;
|
|
75
|
+
export declare function isVideoAutoplaying(video: Locator): Promise<boolean>;
|
|
76
|
+
export declare function isVideoMuted(video: Locator): Promise<boolean>;
|
|
77
|
+
export declare const ValidUserInfo: {
|
|
78
|
+
readonly ValidTestName: "Jane Doe";
|
|
79
|
+
readonly ValidTestEmail: "test@test.com";
|
|
80
|
+
readonly ValidTestPhone: "9876543210";
|
|
81
|
+
readonly LongNameCharacterCount: 250;
|
|
82
|
+
readonly EmojiTestName: "Jane 😀 Doe";
|
|
83
|
+
readonly PaddedTestEmail: " test@test.com ";
|
|
84
|
+
};
|
|
85
|
+
export interface RequiredField {
|
|
86
|
+
label: RegExp;
|
|
87
|
+
validValue: string;
|
|
88
|
+
}
|
|
89
|
+
export declare const RequiredFields: RequiredField[];
|
|
90
|
+
export declare function fillAllFieldsValidly(page: Page): Promise<void>;
|
|
91
|
+
export declare function hasVisibleErrorNearField(fieldLocator: Locator): Promise<boolean>;
|
|
92
|
+
export interface InvalidInputCase {
|
|
93
|
+
fieldLabel: RegExp;
|
|
94
|
+
invalidValue: string;
|
|
95
|
+
expectedErrorText: RegExp;
|
|
96
|
+
}
|
|
97
|
+
export declare const InvalidInputCases: InvalidInputCase[];
|
|
98
|
+
export declare function fillOtherRequiredFieldsValidly(page: Page, skipLabel: RegExp): Promise<void>;
|
|
99
|
+
export interface AutofillField {
|
|
100
|
+
label: RegExp;
|
|
101
|
+
expectedAutocomplete: RegExp;
|
|
102
|
+
}
|
|
103
|
+
export declare const AutofillFields: AutofillField[];
|
|
104
|
+
export interface KeyboardField {
|
|
105
|
+
label: RegExp;
|
|
106
|
+
expectedInputMode: RegExp;
|
|
107
|
+
}
|
|
108
|
+
export declare const KeyboardFields: KeyboardField[];
|
|
109
|
+
export declare function getActiveSlideIndex(carousel: Locator): Promise<number>;
|
|
110
|
+
export declare function getTotalSlideCount(carousel: Locator): Promise<number>;
|
|
111
|
+
export declare function hasEmptySlideFrame(carousel: Locator): Promise<boolean>;
|
|
112
|
+
export declare function goToNextSlideViaArrow(carousel: Locator): Promise<boolean>;
|
|
113
|
+
export declare function getDotCount(carousel: Locator): Promise<number>;
|
|
114
|
+
export declare function goToSlideViaDot(carousel: Locator, dotIndex: number): Promise<boolean>;
|
|
115
|
+
export declare function swipeToNextSlide(carousel: Locator): Promise<boolean>;
|
|
116
|
+
export declare function swipeToPreviousSlide(carousel: Locator): Promise<boolean>;
|
|
117
|
+
export declare function traverseSlidesForward(carousel: Locator, carouselIndex: number, isMobile: boolean): Promise<void>;
|
|
118
|
+
export declare function checkSwipeBackward(carousel: Locator, carouselIndex: number): Promise<void>;
|
|
119
|
+
export declare function checkAllDots(carousel: Locator, carouselIndex: number): Promise<void>;
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KeyboardFields = exports.AutofillFields = exports.InvalidInputCases = exports.RequiredFields = 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.findOverlappingTapTargets = findOverlappingTapTargets;
|
|
10
|
+
exports.findImageSizeIssues = findImageSizeIssues;
|
|
11
|
+
exports.isImageUpscaled = isImageUpscaled;
|
|
12
|
+
exports.isImageOversized = isImageOversized;
|
|
13
|
+
exports.isCutoffAtViewportEdge = isCutoffAtViewportEdge;
|
|
14
|
+
exports.findElementsCutoffAtViewportEdge = findElementsCutoffAtViewportEdge;
|
|
15
|
+
exports.findUndersizedBodyText = findUndersizedBodyText;
|
|
16
|
+
exports.findUndersizedTouchTargets = findUndersizedTouchTargets;
|
|
17
|
+
exports.hasHorizontalScrollbar = hasHorizontalScrollbar;
|
|
18
|
+
exports.findCollapsedEmbeds = findCollapsedEmbeds;
|
|
19
|
+
exports.isVideoAutoplaying = isVideoAutoplaying;
|
|
20
|
+
exports.isVideoMuted = isVideoMuted;
|
|
21
|
+
exports.fillAllFieldsValidly = fillAllFieldsValidly;
|
|
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
|
+
const test_1 = require("@playwright/test");
|
|
36
|
+
exports.ResponsiveConfig = {
|
|
37
|
+
ScrollSteps: 10,
|
|
38
|
+
ScrollStepDelayMs: 100,
|
|
39
|
+
ViewPortWidth: 1440,
|
|
40
|
+
ViewPortHeight: 900,
|
|
41
|
+
ZoomLevel: "200%",
|
|
42
|
+
ZoomSettleDelayMs: 200,
|
|
43
|
+
UpscaleTolerance: 1.05,
|
|
44
|
+
OversizeTolerance: 2,
|
|
45
|
+
ScrollDriftTolerance: 0.15,
|
|
46
|
+
MinBodyTextFontSizePX: 12,
|
|
47
|
+
PinFlickerTolerancePX: 2,
|
|
48
|
+
MinTouchTargetSizePX: 44,
|
|
49
|
+
KeyboardOcclusionRatio: 0.4,
|
|
50
|
+
};
|
|
51
|
+
// Formats a descriptor captured inside page.evaluate (see findElementsCutoffAtViewportEdge and
|
|
52
|
+
// findUndersizedBodyText) into a readable tag selector, e.g. <p.hero.subtitle>.
|
|
53
|
+
function describeElement(descriptor) {
|
|
54
|
+
const rawClassName = descriptor.className.trim();
|
|
55
|
+
const className = rawClassName.length > 0 ? `.${rawClassName.replace(/\s+/g, ".")}` : "";
|
|
56
|
+
return `<${descriptor.tagName.toLowerCase()}${className}>`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* References for summary and details elements:
|
|
60
|
+
* - https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/summary
|
|
61
|
+
* - https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/details
|
|
62
|
+
*/
|
|
63
|
+
const TappableElementsList = [
|
|
64
|
+
"a",
|
|
65
|
+
"button",
|
|
66
|
+
"select",
|
|
67
|
+
"summary",
|
|
68
|
+
'input[type="submit"]',
|
|
69
|
+
'input[type="button"]',
|
|
70
|
+
'input[type="reset"]',
|
|
71
|
+
'input[type="checkbox"]',
|
|
72
|
+
'input[type="radio"]',
|
|
73
|
+
"label[for]",
|
|
74
|
+
'[role="button"]',
|
|
75
|
+
'[role="link"]',
|
|
76
|
+
'[role="tab"]',
|
|
77
|
+
'[role="menuitem"]',
|
|
78
|
+
'[role="option"]',
|
|
79
|
+
'[role="switch"]',
|
|
80
|
+
'[role="checkbox"]',
|
|
81
|
+
'[role="radio"]',
|
|
82
|
+
'[tabindex="0"]',
|
|
83
|
+
];
|
|
84
|
+
exports.TappableElementsSelector = TappableElementsList.join(", ");
|
|
85
|
+
// Native checkbox/radio inputs render at a small, fixed OS size regardless of page styling — their
|
|
86
|
+
// real tappable target is normally the paired <label for="...">, already covered separately here.
|
|
87
|
+
// Excluded from the size check (but not the overlap check) so a standard HTML form doesn't fail
|
|
88
|
+
// on every single page just for using unstyled checkboxes/radios.
|
|
89
|
+
const SizeExemptTappableSelectors = new Set(['input[type="checkbox"]', 'input[type="radio"]']);
|
|
90
|
+
exports.TouchTargetSizeSelector = TappableElementsList.filter((selector) => {
|
|
91
|
+
return !SizeExemptTappableSelectors.has(selector);
|
|
92
|
+
}).join(", ");
|
|
93
|
+
async function getScrollMetrics(page) {
|
|
94
|
+
return page.evaluate(() => {
|
|
95
|
+
return {
|
|
96
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
97
|
+
scrollY: window.scrollY,
|
|
98
|
+
clientHeight: document.documentElement.clientHeight,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function scrollThroughPage(page) {
|
|
103
|
+
const { scrollHeight } = await getScrollMetrics(page);
|
|
104
|
+
const scrollStepDelta = Math.floor(scrollHeight / exports.ResponsiveConfig.ScrollSteps);
|
|
105
|
+
for (let step = 0; step <= exports.ResponsiveConfig.ScrollSteps; step++) {
|
|
106
|
+
await page.mouse.wheel(0, scrollStepDelta);
|
|
107
|
+
await page.waitForTimeout(exports.ResponsiveConfig.ScrollStepDelayMs);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function findTruncatedOrOverflowingText(page) {
|
|
111
|
+
return page.evaluate(() => {
|
|
112
|
+
const broken = [];
|
|
113
|
+
const elements = Array.from(document.querySelectorAll("p, li, td, th, dt, dd, blockquote, figcaption, h1, h2, h3, h4, h5, h6"));
|
|
114
|
+
for (const element of elements) {
|
|
115
|
+
const text = element.textContent.trim();
|
|
116
|
+
if (text.length === 0)
|
|
117
|
+
continue;
|
|
118
|
+
const style = window.getComputedStyle(element);
|
|
119
|
+
const isEllipsisTruncated = style.textOverflow === "ellipsis" && style.overflow !== "visible";
|
|
120
|
+
const isOverflowingHorizontally = element.scrollWidth > element.clientWidth + 1;
|
|
121
|
+
if (isEllipsisTruncated || isOverflowingHorizontally) {
|
|
122
|
+
broken.push(`<${element.tagName.toLowerCase()} class="${element.className}"> overflow=${style.overflow}, textOverflow=${style.textOverflow}: "${text.slice(0, 40)}"`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return broken;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function findOverlappingElements(page, containerSelector) {
|
|
129
|
+
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
|
+
function isVisibleInViewport(element) {
|
|
134
|
+
const rect = element.getBoundingClientRect();
|
|
135
|
+
const hasDimensions = rect.width > 0 && rect.height > 0;
|
|
136
|
+
const horizontalOverlap = Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0);
|
|
137
|
+
const verticalOverlap = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
|
|
138
|
+
return hasDimensions && horizontalOverlap > 0 && verticalOverlap > 0;
|
|
139
|
+
}
|
|
140
|
+
const conflicts = [];
|
|
141
|
+
const elements = Array.from(document.querySelectorAll(selector)).filter(isVisibleInViewport);
|
|
142
|
+
for (let i = 0; i < elements.length; i++) {
|
|
143
|
+
for (let index = i + 1; index < elements.length; index++) {
|
|
144
|
+
const a = elements[i];
|
|
145
|
+
const b = elements[index];
|
|
146
|
+
if (a.contains(b) || b.contains(a))
|
|
147
|
+
continue;
|
|
148
|
+
const rectA = a.getBoundingClientRect();
|
|
149
|
+
const rectB = b.getBoundingClientRect();
|
|
150
|
+
const overlapWidth = Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left);
|
|
151
|
+
const overlapHeight = Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top);
|
|
152
|
+
if (overlapWidth > 0 && overlapHeight > 0) {
|
|
153
|
+
conflicts.push({
|
|
154
|
+
elementA: { tagName: a.tagName, className: a.className },
|
|
155
|
+
elementB: { tagName: b.tagName, className: b.className },
|
|
156
|
+
overlapWidth,
|
|
157
|
+
overlapHeight,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return conflicts;
|
|
163
|
+
}, containerSelector);
|
|
164
|
+
return overlaps.map((overlap) => {
|
|
165
|
+
return `${overlap.elementA.tagName} overlaps ${overlap.elementB.tagName} by ${overlap.overlapWidth.toFixed(0)}x${overlap.overlapHeight.toFixed(0)}px`;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async function findOverlappingTapTargets(page) {
|
|
169
|
+
return findOverlappingElements(page, exports.TappableElementsSelector);
|
|
170
|
+
}
|
|
171
|
+
// Content elements worth checking for overlap after a layout-affecting change (e.g. orientation
|
|
172
|
+
// change): tappable elements, text-bearing elements, and images.
|
|
173
|
+
exports.ContentOverlapSelector = [
|
|
174
|
+
exports.TappableElementsSelector,
|
|
175
|
+
"p",
|
|
176
|
+
"li",
|
|
177
|
+
"td",
|
|
178
|
+
"th",
|
|
179
|
+
"dt",
|
|
180
|
+
"dd",
|
|
181
|
+
"blockquote",
|
|
182
|
+
"figcaption",
|
|
183
|
+
"h1",
|
|
184
|
+
"h2",
|
|
185
|
+
"h3",
|
|
186
|
+
"h4",
|
|
187
|
+
"h5",
|
|
188
|
+
"h6",
|
|
189
|
+
"img",
|
|
190
|
+
].join(", ");
|
|
191
|
+
async function findImageSizeIssues(page) {
|
|
192
|
+
return page.evaluate(() => {
|
|
193
|
+
const images = Array.from(document.querySelectorAll("img"));
|
|
194
|
+
const issues = images
|
|
195
|
+
.map((image) => {
|
|
196
|
+
let issue = null;
|
|
197
|
+
const rect = image.getBoundingClientRect();
|
|
198
|
+
const isRendered = rect.width > 0 && rect.height > 0;
|
|
199
|
+
const hasNaturalSize = image.naturalWidth > 0 && image.naturalHeight > 0;
|
|
200
|
+
if (isRendered && hasNaturalSize) {
|
|
201
|
+
issue = {
|
|
202
|
+
src: image.currentSrc,
|
|
203
|
+
naturalWidth: image.naturalWidth,
|
|
204
|
+
naturalHeight: image.naturalHeight,
|
|
205
|
+
renderedWidth: rect.width,
|
|
206
|
+
renderedHeight: rect.height,
|
|
207
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return issue;
|
|
211
|
+
})
|
|
212
|
+
.filter((entry) => {
|
|
213
|
+
return entry !== null;
|
|
214
|
+
});
|
|
215
|
+
return issues;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function isImageUpscaled(image) {
|
|
219
|
+
const isWidthUpscaled = image.renderedWidth * image.devicePixelRatio > image.naturalWidth * exports.ResponsiveConfig.UpscaleTolerance;
|
|
220
|
+
const isHeightUpscaled = image.renderedHeight * image.devicePixelRatio > image.naturalHeight * exports.ResponsiveConfig.UpscaleTolerance;
|
|
221
|
+
return isWidthUpscaled || isHeightUpscaled;
|
|
222
|
+
}
|
|
223
|
+
function isImageOversized(image) {
|
|
224
|
+
const isWidthOversized = image.naturalWidth > image.renderedWidth * image.devicePixelRatio * exports.ResponsiveConfig.OversizeTolerance;
|
|
225
|
+
const isHeightOversized = image.naturalHeight > image.renderedHeight * image.devicePixelRatio * exports.ResponsiveConfig.OversizeTolerance;
|
|
226
|
+
return isWidthOversized || isHeightOversized;
|
|
227
|
+
}
|
|
228
|
+
// Cut off means straddling the viewport edge (partially visible), not positioned entirely
|
|
229
|
+
// outside it — off-canvas nav drawers, upcoming carousel slides, and visually-hidden
|
|
230
|
+
// screen-reader text are intentionally placed fully outside the viewport and are not bugs.
|
|
231
|
+
// Pure geometry (no DOM APIs), so it runs in Node against a rect snapshot taken in the browser.
|
|
232
|
+
function isCutoffAtViewportEdge(rect, viewport) {
|
|
233
|
+
const hasDimensions = rect.width > 0 && rect.height > 0;
|
|
234
|
+
const horizontalOverlap = Math.min(rect.right, viewport.width) - Math.max(rect.left, 0);
|
|
235
|
+
const verticalOverlap = Math.min(rect.bottom, viewport.height) - Math.max(rect.top, 0);
|
|
236
|
+
const overlapsViewport = horizontalOverlap > 0 && verticalOverlap > 0;
|
|
237
|
+
const fullyInsideViewport = rect.left >= 0 && rect.right <= viewport.width && rect.top >= 0 && rect.bottom <= viewport.height;
|
|
238
|
+
return hasDimensions && overlapsViewport && !fullyInsideViewport;
|
|
239
|
+
}
|
|
240
|
+
async function findElementsCutoffAtViewportEdge(page, containerSelector = "body *") {
|
|
241
|
+
const { elements, viewport } = await page.evaluate((selector) => {
|
|
242
|
+
function isRenderedElement(element) {
|
|
243
|
+
const style = window.getComputedStyle(element);
|
|
244
|
+
return style.display !== "none" && style.visibility !== "hidden" && Number.parseFloat(style.opacity) !== 0;
|
|
245
|
+
}
|
|
246
|
+
const renderedElements = Array.from(document.querySelectorAll(selector))
|
|
247
|
+
.filter(isRenderedElement)
|
|
248
|
+
.map((element) => {
|
|
249
|
+
const rect = element.getBoundingClientRect();
|
|
250
|
+
return {
|
|
251
|
+
tagName: element.tagName,
|
|
252
|
+
className: element instanceof HTMLElement ? element.className : "",
|
|
253
|
+
rect: {
|
|
254
|
+
left: rect.left,
|
|
255
|
+
right: rect.right,
|
|
256
|
+
top: rect.top,
|
|
257
|
+
bottom: rect.bottom,
|
|
258
|
+
width: rect.width,
|
|
259
|
+
height: rect.height,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
return { elements: renderedElements, viewport: { width: window.innerWidth, height: window.innerHeight } };
|
|
264
|
+
}, containerSelector);
|
|
265
|
+
return elements
|
|
266
|
+
.filter((element) => {
|
|
267
|
+
return isCutoffAtViewportEdge(element.rect, viewport);
|
|
268
|
+
})
|
|
269
|
+
.map(describeElement);
|
|
270
|
+
}
|
|
271
|
+
// Returns a descriptor (not the element itself) rather than returning it directly: page.evaluate's
|
|
272
|
+
// return value is JSON-serialized across the browser/Node boundary, and a DOM element has no own
|
|
273
|
+
// enumerable properties, so it would come back as an empty object instead of a usable reference.
|
|
274
|
+
async function findUndersizedBodyText(page) {
|
|
275
|
+
const undersizedElements = await page.evaluate((minFontSizePX) => {
|
|
276
|
+
const elements = Array.from(document.querySelectorAll("p, li, td, th, dt, dd, blockquote, figcaption"));
|
|
277
|
+
return elements.reduce((undersized, element) => {
|
|
278
|
+
const text = element.textContent.trim();
|
|
279
|
+
const fontSizePX = Number.parseFloat(window.getComputedStyle(element).fontSize);
|
|
280
|
+
if (text.length > 0 && fontSizePX < minFontSizePX) {
|
|
281
|
+
undersized.push({ tagName: element.tagName, className: element.className, fontSizePX });
|
|
282
|
+
}
|
|
283
|
+
return undersized;
|
|
284
|
+
}, []);
|
|
285
|
+
}, exports.ResponsiveConfig.MinBodyTextFontSizePX);
|
|
286
|
+
return undersizedElements.map((element) => {
|
|
287
|
+
return `${describeElement(element)} (${String(element.fontSizePX)}px)`;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
async function findUndersizedTouchTargets(page, containerSelector) {
|
|
291
|
+
const undersizedElements = await page.evaluate(({ selector, minSizePX }) => {
|
|
292
|
+
function isRendered(element) {
|
|
293
|
+
const style = window.getComputedStyle(element);
|
|
294
|
+
return style.display !== "none" && style.visibility !== "hidden" && Number.parseFloat(style.opacity) !== 0;
|
|
295
|
+
}
|
|
296
|
+
return Array.from(document.querySelectorAll(selector))
|
|
297
|
+
.filter(isRendered)
|
|
298
|
+
.map((element) => {
|
|
299
|
+
const rect = element.getBoundingClientRect();
|
|
300
|
+
return {
|
|
301
|
+
tagName: element.tagName,
|
|
302
|
+
className: element.className,
|
|
303
|
+
width: rect.width,
|
|
304
|
+
height: rect.height,
|
|
305
|
+
text: element.textContent.trim().slice(0, 40),
|
|
306
|
+
};
|
|
307
|
+
})
|
|
308
|
+
.filter((entry) => {
|
|
309
|
+
const hasDimensions = entry.width > 0 && entry.height > 0;
|
|
310
|
+
return hasDimensions && (entry.width < minSizePX || entry.height < minSizePX);
|
|
311
|
+
});
|
|
312
|
+
}, { selector: containerSelector, minSizePX: exports.ResponsiveConfig.MinTouchTargetSizePX });
|
|
313
|
+
return undersizedElements.map((element) => {
|
|
314
|
+
return `${describeElement(element)} is ${element.width.toFixed(0)}x${element.height.toFixed(0)}px: "${element.text}"`;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async function hasHorizontalScrollbar(page) {
|
|
318
|
+
return page.evaluate(() => {
|
|
319
|
+
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
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
|
+
async function isVideoAutoplaying(video) {
|
|
337
|
+
const isPaused = await video.evaluate((element) => {
|
|
338
|
+
return element.paused;
|
|
339
|
+
});
|
|
340
|
+
return !isPaused;
|
|
341
|
+
}
|
|
342
|
+
async function isVideoMuted(video) {
|
|
343
|
+
return video.evaluate((element) => {
|
|
344
|
+
return element.muted;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
exports.ValidUserInfo = {
|
|
348
|
+
ValidTestName: "Jane Doe",
|
|
349
|
+
ValidTestEmail: "test@test.com",
|
|
350
|
+
ValidTestPhone: "9876543210",
|
|
351
|
+
LongNameCharacterCount: 250,
|
|
352
|
+
EmojiTestName: "Jane 😀 Doe",
|
|
353
|
+
PaddedTestEmail: " test@test.com ",
|
|
354
|
+
};
|
|
355
|
+
exports.RequiredFields = [
|
|
356
|
+
{ label: /name/i, validValue: exports.ValidUserInfo.ValidTestName },
|
|
357
|
+
{ label: /email/i, validValue: exports.ValidUserInfo.ValidTestEmail },
|
|
358
|
+
{ label: /phone/i, validValue: exports.ValidUserInfo.ValidTestPhone },
|
|
359
|
+
];
|
|
360
|
+
async function fillAllFieldsValidly(page) {
|
|
361
|
+
await Promise.all(exports.RequiredFields.map(async (field) => {
|
|
362
|
+
const locator = page.getByLabel(field.label);
|
|
363
|
+
if ((await locator.count()) > 0) {
|
|
364
|
+
await locator.first().fill(field.validValue);
|
|
365
|
+
}
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
async function hasVisibleErrorNearField(fieldLocator) {
|
|
369
|
+
const fieldID = await fieldLocator.getAttribute("id");
|
|
370
|
+
const describedBy = await fieldLocator.getAttribute("aria-describedby");
|
|
371
|
+
let isDescribedByErrorVisible = false;
|
|
372
|
+
if (describedBy !== null) {
|
|
373
|
+
const describedByLocator = fieldLocator.page().locator(`#${describedBy}`);
|
|
374
|
+
isDescribedByErrorVisible = await describedByLocator.isVisible();
|
|
375
|
+
}
|
|
376
|
+
let isAdjacentErrorVisible = false;
|
|
377
|
+
if (fieldID !== null) {
|
|
378
|
+
const adjacentError = fieldLocator.page().locator(`[data-error-for="${fieldID}"]`);
|
|
379
|
+
isAdjacentErrorVisible = await adjacentError.isVisible();
|
|
380
|
+
}
|
|
381
|
+
return isDescribedByErrorVisible || isAdjacentErrorVisible;
|
|
382
|
+
}
|
|
383
|
+
exports.InvalidInputCases = [
|
|
384
|
+
{ fieldLabel: /email/i, invalidValue: "notanemail", expectedErrorText: /enter a valid email address/i },
|
|
385
|
+
{ fieldLabel: /email/i, invalidValue: "test@", expectedErrorText: /enter a valid email address/i },
|
|
386
|
+
{ fieldLabel: /email/i, invalidValue: "@test.com", expectedErrorText: /enter a valid email address/i },
|
|
387
|
+
{ fieldLabel: /phone/i, invalidValue: "abc", expectedErrorText: /enter a valid phone number/i },
|
|
388
|
+
{ fieldLabel: /phone/i, invalidValue: "12", expectedErrorText: /enter a valid phone number/i },
|
|
389
|
+
];
|
|
390
|
+
async function fillOtherRequiredFieldsValidly(page, skipLabel) {
|
|
391
|
+
if (!/name/i.test(skipLabel.source)) {
|
|
392
|
+
await page.getByLabel(/name/i).fill(exports.ValidUserInfo.ValidTestName);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
exports.AutofillFields = [
|
|
396
|
+
{ label: /name/i, expectedAutocomplete: /name/i },
|
|
397
|
+
{ label: /email/i, expectedAutocomplete: /email/i },
|
|
398
|
+
{ label: /phone/i, expectedAutocomplete: /tel/i },
|
|
399
|
+
];
|
|
400
|
+
exports.KeyboardFields = [
|
|
401
|
+
{ label: /email/i, expectedInputMode: /email/i },
|
|
402
|
+
{ label: /phone/i, expectedInputMode: /tel|numeric/i },
|
|
403
|
+
];
|
|
404
|
+
const SlideSelector = '[data-slide], [role="group"], .slide';
|
|
405
|
+
const DotSelector = '[role="tab"], .dot, [data-dot]';
|
|
406
|
+
async function getActiveSlideIndex(carousel) {
|
|
407
|
+
return carousel.evaluate((element, selector) => {
|
|
408
|
+
const slides = Array.from(element.querySelectorAll(selector));
|
|
409
|
+
return slides.findIndex((slide) => {
|
|
410
|
+
return slide.getAttribute("aria-hidden") !== "true";
|
|
411
|
+
});
|
|
412
|
+
}, SlideSelector);
|
|
413
|
+
}
|
|
414
|
+
async function getTotalSlideCount(carousel) {
|
|
415
|
+
return carousel.evaluate((element, selector) => {
|
|
416
|
+
return element.querySelectorAll(selector).length;
|
|
417
|
+
}, SlideSelector);
|
|
418
|
+
}
|
|
419
|
+
async function hasEmptySlideFrame(carousel) {
|
|
420
|
+
return carousel.evaluate((element, selector) => {
|
|
421
|
+
const visibleSlides = Array.from(element.querySelectorAll(selector)).filter((slide) => {
|
|
422
|
+
return slide.getAttribute("aria-hidden") !== "true";
|
|
423
|
+
});
|
|
424
|
+
const isEveryVisibleSlideBlank = visibleSlides.every((slide) => {
|
|
425
|
+
return slide.textContent.trim() === "";
|
|
426
|
+
});
|
|
427
|
+
return visibleSlides.length === 0 || isEveryVisibleSlideBlank;
|
|
428
|
+
}, SlideSelector);
|
|
429
|
+
}
|
|
430
|
+
async function goToNextSlideViaArrow(carousel) {
|
|
431
|
+
const nextArrow = carousel.getByRole("button", { name: /next/i });
|
|
432
|
+
const arrowCount = await nextArrow.count();
|
|
433
|
+
let didAdvance = false;
|
|
434
|
+
if (arrowCount > 0) {
|
|
435
|
+
const isEnabled = await nextArrow.isEnabled();
|
|
436
|
+
if (isEnabled) {
|
|
437
|
+
await nextArrow.click();
|
|
438
|
+
didAdvance = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return didAdvance;
|
|
442
|
+
}
|
|
443
|
+
async function getDotCount(carousel) {
|
|
444
|
+
return carousel.locator(DotSelector).count();
|
|
445
|
+
}
|
|
446
|
+
async function goToSlideViaDot(carousel, dotIndex) {
|
|
447
|
+
const dot = carousel.locator(DotSelector).nth(dotIndex);
|
|
448
|
+
const isEnabled = await dot.isEnabled();
|
|
449
|
+
if (isEnabled) {
|
|
450
|
+
await dot.click();
|
|
451
|
+
}
|
|
452
|
+
return isEnabled;
|
|
453
|
+
}
|
|
454
|
+
const SlideDirection = {
|
|
455
|
+
Forward: "forward",
|
|
456
|
+
Backward: "backward",
|
|
457
|
+
};
|
|
458
|
+
// Playwright has no built-in touch-swipe gesture, but a mouse drag across most of the carousel's
|
|
459
|
+
// width is functionally equivalent for carousels that respond to pointer events (which cover both
|
|
460
|
+
// mouse drag and touch swipe), and is more portable across browser engines than dispatching
|
|
461
|
+
// synthetic touch events directly.
|
|
462
|
+
async function dragCarousel(carousel, direction) {
|
|
463
|
+
const box = await carousel.boundingBox();
|
|
464
|
+
const didDrag = box !== null;
|
|
465
|
+
if (box !== null) {
|
|
466
|
+
const page = carousel.page();
|
|
467
|
+
const y = box.y + box.height / 2;
|
|
468
|
+
const isForward = direction === SlideDirection.Forward;
|
|
469
|
+
const fromX = isForward ? box.x + box.width * 0.8 : box.x + box.width * 0.2;
|
|
470
|
+
const toX = isForward ? box.x + box.width * 0.2 : box.x + box.width * 0.8;
|
|
471
|
+
await page.mouse.move(fromX, y);
|
|
472
|
+
await page.mouse.down();
|
|
473
|
+
await page.mouse.move(toX, y, { steps: 10 });
|
|
474
|
+
await page.mouse.up();
|
|
475
|
+
}
|
|
476
|
+
return didDrag;
|
|
477
|
+
}
|
|
478
|
+
async function swipeToNextSlide(carousel) {
|
|
479
|
+
return dragCarousel(carousel, SlideDirection.Forward);
|
|
480
|
+
}
|
|
481
|
+
async function swipeToPreviousSlide(carousel) {
|
|
482
|
+
return dragCarousel(carousel, SlideDirection.Backward);
|
|
483
|
+
}
|
|
484
|
+
// Advances through every slide via arrow (desktop) or swipe (mobile), asserting the frame is
|
|
485
|
+
// never blank along the way. Stops early once it either loops back to the start or gets stuck on
|
|
486
|
+
// the same slide (a non-looping carousel that has reached its last slide).
|
|
487
|
+
async function traverseSlidesForward(carousel, carouselIndex, isMobile) {
|
|
488
|
+
const totalSlides = await getTotalSlideCount(carousel);
|
|
489
|
+
const startIndex = await getActiveSlideIndex(carousel);
|
|
490
|
+
let previousIndex = startIndex;
|
|
491
|
+
// One extra advance beyond the actual slide count, to also exercise the loop/stop behavior at
|
|
492
|
+
// the end rather than just reaching the last slide.
|
|
493
|
+
const maxAdvances = totalSlides + 1;
|
|
494
|
+
let shouldContinue = true;
|
|
495
|
+
for (let advance = 0; advance < maxAdvances && shouldContinue; advance++) {
|
|
496
|
+
const didAdvance = isMobile ? await swipeToNextSlide(carousel) : await goToNextSlideViaArrow(carousel);
|
|
497
|
+
if (didAdvance) {
|
|
498
|
+
const isEmptyFrame = await hasEmptySlideFrame(carousel);
|
|
499
|
+
(0, test_1.expect)(isEmptyFrame, `Carousel at index ${carouselIndex.toString()}: Slide frame was blank after ${String(advance + 1)} ${isMobile ? "swipes" : "arrow clicks"}`).toBe(false);
|
|
500
|
+
const currentIndex = await getActiveSlideIndex(carousel);
|
|
501
|
+
const hasLoopedBackToStart = currentIndex === startIndex && previousIndex !== startIndex;
|
|
502
|
+
const hasStoppedAdvancing = currentIndex === previousIndex;
|
|
503
|
+
if (hasLoopedBackToStart || hasStoppedAdvancing) {
|
|
504
|
+
shouldContinue = false;
|
|
505
|
+
}
|
|
506
|
+
previousIndex = currentIndex;
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
shouldContinue = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function checkSwipeBackward(carousel, carouselIndex) {
|
|
514
|
+
const didSwipeBack = await swipeToPreviousSlide(carousel);
|
|
515
|
+
if (didSwipeBack) {
|
|
516
|
+
const isEmptyFrameAfterSwipeBack = await hasEmptySlideFrame(carousel);
|
|
517
|
+
(0, test_1.expect)(isEmptyFrameAfterSwipeBack, `Carousel at index ${carouselIndex.toString()}: Slide frame was blank after swiping backward`).toBe(false);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async function checkAllDots(carousel, carouselIndex) {
|
|
521
|
+
const dotCount = await getDotCount(carousel);
|
|
522
|
+
for (let dotIndex = 0; dotIndex < dotCount; dotIndex++) {
|
|
523
|
+
const didGoToDot = await goToSlideViaDot(carousel, dotIndex);
|
|
524
|
+
if (didGoToDot) {
|
|
525
|
+
const isEmptyFrameAfterDot = await hasEmptySlideFrame(carousel);
|
|
526
|
+
(0, test_1.expect)(isEmptyFrameAfterDot, `Carousel at index ${carouselIndex.toString()}: Slide frame was blank after clicking dot ${dotIndex.toString()}`).toBe(false);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manufac/general-tests",
|
|
3
|
+
"version": "0.0.1-alpha",
|
|
4
|
+
"description": "A library for general tests for landing pages",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./*": "./dist/*"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"lint": "eslint --fix --cache",
|
|
15
|
+
"prepack": "npm run build",
|
|
16
|
+
"pretty": "prettier --cache --write . && sort-package-json"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"zod": "^4.4.3"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@manufac/eslint-config": "*",
|
|
23
|
+
"@manufac/prettier-config": "*",
|
|
24
|
+
"@playwright/test": "^1.62.0",
|
|
25
|
+
"typescript": "^7.0.2"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@playwright/test": "^1.62.0"
|
|
29
|
+
}
|
|
30
|
+
}
|