@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
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @manufac/general-tests
|
|
2
|
+
|
|
3
|
+
Shared Playwright regression suite for landing pages, built from the Landing Page Test Plan. It ships generic, page-agnostic checks (page load, responsive layout, forms, CTAs/links, media, orientation, touch targets, etc.) that any landing-page app in the monorepo can run against its own pages.
|
|
4
|
+
|
|
5
|
+
See [CHANGELOG.md](./CHANGELOG.md) for release notes.
|
|
6
|
+
|
|
7
|
+
## Consuming from an app
|
|
8
|
+
|
|
9
|
+
Add it as a normal dependency, pinned to the version you want to consume:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @manufac/general-tests@0.0.1-alpha
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@manufac/general-tests": "0.0.1-alpha"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then wire it into the app's `playwright.config.ts` using the project-builder helpers:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { defineConfig } from "@playwright/test";
|
|
27
|
+
import { createDesktopProject, createMobileProject } from "@manufac/general-tests/config/projects.js";
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
use: {
|
|
31
|
+
baseURL: "http://localhost:8080",
|
|
32
|
+
},
|
|
33
|
+
projects: [
|
|
34
|
+
createDesktopProject("Desktop Chrome", "Desktop Chrome"),
|
|
35
|
+
createDesktopProject("Desktop Safari", "Desktop Safari"),
|
|
36
|
+
createDesktopProject("Desktop Firefox", "Desktop Firefox"),
|
|
37
|
+
createMobileProject("Mobile Safari", "iPhone 12"),
|
|
38
|
+
createMobileProject("Mobile Chrome", "Pixel 5"),
|
|
39
|
+
],
|
|
40
|
+
webServer: {
|
|
41
|
+
command: "npm run start",
|
|
42
|
+
url: "http://localhost:8080",
|
|
43
|
+
reuseExistingServer: !process.env.CI,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `createDesktopProject(name, device, breakpoint?)` runs the `shared` + `desktop-only` specs under the given Playwright device, defaulting to the Desktop breakpoint (1440x900).
|
|
49
|
+
- `createMobileProject(name, device, breakpoint?)` runs the `shared` + `mobile-only` specs, defaulting to the Mobile M breakpoint (390x844).
|
|
50
|
+
- Both accept an optional third `breakpoint` argument — one of `Breakpoints.MobileS/MobileM/Tablet/Laptop/Desktop/Wide` (Section 3 of the test plan) — to test at a different viewport.
|
|
51
|
+
|
|
52
|
+
By default every spec runs against `/`. To point a project at a different page, override the `targetPage` fixture option:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
createDesktopProject("Desktop Chrome — pricing", "Desktop Chrome"),
|
|
56
|
+
// then, in that project's `use`:
|
|
57
|
+
use: { targetPage: "/pricing" }
|
|
58
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { devices } from "@playwright/test";
|
|
2
|
+
import type { Project } from "@playwright/test";
|
|
3
|
+
export interface Breakpoint {
|
|
4
|
+
name: string;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
export declare const Breakpoints: {
|
|
9
|
+
readonly MobileS: {
|
|
10
|
+
readonly name: "Mobile S";
|
|
11
|
+
readonly width: 320;
|
|
12
|
+
readonly height: 568;
|
|
13
|
+
};
|
|
14
|
+
readonly MobileM: {
|
|
15
|
+
readonly name: "Mobile M";
|
|
16
|
+
readonly width: 390;
|
|
17
|
+
readonly height: 844;
|
|
18
|
+
};
|
|
19
|
+
readonly Tablet: {
|
|
20
|
+
readonly name: "Tablet";
|
|
21
|
+
readonly width: 768;
|
|
22
|
+
readonly height: 1024;
|
|
23
|
+
};
|
|
24
|
+
readonly Laptop: {
|
|
25
|
+
readonly name: "Laptop";
|
|
26
|
+
readonly width: 1280;
|
|
27
|
+
readonly height: 800;
|
|
28
|
+
};
|
|
29
|
+
readonly Desktop: {
|
|
30
|
+
readonly name: "Desktop";
|
|
31
|
+
readonly width: 1440;
|
|
32
|
+
readonly height: 900;
|
|
33
|
+
};
|
|
34
|
+
readonly Wide: {
|
|
35
|
+
readonly name: "Wide";
|
|
36
|
+
readonly width: 1920;
|
|
37
|
+
readonly height: 1080;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
export declare function createDesktopProject(name: string, device: keyof typeof devices, breakpoint?: Breakpoint): Project;
|
|
41
|
+
export declare function createMobileProject(name: string, device: keyof typeof devices, breakpoint?: Breakpoint): Project;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Breakpoints = void 0;
|
|
4
|
+
exports.createDesktopProject = createDesktopProject;
|
|
5
|
+
exports.createMobileProject = createMobileProject;
|
|
6
|
+
const test_1 = require("@playwright/test");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
// Section 3 of the Landing Page Test Plan — breakpoints / viewports.
|
|
9
|
+
exports.Breakpoints = {
|
|
10
|
+
MobileS: { name: "Mobile S", width: 320, height: 568 },
|
|
11
|
+
MobileM: { name: "Mobile M", width: 390, height: 844 },
|
|
12
|
+
Tablet: { name: "Tablet", width: 768, height: 1024 },
|
|
13
|
+
Laptop: { name: "Laptop", width: 1280, height: 800 },
|
|
14
|
+
Desktop: { name: "Desktop", width: 1440, height: 900 },
|
|
15
|
+
Wide: { name: "Wide", width: 1920, height: 1080 },
|
|
16
|
+
};
|
|
17
|
+
// Resolves to dist/specs at runtime so projects work regardless of the consuming app's own testDir.
|
|
18
|
+
const SpecsDirectory = (0, node_path_1.join)(__dirname, "..", "specs");
|
|
19
|
+
const SharedTestMatch = "shared/**/*.spec.js";
|
|
20
|
+
const DesktopOnlyTestMatch = "desktop-only/**/*.spec.js";
|
|
21
|
+
const MobileOnlyTestMatch = "mobile-only/**/*.spec.js";
|
|
22
|
+
function createDesktopProject(name, device, breakpoint = exports.Breakpoints.Desktop) {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
testDir: SpecsDirectory,
|
|
26
|
+
testMatch: [SharedTestMatch, DesktopOnlyTestMatch],
|
|
27
|
+
use: { ...test_1.devices[device], viewport: { width: breakpoint.width, height: breakpoint.height } },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function createMobileProject(name, device, breakpoint = exports.Breakpoints.MobileM) {
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
testDir: SpecsDirectory,
|
|
34
|
+
testMatch: [SharedTestMatch, MobileOnlyTestMatch],
|
|
35
|
+
use: { ...test_1.devices[device], viewport: { width: breakpoint.width, height: breakpoint.height } },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export interface PageTestOptions {
|
|
2
|
+
targetPage: string;
|
|
3
|
+
consoleErrors: string[];
|
|
4
|
+
}
|
|
5
|
+
export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & PageTestOptions, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.test = void 0;
|
|
4
|
+
const test_1 = require("@playwright/test");
|
|
5
|
+
// Ref: https://playwright.dev/docs/test-fixtures#creating-a-fixture
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
7
|
+
exports.test = test_1.test.extend({
|
|
8
|
+
// Ref: https://playwright.dev/docs/test-fixtures#fixtures-options
|
|
9
|
+
/**
|
|
10
|
+
* Playwright's test.extend<Options>({ someOption: [defaultValue, { option: true }] })
|
|
11
|
+
* syntax declares targetPage as a fixture option — a configurable value with a
|
|
12
|
+
* default ("/" here), which any consumer can override via test.use({ targetPage: "..." }),
|
|
13
|
+
* either per spec file, per describe block, or per Playwright "project" in a config.
|
|
14
|
+
* Without { option: true }, it'd just be a fixed value baked into the fixture,
|
|
15
|
+
* not something a consumer could override.
|
|
16
|
+
*/
|
|
17
|
+
targetPage: ["/", { option: true }],
|
|
18
|
+
consoleErrors: async ({ page }, use) => {
|
|
19
|
+
let errors = [];
|
|
20
|
+
page.on("console", (message) => {
|
|
21
|
+
if (message.type() === "error") {
|
|
22
|
+
errors = [...errors, message.text()];
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
await use(errors);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ManufacTestConfigSchema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
exports.ManufacTestConfigSchema = zod_1.z.object({
|
|
6
|
+
pages: zod_1.z.array(zod_1.z.string()).min(1, "At least one page is required"),
|
|
7
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../fixtures");
|
|
4
|
+
const utils_1 = require("../utils");
|
|
5
|
+
const test_1 = require("@playwright/test");
|
|
6
|
+
(0, fixtures_1.test)("RS-07 Browser zoom (desktop) at 200%", async ({ page, targetPage }) => {
|
|
7
|
+
await page.setViewportSize({ width: utils_1.ResponsiveConfig.ViewPortWidth, height: utils_1.ResponsiveConfig.ViewPortHeight });
|
|
8
|
+
await page.goto(targetPage);
|
|
9
|
+
await page.evaluate((zoom) => {
|
|
10
|
+
document.documentElement.style.zoom = zoom;
|
|
11
|
+
}, utils_1.ResponsiveConfig.ZoomLevel);
|
|
12
|
+
await page.waitForTimeout(utils_1.ResponsiveConfig.ZoomSettleDelayMs);
|
|
13
|
+
await (0, utils_1.scrollThroughPage)(page);
|
|
14
|
+
const brokenText = await (0, utils_1.findTruncatedOrOverflowingText)(page);
|
|
15
|
+
(0, test_1.expect)(brokenText, `Text overlapping/clipped at 200% zoom:\n${brokenText.join("\n")}`).toEqual([]);
|
|
16
|
+
const overlappingTargets = await (0, utils_1.findOverlappingTapTargets)(page);
|
|
17
|
+
(0, test_1.expect)(overlappingTargets, `Interactive elements unreachable (overlapping) at 200% zoom:\n${overlappingTargets.join("\n")}`).toEqual([]);
|
|
18
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../fixtures");
|
|
4
|
+
const test_1 = require("@playwright/test");
|
|
5
|
+
fixtures_1.test.describe("Mobile Navigation", () => {
|
|
6
|
+
(0, fixtures_1.test)("CT-05: Hamburger/mobile nav", async ({ page, targetPage }) => {
|
|
7
|
+
await page.goto(targetPage);
|
|
8
|
+
const hamburger = page
|
|
9
|
+
.getByRole("button", { name: /menu|navigation/i })
|
|
10
|
+
.or(page.getByRole("button", { expanded: false }))
|
|
11
|
+
.or(page.getByRole("button", { expanded: true }))
|
|
12
|
+
.or(page.locator(".hamburger, .menu-toggle"))
|
|
13
|
+
.first();
|
|
14
|
+
const count = await hamburger.count();
|
|
15
|
+
if (count === 0) {
|
|
16
|
+
fixtures_1.test.skip(true, "No hamburger menu button found on the page");
|
|
17
|
+
}
|
|
18
|
+
const isVisible = await hamburger.isVisible();
|
|
19
|
+
if (!isVisible) {
|
|
20
|
+
fixtures_1.test.skip(true, "Hamburger menu button is not visible");
|
|
21
|
+
}
|
|
22
|
+
await hamburger.click();
|
|
23
|
+
await page.waitForTimeout(500); // Wait for transition
|
|
24
|
+
const bodyOverflow = await page.evaluate(() => {
|
|
25
|
+
return window.getComputedStyle(document.body).overflow;
|
|
26
|
+
});
|
|
27
|
+
const isHidden = bodyOverflow === "hidden" || bodyOverflow === "clip";
|
|
28
|
+
(0, test_1.expect)(isHidden, "Page behind the open mobile menu should not be scrollable").toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../fixtures");
|
|
4
|
+
const utils_1 = require("../utils");
|
|
5
|
+
const test_1 = require("@playwright/test");
|
|
6
|
+
(0, fixtures_1.test)("RS-02 Orientation change (mobile) reflows without overlap and preserves scroll section", async ({ page, targetPage, }) => {
|
|
7
|
+
await page.goto(targetPage);
|
|
8
|
+
const portraitViewport = page.viewportSize();
|
|
9
|
+
if (portraitViewport === null) {
|
|
10
|
+
throw new Error("Viewport size must be set before running orientation test");
|
|
11
|
+
}
|
|
12
|
+
const { width, height } = portraitViewport;
|
|
13
|
+
const { scrollHeight, clientHeight } = await (0, utils_1.getScrollMetrics)(page);
|
|
14
|
+
// A page only truly scrolls when its content is taller than the viewport, not merely > 0px.
|
|
15
|
+
const isScrollable = scrollHeight > clientHeight;
|
|
16
|
+
const scrollTarget = isScrollable ? Math.floor(scrollHeight / 2) : 0;
|
|
17
|
+
const scrollRatioBefore = isScrollable ? scrollTarget / scrollHeight : 0;
|
|
18
|
+
if (isScrollable) {
|
|
19
|
+
// window.scrollTo guarantees landing at the target, unlike a wheel-delta scroll gesture.
|
|
20
|
+
await page.evaluate((y) => {
|
|
21
|
+
window.scrollTo(0, y);
|
|
22
|
+
}, scrollTarget);
|
|
23
|
+
}
|
|
24
|
+
const phases = [
|
|
25
|
+
{ name: "landscape", viewport: { width: height, height: width } },
|
|
26
|
+
{ name: "portrait", viewport: { width, height } },
|
|
27
|
+
];
|
|
28
|
+
for (const phase of phases) {
|
|
29
|
+
await page.setViewportSize(phase.viewport);
|
|
30
|
+
// Layout must reflow cleanly after rotation — no elements clipped by the viewport edge...
|
|
31
|
+
const cutoffElements = await (0, utils_1.findElementsCutoffAtViewportEdge)(page);
|
|
32
|
+
(0, test_1.expect)(cutoffElements, `Found element(s) cut off at the viewport edge after rotating to ${phase.name}:\n${cutoffElements.join("\n")}`).toEqual([]);
|
|
33
|
+
// ...and no two elements overlapping each other.
|
|
34
|
+
const overlappingElements = await (0, utils_1.findOverlappingElements)(page, utils_1.ContentOverlapSelector);
|
|
35
|
+
(0, test_1.expect)(overlappingElements, `Found overlapping element(s) after rotating to ${phase.name}:\n${overlappingElements.join("\n")}`).toEqual([]);
|
|
36
|
+
if (isScrollable) {
|
|
37
|
+
// Scroll ratio (not absolute position) should stay stable since page height changes with orientation.
|
|
38
|
+
const currentMetrics = await (0, utils_1.getScrollMetrics)(page);
|
|
39
|
+
const scrollRatioAfter = currentMetrics.scrollY / currentMetrics.scrollHeight;
|
|
40
|
+
(0, test_1.expect)(Math.abs(scrollRatioAfter - scrollRatioBefore), `Scroll position jumped away from the original section after rotating to ${phase.name}`).toBeLessThan(utils_1.ResponsiveConfig.ScrollDriftTolerance);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../fixtures");
|
|
4
|
+
const utils_1 = require("../utils");
|
|
5
|
+
const test_1 = require("@playwright/test");
|
|
6
|
+
(0, fixtures_1.test)("RS-04 Text readability at 320px", async ({ page, targetPage }) => {
|
|
7
|
+
await page.goto(targetPage);
|
|
8
|
+
const undersizedText = await (0, utils_1.findUndersizedBodyText)(page);
|
|
9
|
+
(0, test_1.expect)(undersizedText, `Found ${String(undersizedText.length)} body text element(s) below ${String(utils_1.ResponsiveConfig.MinBodyTextFontSizePX)}px at 320px width:\n${undersizedText.join("\n")}`).toEqual([]);
|
|
10
|
+
const brokenText = await (0, utils_1.findTruncatedOrOverflowingText)(page);
|
|
11
|
+
(0, test_1.expect)(brokenText, `Truncated or overflowing text at 320px width:\n${brokenText.join("\n")}`).toEqual([]);
|
|
12
|
+
const isScrollable = await (0, utils_1.hasHorizontalScrollbar)(page);
|
|
13
|
+
(0, test_1.expect)(isScrollable, "Horizontal scrollbar present at 320px — text requires horizontal scrolling to read").toBe(false);
|
|
14
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../fixtures");
|
|
4
|
+
const utils_1 = require("../utils");
|
|
5
|
+
const test_1 = require("@playwright/test");
|
|
6
|
+
fixtures_1.test.describe("Forms - Soft Keyboard", () => {
|
|
7
|
+
utils_1.KeyboardFields.forEach((keyboardField) => {
|
|
8
|
+
(0, fixtures_1.test)(`FM-05 ${keyboardField.label.source} field opens the correct soft keyboard`, async ({ page, targetPage }) => {
|
|
9
|
+
await page.goto(targetPage);
|
|
10
|
+
const viewport = page.viewportSize();
|
|
11
|
+
if (viewport === null) {
|
|
12
|
+
throw new Error("Viewport size must be set before running the soft keyboard test");
|
|
13
|
+
}
|
|
14
|
+
const field = page.getByLabel(keyboardField.label);
|
|
15
|
+
await field.click();
|
|
16
|
+
// NOTE: Playwright cannot render a real device's on-screen keyboard, so the
|
|
17
|
+
// originally-specified check ("email field opens the @ keyboard, phone field
|
|
18
|
+
// opens the number pad") is verified indirectly via the input's inputmode/type
|
|
19
|
+
// attribute, which is what drives the OS keyboard choice on a real device.
|
|
20
|
+
const [inputMode, inputType] = await Promise.all([field.getAttribute("inputmode"), field.getAttribute("type")]);
|
|
21
|
+
const hasCorrectKeyboardHint = keyboardField.expectedInputMode.test(inputMode ?? "") || keyboardField.expectedInputMode.test(inputType ?? "");
|
|
22
|
+
(0, test_1.expect)(hasCorrectKeyboardHint, `${keyboardField.label.source} field should have an inputmode/type that opens the correct soft keyboard`).toBe(true);
|
|
23
|
+
// A real on-screen keyboard typically covers roughly the bottom 40% of a mobile viewport.
|
|
24
|
+
// Playwright can't render an actual OS keyboard, so this is approximated by shrinking the
|
|
25
|
+
// viewport after focusing the field, turning "field shouldn't be hidden behind the keyboard"
|
|
26
|
+
// into a real geometry check instead of one that trivially always passes.
|
|
27
|
+
const visibleHeight = Math.round(viewport.height * (1 - utils_1.ResponsiveConfig.KeyboardOcclusionRatio));
|
|
28
|
+
await page.setViewportSize({ width: viewport.width, height: visibleHeight });
|
|
29
|
+
const fieldBox = await field.boundingBox();
|
|
30
|
+
const isAboveKeyboard = fieldBox !== null && fieldBox.y >= 0 && fieldBox.y + fieldBox.height <= visibleHeight;
|
|
31
|
+
(0, test_1.expect)(isAboveKeyboard, `${keyboardField.label.source} field should never be hidden behind the keyboard; the page should scroll it into view`).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../fixtures");
|
|
4
|
+
const utils_1 = require("../utils");
|
|
5
|
+
const test_1 = require("@playwright/test");
|
|
6
|
+
(0, fixtures_1.test)("RS-05 Touch targets (mobile) are at least 44x44px and do not overlap", async ({ page, targetPage }) => {
|
|
7
|
+
await page.goto(targetPage);
|
|
8
|
+
const undersizedTargets = await (0, utils_1.findUndersizedTouchTargets)(page, utils_1.TouchTargetSizeSelector);
|
|
9
|
+
(0, test_1.expect)(undersizedTargets, `Touch targets below ${String(utils_1.ResponsiveConfig.MinTouchTargetSizePX)}x${String(utils_1.ResponsiveConfig.MinTouchTargetSizePX)}px:\n${undersizedTargets.join("\n")}`).toEqual([]);
|
|
10
|
+
const overlappingTargets = await (0, utils_1.findOverlappingTapTargets)(page);
|
|
11
|
+
(0, test_1.expect)(overlappingTargets, `Overlapping tap targets risk triggering the wrong element:\n${overlappingTargets.join("\n")}`).toEqual([]);
|
|
12
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../fixtures");
|
|
4
|
+
const test_1 = require("@playwright/test");
|
|
5
|
+
fixtures_1.test.describe("Landing Page - CTAs, Links & Navigation", () => {
|
|
6
|
+
(0, fixtures_1.test)("CT-01: All CTAs functional", async ({ page, targetPage }) => {
|
|
7
|
+
await page.goto(targetPage);
|
|
8
|
+
// Look for elements with button role or common CTA link names using smart locators
|
|
9
|
+
const ctas = page
|
|
10
|
+
.getByRole("button", { name: /submit|send|join|subscribe|sign|start|contact|book|request|try/i })
|
|
11
|
+
.or(page.getByRole("link", { name: /submit|send|join|subscribe|sign|start|contact|book|request|try/i }));
|
|
12
|
+
const ctaCount = await ctas.count();
|
|
13
|
+
(0, test_1.expect)(ctaCount, "Page should have at least some CTAs").toBeGreaterThan(0);
|
|
14
|
+
for (let index = 0; index < ctaCount; index++) {
|
|
15
|
+
const cta = ctas.nth(index);
|
|
16
|
+
const tagName = await cta.evaluate((element) => {
|
|
17
|
+
return element.tagName.toLowerCase();
|
|
18
|
+
});
|
|
19
|
+
switch (tagName) {
|
|
20
|
+
case "a": {
|
|
21
|
+
const href = await cta.getAttribute("href");
|
|
22
|
+
(0, test_1.expect)(typeof href === "string", "CTA link must have an href").toBe(true);
|
|
23
|
+
if (typeof href === "string") {
|
|
24
|
+
(0, test_1.expect)(href.length > 0, "CTA href must not be empty").toBe(true);
|
|
25
|
+
}
|
|
26
|
+
const target = await cta.getAttribute("target");
|
|
27
|
+
if (target !== null) {
|
|
28
|
+
(0, test_1.expect)(target, "CTA target attribute should be _blank if present").toBe("_blank");
|
|
29
|
+
}
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case "button": {
|
|
33
|
+
await (0, test_1.expect)(cta, "CTA button should be visible").toBeVisible();
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
(0, fixtures_1.test)("CT-02: All links valid", async ({ page, request, targetPage }) => {
|
|
40
|
+
await page.goto(targetPage);
|
|
41
|
+
const links = page.getByRole("link");
|
|
42
|
+
const linkCount = await links.count();
|
|
43
|
+
(0, test_1.expect)(linkCount, "Page should have at least some links").toBeGreaterThan(0);
|
|
44
|
+
let linkURLs = [];
|
|
45
|
+
for (let index = 0; index < linkCount; index++) {
|
|
46
|
+
const href = await links.nth(index).getAttribute("href");
|
|
47
|
+
if (typeof href === "string") {
|
|
48
|
+
// We only check internal links to avoid flaky tests due to external anti-bot protections
|
|
49
|
+
if (href.startsWith("/")) {
|
|
50
|
+
const isIncluded = linkURLs.includes(href);
|
|
51
|
+
if (!isIncluded) {
|
|
52
|
+
linkURLs = [...linkURLs, href];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const linkURL of linkURLs) {
|
|
58
|
+
const requestURL = new URL(linkURL, page.url()).toString();
|
|
59
|
+
const response = await request.get(requestURL);
|
|
60
|
+
const isResponseOK = response.ok();
|
|
61
|
+
(0, test_1.expect)(isResponseOK, `Link ${linkURL} returned status ${response.status().toString()}`).toBe(true);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
(0, fixtures_1.test)("CT-03: Anchor scroll on mobile", async ({ page, targetPage }) => {
|
|
65
|
+
await page.goto(targetPage);
|
|
66
|
+
const links = page.getByRole("link");
|
|
67
|
+
const count = await links.count();
|
|
68
|
+
if (count === 0) {
|
|
69
|
+
fixtures_1.test.skip(true, "No links found on the page");
|
|
70
|
+
}
|
|
71
|
+
let hasClicked = false;
|
|
72
|
+
for (let index = 0; index < count; index++) {
|
|
73
|
+
const href = await links.nth(index).getAttribute("href");
|
|
74
|
+
if (typeof href === "string" && href.startsWith("#") && href.length > 1) {
|
|
75
|
+
const targetID = href.substring(1);
|
|
76
|
+
const targetElement = page.locator(`id=${targetID}`);
|
|
77
|
+
const targetCount = await targetElement.count();
|
|
78
|
+
if (targetCount > 0) {
|
|
79
|
+
await links.nth(index).click();
|
|
80
|
+
await (0, test_1.expect)(targetElement).toBeInViewport();
|
|
81
|
+
hasClicked = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!hasClicked) {
|
|
87
|
+
fixtures_1.test.skip(true, "No valid anchor links with existing targets found on the page");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
(0, fixtures_1.test)("CT-04: Phone/email links", async ({ page, targetPage }) => {
|
|
91
|
+
await page.goto(targetPage);
|
|
92
|
+
const links = page.getByRole("link");
|
|
93
|
+
const count = await links.count();
|
|
94
|
+
if (count === 0) {
|
|
95
|
+
fixtures_1.test.skip(true, "No links found on the page to check for phone/email");
|
|
96
|
+
}
|
|
97
|
+
for (let index = 0; index < count; index++) {
|
|
98
|
+
const href = await links.nth(index).getAttribute("href");
|
|
99
|
+
if (typeof href === "string") {
|
|
100
|
+
if (href.startsWith("tel:")) {
|
|
101
|
+
const phoneRegex = /^tel:[+\d\-\s()]+$/;
|
|
102
|
+
const isValid = phoneRegex.test(href);
|
|
103
|
+
(0, test_1.expect)(isValid, `Phone link format should be valid: ${href}`).toBe(true);
|
|
104
|
+
}
|
|
105
|
+
else if (href.startsWith("mailto:")) {
|
|
106
|
+
// Simple email validation for mailto format
|
|
107
|
+
const emailRegex = /^mailto:[^@\s]+@[^@\s]+\.[^@\s]+.*$/;
|
|
108
|
+
const isValid = emailRegex.test(href);
|
|
109
|
+
(0, test_1.expect)(isValid, `Email link format should be valid: ${href}`).toBe(true);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
(0, fixtures_1.test)("CT-06: Hover vs touch states", async ({ page, isMobile, targetPage }) => {
|
|
115
|
+
await page.goto(targetPage);
|
|
116
|
+
const interactables = page.getByRole("button").or(page.getByRole("link"));
|
|
117
|
+
const count = await interactables.count();
|
|
118
|
+
if (count === 0) {
|
|
119
|
+
fixtures_1.test.skip(true, "No buttons or links found to test interaction states");
|
|
120
|
+
}
|
|
121
|
+
const interactablesAll = await interactables.all();
|
|
122
|
+
await Promise.all(interactablesAll.map(async (element, index) => {
|
|
123
|
+
const isVisible = await element.isVisible();
|
|
124
|
+
if (isVisible) {
|
|
125
|
+
// Prevent navigation or unwanted side effects during interaction testing
|
|
126
|
+
await element.evaluate((node) => {
|
|
127
|
+
node.addEventListener("click", (event) => {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
event.stopPropagation();
|
|
130
|
+
}, { once: true });
|
|
131
|
+
});
|
|
132
|
+
if (!isMobile) {
|
|
133
|
+
const initialStyle = await element.evaluate((node) => {
|
|
134
|
+
const style = window.getComputedStyle(node);
|
|
135
|
+
return {
|
|
136
|
+
bg: style.backgroundColor,
|
|
137
|
+
color: style.color,
|
|
138
|
+
opacity: style.opacity,
|
|
139
|
+
border: style.borderColor,
|
|
140
|
+
shadow: style.boxShadow,
|
|
141
|
+
transform: style.transform,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
await element.hover();
|
|
145
|
+
const hoverStyle = await element.evaluate((node) => {
|
|
146
|
+
const style = window.getComputedStyle(node);
|
|
147
|
+
return {
|
|
148
|
+
bg: style.backgroundColor,
|
|
149
|
+
color: style.color,
|
|
150
|
+
opacity: style.opacity,
|
|
151
|
+
border: style.borderColor,
|
|
152
|
+
shadow: style.boxShadow,
|
|
153
|
+
transform: style.transform,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
const isStyleChanged = initialStyle.bg !== hoverStyle.bg ||
|
|
157
|
+
initialStyle.color !== hoverStyle.color ||
|
|
158
|
+
initialStyle.opacity !== hoverStyle.opacity ||
|
|
159
|
+
initialStyle.border !== hoverStyle.border ||
|
|
160
|
+
initialStyle.shadow !== hoverStyle.shadow ||
|
|
161
|
+
initialStyle.transform !== hoverStyle.transform;
|
|
162
|
+
(0, test_1.expect)(isStyleChanged, `Hovering should change styling for element at index ${index.toString()}`).toBe(true);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const initialStyle = await element.evaluate((node) => {
|
|
166
|
+
const style = window.getComputedStyle(node);
|
|
167
|
+
return {
|
|
168
|
+
bg: style.backgroundColor,
|
|
169
|
+
color: style.color,
|
|
170
|
+
opacity: style.opacity,
|
|
171
|
+
border: style.borderColor,
|
|
172
|
+
shadow: style.boxShadow,
|
|
173
|
+
transform: style.transform,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
await element.tap();
|
|
177
|
+
const finalStyle = await element.evaluate((node) => {
|
|
178
|
+
const style = window.getComputedStyle(node);
|
|
179
|
+
return {
|
|
180
|
+
bg: style.backgroundColor,
|
|
181
|
+
color: style.color,
|
|
182
|
+
opacity: style.opacity,
|
|
183
|
+
border: style.borderColor,
|
|
184
|
+
shadow: style.boxShadow,
|
|
185
|
+
transform: style.transform,
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
const isStyleChanged = initialStyle.bg !== finalStyle.bg ||
|
|
189
|
+
initialStyle.color !== finalStyle.color ||
|
|
190
|
+
initialStyle.opacity !== finalStyle.opacity ||
|
|
191
|
+
initialStyle.border !== finalStyle.border ||
|
|
192
|
+
initialStyle.shadow !== finalStyle.shadow ||
|
|
193
|
+
initialStyle.transform !== finalStyle.transform;
|
|
194
|
+
(0, test_1.expect)(isStyleChanged, `Element at index ${index.toString()} should change styling after tap on mobile`).toBe(true);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}));
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../../fixtures");
|
|
4
|
+
const utils_1 = require("../../utils");
|
|
5
|
+
const test_1 = require("@playwright/test");
|
|
6
|
+
fixtures_1.test.describe("Forms - Autofill", () => {
|
|
7
|
+
(0, fixtures_1.test)("FM-06 Form fields expose autocomplete hints for autofill compatibility", async ({ page, targetPage }) => {
|
|
8
|
+
await page.goto(targetPage);
|
|
9
|
+
await Promise.all(utils_1.AutofillFields.map(async (autofillField) => {
|
|
10
|
+
const field = page.getByLabel(autofillField.label);
|
|
11
|
+
const autocompleteValue = await field.getAttribute("autocomplete");
|
|
12
|
+
const hasCorrectAutocomplete = autofillField.expectedAutocomplete.test(autocompleteValue ?? "");
|
|
13
|
+
(0, test_1.expect)(hasCorrectAutocomplete, `${autofillField.label.source} field should have an autocomplete attribute matching ${autofillField.expectedAutocomplete.source} so browser autofill puts the value in the correct field`).toBe(true);
|
|
14
|
+
}));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const fixtures_1 = require("../../../fixtures");
|
|
4
|
+
const utils_1 = require("../../utils");
|
|
5
|
+
const test_1 = require("@playwright/test");
|
|
6
|
+
fixtures_1.test.describe("Forms - Double Submit", () => {
|
|
7
|
+
(0, fixtures_1.test)("FM-04 Double-submit protection allows only one submission", async ({ page, targetPage }) => {
|
|
8
|
+
await page.goto(targetPage);
|
|
9
|
+
const submitButton = page.locator('button[type="submit"], input[type="submit"]');
|
|
10
|
+
const submitButtonCount = await submitButton.count();
|
|
11
|
+
if (submitButtonCount === 0) {
|
|
12
|
+
fixtures_1.test.skip(true, "No submit button present on this page");
|
|
13
|
+
}
|
|
14
|
+
const nameField = page.getByLabel(/name/i);
|
|
15
|
+
const nameFieldCount = await nameField.count();
|
|
16
|
+
if (nameFieldCount > 0) {
|
|
17
|
+
await nameField.first().fill(utils_1.ValidUserInfo.ValidTestName);
|
|
18
|
+
}
|
|
19
|
+
const emailField = page.getByLabel(/email/i);
|
|
20
|
+
const emailFieldCount = await emailField.count();
|
|
21
|
+
if (emailFieldCount > 0) {
|
|
22
|
+
await emailField.first().fill(utils_1.ValidUserInfo.ValidTestEmail);
|
|
23
|
+
}
|
|
24
|
+
const phoneField = page.getByLabel(/phone/i);
|
|
25
|
+
const phoneFieldCount = await phoneField.count();
|
|
26
|
+
if (phoneFieldCount > 0) {
|
|
27
|
+
await phoneField.first().fill(utils_1.ValidUserInfo.ValidTestPhone);
|
|
28
|
+
}
|
|
29
|
+
const targetSubmitButton = submitButton.first();
|
|
30
|
+
const firstClick = targetSubmitButton.click();
|
|
31
|
+
await targetSubmitButton.click({ force: true });
|
|
32
|
+
await firstClick;
|
|
33
|
+
const isEnabled = !(await targetSubmitButton.isDisabled());
|
|
34
|
+
const ariaBusy = await targetSubmitButton.getAttribute("aria-busy");
|
|
35
|
+
const isBusy = ariaBusy === "true";
|
|
36
|
+
const hasDoubleSubmitProtection = !isEnabled || isBusy;
|
|
37
|
+
(0, test_1.expect)(hasDoubleSubmitProtection, "Submit button should visibly disable or show a loading state after the first click").toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|