@manufac/general-tests 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -55
- package/dist/config/projects.d.ts +3 -1
- package/dist/config/projects.js +4 -2
- package/dist/fixtures/index.d.ts +2 -0
- package/dist/fixtures/index.js +1 -0
- package/dist/schema/index.d.ts +39 -26
- package/dist/schema/index.js +14 -36
- package/dist/specs/forms.spec.d.ts +1 -0
- package/dist/specs/forms.spec.js +74 -0
- package/dist/specs/utils.d.ts +13 -1
- package/dist/specs/utils.js +57 -0
- package/dist/specs/visual-regression.spec.d.ts +1 -0
- package/dist/specs/visual-regression.spec.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,74 +9,128 @@ See [CHANGELOG.md](./CHANGELOG.md) for release notes.
|
|
|
9
9
|
Add it as a normal dependency, pinned to the version you want to consume:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npm install @manufac/general-tests
|
|
12
|
+
npm install @manufac/general-tests
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
Then wire it into the app's `playwright.config.ts`.
|
|
16
|
+
|
|
17
|
+
First, declare the app-specific pages in a small local config file, typed against `ManufacTestConfig`:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// manufac-test.config.ts
|
|
21
|
+
import type { ManufacTestConfig } from "@manufac/general-tests/schema/index.js";
|
|
22
|
+
|
|
23
|
+
export const Config: ManufacTestConfig = {
|
|
24
|
+
pages: ["/", "/pricing", "/contact-us", "/privacy"],
|
|
25
|
+
};
|
|
21
26
|
```
|
|
22
27
|
|
|
23
|
-
Then
|
|
28
|
+
Then reference it from `playwright.config.ts`:
|
|
24
29
|
|
|
25
30
|
```ts
|
|
26
|
-
import { defineConfig } from "@playwright/test";
|
|
27
|
-
import {
|
|
31
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import {
|
|
34
|
+
DesktopBreakpoints,
|
|
35
|
+
ResponsiveLayoutBreakpoints,
|
|
36
|
+
LoadingPageTestMatch,
|
|
37
|
+
ResponsiveLayoutTestMatch,
|
|
38
|
+
} from "@manufac/general-tests/config/projects.js";
|
|
39
|
+
import type { PageTestOptions } from "@manufac/general-tests/fixtures/index.js";
|
|
40
|
+
import type { Project } from "@playwright/test";
|
|
41
|
+
import { Config } from "./manufac-test.config.js";
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
// Resolves to node_modules/@manufac/general-tests/dist/specs, wherever it's installed from.
|
|
44
|
+
const GeneralTestsSpecsDir = join(dirname(require.resolve("@manufac/general-tests/config/projects.js")), "..", "specs");
|
|
45
|
+
|
|
46
|
+
const Projects: Project[] = [
|
|
47
|
+
{
|
|
48
|
+
name: "Desktop Chrome",
|
|
49
|
+
testDir: GeneralTestsSpecsDir,
|
|
50
|
+
testMatch: [LoadingPageTestMatch, ResponsiveLayoutTestMatch],
|
|
51
|
+
use: { ...devices["Desktop Chrome"], viewport: DesktopBreakpoints.Desktop },
|
|
32
52
|
},
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
53
|
+
{
|
|
54
|
+
name: "Desktop Safari",
|
|
55
|
+
testDir: GeneralTestsSpecsDir,
|
|
56
|
+
testMatch: [LoadingPageTestMatch, ResponsiveLayoutTestMatch],
|
|
57
|
+
use: { ...devices["Desktop Safari"], viewport: DesktopBreakpoints.Desktop },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "Desktop Firefox",
|
|
61
|
+
testDir: GeneralTestsSpecsDir,
|
|
62
|
+
testMatch: [LoadingPageTestMatch, ResponsiveLayoutTestMatch],
|
|
63
|
+
use: { ...devices["Desktop Firefox"], viewport: DesktopBreakpoints.Desktop },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "Responsive 375",
|
|
67
|
+
testDir: GeneralTestsSpecsDir,
|
|
68
|
+
testMatch: ResponsiveLayoutTestMatch,
|
|
69
|
+
use: { ...devices["Desktop Chrome"], viewport: ResponsiveLayoutBreakpoints.MobileL },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "Responsive 768",
|
|
73
|
+
testDir: GeneralTestsSpecsDir,
|
|
74
|
+
testMatch: ResponsiveLayoutTestMatch,
|
|
75
|
+
use: { ...devices["Desktop Chrome"], viewport: ResponsiveLayoutBreakpoints.Tablet },
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
export default defineConfig<PageTestOptions>({
|
|
80
|
+
testDir: "./e2e",
|
|
81
|
+
use: {
|
|
82
|
+
baseURL: "https://your-app.example.com",
|
|
83
|
+
pages: Config.pages,
|
|
64
84
|
},
|
|
85
|
+
projects: Projects,
|
|
65
86
|
});
|
|
66
87
|
```
|
|
67
88
|
|
|
68
|
-
- `
|
|
69
|
-
- `
|
|
70
|
-
- `
|
|
89
|
+
- `LoadingPageTestMatch` runs `loading-page.spec.ts` — page-load/console-error/network checks, once per project, over every page in `Config.pages`.
|
|
90
|
+
- `ResponsiveLayoutTestMatch` runs `responsive-layout.spec.ts` — checked at whichever `viewport` each project sets, so add one project per breakpoint you care about (`ResponsiveLayoutBreakpoints.MobileL` / `.Tablet` / `.Desktop`).
|
|
91
|
+
- `require.resolve` + `join(..., "..", "specs")` finds the package's built `specs` directory regardless of how it's installed (registry, `file:`, or a vendored tarball) — `testDir` must point there since these two spec files aren't re-exported any other way.
|
|
92
|
+
- `PageTestOptions` (from `fixtures/index.js`) types the `use` block so `pages` (and `forms`, if you set it) are checked against the fixture's actual shape.
|
|
93
|
+
|
|
94
|
+
## Storybook visual regression
|
|
95
|
+
|
|
96
|
+
`VisualRegressionTestMatch` runs `visual-regression.spec.ts` — a single generic test that walks every story in a running Storybook instance and compares it against a baseline screenshot with Playwright's `toHaveScreenshot()`. No per-component spec files are needed; new stories are picked up automatically.
|
|
71
97
|
|
|
72
|
-
|
|
98
|
+
It needs its own Playwright config, separate from the one above, since it runs against Storybook's dev server instead of the app itself:
|
|
73
99
|
|
|
74
100
|
```ts
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
101
|
+
// playwright.storybook.config.ts
|
|
102
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
103
|
+
import { dirname, join } from "node:path";
|
|
104
|
+
import { VisualRegressionTestMatch } from "@manufac/general-tests/config/projects.js";
|
|
105
|
+
|
|
106
|
+
const GeneralTestsSpecsDir = join(dirname(require.resolve("@manufac/general-tests/config/projects.js")), "..", "specs");
|
|
107
|
+
|
|
108
|
+
export default defineConfig({
|
|
109
|
+
testDir: GeneralTestsSpecsDir,
|
|
110
|
+
testMatch: VisualRegressionTestMatch,
|
|
111
|
+
// Relative, so Playwright resolves it against this config file's own directory (not
|
|
112
|
+
// testDir, which points into the installed package) — baselines stay in this repo.
|
|
113
|
+
snapshotPathTemplate: "e2e-visual/__screenshots__/{arg}{ext}",
|
|
114
|
+
reporter: [["html", { open: "never" }]],
|
|
115
|
+
expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.001, animations: "disabled" } },
|
|
116
|
+
use: { ...devices["Desktop Chrome"], viewport: { width: 1366, height: 768 }, baseURL: "http://localhost:6006" },
|
|
117
|
+
webServer: {
|
|
118
|
+
command: "npm run start:sb -- --ci --port 6006",
|
|
119
|
+
url: "http://localhost:6006",
|
|
120
|
+
reuseExistingServer: true,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
82
123
|
```
|
|
124
|
+
|
|
125
|
+
Then add two scripts to the app's `package.json` (adjust `start:sb`/`build:sb` to however the app runs Storybook):
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"scripts": {
|
|
130
|
+
"test:visual": "npm run build:sb && playwright test -c playwright.storybook.config.ts",
|
|
131
|
+
"update:visual": "npm run build:sb && playwright test -c playwright.storybook.config.ts --update-snapshots"
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Baselines land in `e2e-visual/__screenshots__/<story-id>.png`, next to `playwright.storybook.config.ts`.
|
|
@@ -32,5 +32,7 @@ export declare const ProjectTypes: {
|
|
|
32
32
|
readonly Desktop: "desktop";
|
|
33
33
|
readonly Shared: "shared";
|
|
34
34
|
};
|
|
35
|
-
export declare const LoadingPageTestMatch = "loading-page.spec.
|
|
35
|
+
export declare const LoadingPageTestMatch = "loading-page.spec.js";
|
|
36
36
|
export declare const ResponsiveLayoutTestMatch = "responsive-layout.spec.js";
|
|
37
|
+
export declare const FormsTestMatch = "forms.spec.js";
|
|
38
|
+
export declare const VisualRegressionTestMatch = "visual-regression.spec.js";
|
package/dist/config/projects.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ResponsiveLayoutTestMatch = exports.LoadingPageTestMatch = exports.ProjectTypes = exports.ResponsiveLayoutBreakpoints = exports.DesktopBreakpoints = void 0;
|
|
3
|
+
exports.VisualRegressionTestMatch = exports.FormsTestMatch = exports.ResponsiveLayoutTestMatch = exports.LoadingPageTestMatch = exports.ProjectTypes = exports.ResponsiveLayoutBreakpoints = exports.DesktopBreakpoints = void 0;
|
|
4
4
|
// Section 3 of the Landing Page Test Plan — breakpoints / viewports.
|
|
5
5
|
exports.DesktopBreakpoints = {
|
|
6
6
|
Desktop: { name: "Desktop", width: 1440, height: 900 },
|
|
@@ -15,5 +15,7 @@ exports.ProjectTypes = {
|
|
|
15
15
|
Desktop: "desktop",
|
|
16
16
|
Shared: "shared",
|
|
17
17
|
};
|
|
18
|
-
exports.LoadingPageTestMatch = "loading-page.spec.
|
|
18
|
+
exports.LoadingPageTestMatch = "loading-page.spec.js";
|
|
19
19
|
exports.ResponsiveLayoutTestMatch = "responsive-layout.spec.js";
|
|
20
|
+
exports.FormsTestMatch = "forms.spec.js";
|
|
21
|
+
exports.VisualRegressionTestMatch = "visual-regression.spec.js";
|
package/dist/fixtures/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { ManufacTestFormConfig } from "../schema";
|
|
1
2
|
export interface PageTestOptions {
|
|
2
3
|
targetPage: string;
|
|
3
4
|
pages: string[];
|
|
5
|
+
forms: ManufacTestFormConfig[];
|
|
4
6
|
consoleErrors: string[];
|
|
5
7
|
}
|
|
6
8
|
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>;
|
package/dist/fixtures/index.js
CHANGED
|
@@ -8,6 +8,7 @@ exports.test = test_1.test.extend({
|
|
|
8
8
|
// Ref: https://playwright.dev/docs/test-fixtures#fixtures-options
|
|
9
9
|
targetPage: ["/", { option: true }],
|
|
10
10
|
pages: [["/"], { option: true }],
|
|
11
|
+
forms: [[], { option: true }],
|
|
11
12
|
consoleErrors: async ({ page }, use) => {
|
|
12
13
|
const errors = [];
|
|
13
14
|
page.on("console", (message) => {
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type FieldInteraction = (typeof FieldInteractions)[keyof typeof FieldInte
|
|
|
13
13
|
* (e.g. a field with no `invalidValue` is simply skipped by the invalid-input check for that form).
|
|
14
14
|
*/
|
|
15
15
|
declare const FormFieldSchema: z.ZodObject<{
|
|
16
|
-
|
|
16
|
+
fieldID: z.ZodString;
|
|
17
17
|
validValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
18
18
|
interaction: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
19
19
|
/** A plain input */
|
|
@@ -22,6 +22,7 @@ declare const FormFieldSchema: z.ZodObject<{
|
|
|
22
22
|
readonly Datetime: "datetime";
|
|
23
23
|
}>>>;
|
|
24
24
|
invalidValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
25
|
+
isRequired: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
25
26
|
expectedAutocomplete: z.ZodOptional<z.ZodNullable<z.ZodCustom<import("react").HTMLInputAutoCompleteAttribute | undefined, import("react").HTMLInputAutoCompleteAttribute | undefined>>>;
|
|
26
27
|
expectedInputMode: z.ZodOptional<z.ZodNullable<z.ZodCustom<"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined, "decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined>>>;
|
|
27
28
|
}, z.core.$strip>;
|
|
@@ -30,9 +31,10 @@ export type ManufacTestFormField = z.infer<typeof FormFieldSchema>;
|
|
|
30
31
|
* Represents One form to be tested
|
|
31
32
|
*/
|
|
32
33
|
declare const FormSchema: z.ZodObject<{
|
|
33
|
-
|
|
34
|
+
formID: z.ZodString;
|
|
35
|
+
page: z.ZodString;
|
|
34
36
|
fields: z.ZodArray<z.ZodObject<{
|
|
35
|
-
|
|
37
|
+
fieldID: z.ZodString;
|
|
36
38
|
validValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
37
39
|
interaction: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
38
40
|
/** A plain input */
|
|
@@ -41,37 +43,40 @@ declare const FormSchema: z.ZodObject<{
|
|
|
41
43
|
readonly Datetime: "datetime";
|
|
42
44
|
}>>>;
|
|
43
45
|
invalidValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
46
|
+
isRequired: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
44
47
|
expectedAutocomplete: z.ZodOptional<z.ZodNullable<z.ZodCustom<import("react").HTMLInputAutoCompleteAttribute | undefined, import("react").HTMLInputAutoCompleteAttribute | undefined>>>;
|
|
45
48
|
expectedInputMode: z.ZodOptional<z.ZodNullable<z.ZodCustom<"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined, "decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined>>>;
|
|
46
49
|
}, z.core.$strip>>;
|
|
50
|
+
successMessage: z.ZodString;
|
|
47
51
|
}, z.core.$strip>;
|
|
48
|
-
export type
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
export type ManufacTestFormConfig = z.infer<typeof FormSchema>;
|
|
53
|
+
/** Validates the `forms` fixture option. */
|
|
54
|
+
export declare const ManufacTestFormsSchema: z.ZodArray<z.ZodObject<{
|
|
55
|
+
formID: z.ZodString;
|
|
56
|
+
page: z.ZodString;
|
|
57
|
+
fields: z.ZodArray<z.ZodObject<{
|
|
58
|
+
fieldID: z.ZodString;
|
|
59
|
+
validValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
60
|
+
interaction: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
61
|
+
/** A plain input */
|
|
62
|
+
readonly Fill: "fill";
|
|
63
|
+
/** A popup-based date/time picker widget */
|
|
64
|
+
readonly Datetime: "datetime";
|
|
65
|
+
}>>>;
|
|
66
|
+
invalidValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
67
|
+
isRequired: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
68
|
+
expectedAutocomplete: z.ZodOptional<z.ZodNullable<z.ZodCustom<import("react").HTMLInputAutoCompleteAttribute | undefined, import("react").HTMLInputAutoCompleteAttribute | undefined>>>;
|
|
69
|
+
expectedInputMode: z.ZodOptional<z.ZodNullable<z.ZodCustom<"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined, "decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined>>>;
|
|
70
|
+
}, z.core.$strip>>;
|
|
71
|
+
successMessage: z.ZodString;
|
|
72
|
+
}, z.core.$strip>>;
|
|
61
73
|
export declare const ManufacTestConfigSchema: z.ZodObject<{
|
|
62
74
|
pages: z.ZodArray<z.ZodString>;
|
|
63
|
-
selectors: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
64
|
-
hamburgerTestID: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
65
|
-
ctaTestID: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
66
|
-
cookieAcceptTestID: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
67
|
-
homeLinkTestID: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
68
|
-
carouselSlideTestID: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
69
|
-
carouselDotTestID: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
70
|
-
}, z.core.$strip>>>;
|
|
71
75
|
forms: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
72
|
-
|
|
76
|
+
formID: z.ZodString;
|
|
77
|
+
page: z.ZodString;
|
|
73
78
|
fields: z.ZodArray<z.ZodObject<{
|
|
74
|
-
|
|
79
|
+
fieldID: z.ZodString;
|
|
75
80
|
validValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
76
81
|
interaction: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
|
|
77
82
|
/** A plain input */
|
|
@@ -80,10 +85,18 @@ export declare const ManufacTestConfigSchema: z.ZodObject<{
|
|
|
80
85
|
readonly Datetime: "datetime";
|
|
81
86
|
}>>>;
|
|
82
87
|
invalidValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
88
|
+
isRequired: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
83
89
|
expectedAutocomplete: z.ZodOptional<z.ZodNullable<z.ZodCustom<import("react").HTMLInputAutoCompleteAttribute | undefined, import("react").HTMLInputAutoCompleteAttribute | undefined>>>;
|
|
84
90
|
expectedInputMode: z.ZodOptional<z.ZodNullable<z.ZodCustom<"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined, "decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined>>>;
|
|
85
91
|
}, z.core.$strip>>;
|
|
92
|
+
successMessage: z.ZodString;
|
|
86
93
|
}, z.core.$strip>>>>;
|
|
87
94
|
}, z.core.$strip>;
|
|
88
95
|
export type ManufacTestConfig = z.infer<typeof ManufacTestConfigSchema>;
|
|
96
|
+
export declare const StorybookIndexSchema: z.ZodObject<{
|
|
97
|
+
entries: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
98
|
+
type: z.ZodString;
|
|
99
|
+
id: z.ZodString;
|
|
100
|
+
}, z.core.$strip>>;
|
|
101
|
+
}, z.core.$strip>;
|
|
89
102
|
export {};
|
package/dist/schema/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ManufacTestConfigSchema = exports.FieldInteractions = void 0;
|
|
3
|
+
exports.StorybookIndexSchema = exports.ManufacTestConfigSchema = exports.ManufacTestFormsSchema = exports.FieldInteractions = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
/** Interaction kind for filling a form field. */
|
|
6
6
|
exports.FieldInteractions = {
|
|
@@ -15,54 +15,32 @@ exports.FieldInteractions = {
|
|
|
15
15
|
* (e.g. a field with no `invalidValue` is simply skipped by the invalid-input check for that form).
|
|
16
16
|
*/
|
|
17
17
|
const FormFieldSchema = zod_1.z.object({
|
|
18
|
-
|
|
19
|
-
testID: zod_1.z.string().min(1),
|
|
20
|
-
/** Value used to fill this field on the happy path. Omit to skip filling this field. */
|
|
18
|
+
fieldID: zod_1.z.string().min(1),
|
|
21
19
|
validValue: zod_1.z.string().nullish(),
|
|
22
|
-
/** How to interact with (or fill) this input */
|
|
23
20
|
interaction: zod_1.z.enum(exports.FieldInteractions).nullish(),
|
|
24
|
-
/**
|
|
25
|
-
* Deliberately invalid value used by the invalid-input check. Omit to skip that check for this field.
|
|
26
|
-
* - The check only verifies the field is left in an invalid state (`aria-invalid` or a failing native `checkValidity()`).
|
|
27
|
-
*/
|
|
28
21
|
invalidValue: zod_1.z.string().nullish(),
|
|
29
|
-
|
|
30
|
-
/** Expected `autocomplete` attribute value, checked by the autofill spec. Omit to skip that check. */
|
|
22
|
+
isRequired: zod_1.z.boolean().nullish(),
|
|
31
23
|
expectedAutocomplete: zod_1.z.custom().nullish(),
|
|
32
|
-
/** Expected `inputmode` attribute value, checked by the soft-keyboard spec. Omit to skip that check. */
|
|
33
24
|
expectedInputMode: zod_1.z.custom().nullish(),
|
|
34
25
|
});
|
|
35
26
|
/**
|
|
36
27
|
* Represents One form to be tested
|
|
37
28
|
*/
|
|
38
29
|
const FormSchema = zod_1.z.object({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/** Fields belonging to this form. */
|
|
30
|
+
formID: zod_1.z.string().min(1),
|
|
31
|
+
page: zod_1.z.string().min(1),
|
|
42
32
|
fields: zod_1.z.array(FormFieldSchema).min(1),
|
|
33
|
+
successMessage: zod_1.z.string().min(1),
|
|
43
34
|
});
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
const ManufacTestSelectorsSchema = zod_1.z.object({
|
|
48
|
-
/** Mobile nav hamburger/menu-toggle button. Single element — this testid must be unique on the page. */
|
|
49
|
-
hamburgerTestID: zod_1.z.string().min(1).nullish(),
|
|
50
|
-
/** Primary CTA button/link checked by CT-01. Single element — this testid must be unique on the page. */
|
|
51
|
-
ctaTestID: zod_1.z.string().min(1).nullish(),
|
|
52
|
-
/** Cookie-consent banner's accept/dismiss button. Single element — this testid must be unique on the page. */
|
|
53
|
-
cookieAcceptTestID: zod_1.z.string().min(1).nullish(),
|
|
54
|
-
/** "Back to home" link shown on the 404 page. Single element — this testid must be unique on the page. */
|
|
55
|
-
homeLinkTestID: zod_1.z.string().min(1).nullish(),
|
|
56
|
-
/** Shared testID present on every carousel slide — a group, not one element: put the SAME testid on every slide. */
|
|
57
|
-
carouselSlideTestID: zod_1.z.string().min(1).nullish(),
|
|
58
|
-
/** Shared testID present on every carousel pagination dot — a group, not one element: put the SAME testid on every dot. */
|
|
59
|
-
carouselDotTestID: zod_1.z.string().min(1).nullish(),
|
|
60
|
-
});
|
|
35
|
+
/** Validates the `forms` fixture option. */
|
|
36
|
+
exports.ManufacTestFormsSchema = zod_1.z.array(FormSchema);
|
|
61
37
|
exports.ManufacTestConfigSchema = zod_1.z.object({
|
|
62
|
-
/** Pages this suite should run against. */
|
|
63
38
|
pages: zod_1.z.array(zod_1.z.string()).min(1, "At least one page is required"),
|
|
64
|
-
/** Defines selectors for commonly tested components. */
|
|
65
|
-
selectors: ManufacTestSelectorsSchema.nullish(),
|
|
66
|
-
/** Forms this suite should exercise, keyed by their own data-testid. Omit to skip form-related checks. */
|
|
67
39
|
forms: zod_1.z.array(FormSchema).nullish(),
|
|
68
40
|
});
|
|
41
|
+
exports.StorybookIndexSchema = zod_1.z.object({
|
|
42
|
+
entries: zod_1.z.record(zod_1.z.string(), zod_1.z.object({
|
|
43
|
+
type: zod_1.z.string(),
|
|
44
|
+
id: zod_1.z.string(),
|
|
45
|
+
})),
|
|
46
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const utils_1 = require("./utils");
|
|
4
|
+
const fixtures_1 = require("../fixtures");
|
|
5
|
+
const test_1 = require("@playwright/test");
|
|
6
|
+
fixtures_1.test.describe("Forms & lead capture", () => {
|
|
7
|
+
(0, fixtures_1.test)("FM-01 Happy path submission", async ({ page, forms, consoleErrors }) => {
|
|
8
|
+
fixtures_1.test.slow();
|
|
9
|
+
for (const form of forms) {
|
|
10
|
+
await fixtures_1.test.step(`${form.page} (${form.formID})`, async () => {
|
|
11
|
+
const consoleErrorsBeforeSubmit = consoleErrors.length; // Capture errors while filling the forms.
|
|
12
|
+
await page.goto(form.page);
|
|
13
|
+
const formLocator = page.getByTestId(form.formID);
|
|
14
|
+
await (0, test_1.expect)(formLocator).toBeVisible();
|
|
15
|
+
for (const field of form.fields) {
|
|
16
|
+
await (0, utils_1.fillField)({ page, locator: formLocator, field });
|
|
17
|
+
}
|
|
18
|
+
const submittedAt = new Date().toISOString();
|
|
19
|
+
await formLocator.locator('button[type="submit"]').click();
|
|
20
|
+
await (0, test_1.expect)(page.getByText(form.successMessage, {
|
|
21
|
+
exact: true,
|
|
22
|
+
})).toBeVisible();
|
|
23
|
+
(0, test_1.expect)(consoleErrors.slice(consoleErrorsBeforeSubmit)).toHaveLength(0);
|
|
24
|
+
// Manual, staging-only follow-up — see downstream check below.
|
|
25
|
+
fixtures_1.test.info().annotations.push({
|
|
26
|
+
type: "FM-01 downstream check",
|
|
27
|
+
description: `Verify "${form.formID}" submission from ${form.page} on staging (submitted ${submittedAt}).`,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
(0, fixtures_1.test)("FM-02 Required field validation", async ({ page, forms }) => {
|
|
33
|
+
fixtures_1.test.slow();
|
|
34
|
+
for (const form of forms) {
|
|
35
|
+
const requiredFields = form.fields.filter((field) => {
|
|
36
|
+
return field.isRequired === true;
|
|
37
|
+
});
|
|
38
|
+
for (const requiredField of requiredFields) {
|
|
39
|
+
await fixtures_1.test.step(`${form.page} (${form.formID}) - ${requiredField.fieldID}`, async () => {
|
|
40
|
+
await page.goto(form.page);
|
|
41
|
+
const formLocator = page.getByTestId(form.formID);
|
|
42
|
+
await (0, test_1.expect)(formLocator).toBeVisible();
|
|
43
|
+
for (const field of form.fields) {
|
|
44
|
+
await (0, utils_1.fillField)({
|
|
45
|
+
page,
|
|
46
|
+
locator: formLocator,
|
|
47
|
+
field: {
|
|
48
|
+
...field,
|
|
49
|
+
validValue: field.fieldID === requiredField.fieldID ? "" : field.validValue,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
await formLocator.locator('button[type="submit"]').click();
|
|
54
|
+
await (0, test_1.expect)(page.getByText(new RegExp(form.successMessage, "i"))).not.toBeVisible();
|
|
55
|
+
const requiredFieldLocator = formLocator.getByTestId(requiredField.fieldID);
|
|
56
|
+
const hasNativeError = await (0, utils_1.hasNativeValidationError)(requiredFieldLocator); // Checks for the browser native validation error for required field.
|
|
57
|
+
if (hasNativeError) {
|
|
58
|
+
(0, test_1.expect)(hasNativeError).toBeTruthy();
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
await (0, test_1.expect)(requiredFieldLocator).toHaveAttribute("aria-invalid", "true");
|
|
62
|
+
const describedBy = await requiredFieldLocator.getAttribute("aria-describedby");
|
|
63
|
+
(0, test_1.expect)(describedBy, `"${requiredField.fieldID}" should be linked to its error via aria-describedby`).toBeTruthy();
|
|
64
|
+
const errorID = describedBy?.split(" ").at(0) ?? ""; // aria-describedby can hold several element IDs, separated by spaces Ref: https://github.com/mantinedev/mantine/blob/e49d1d94280d9841ff27a46f1935ebcba0f1b639/packages/%40mantine/core/src/components/Input/InputWrapper/InputWrapper.tsx#L213
|
|
65
|
+
const errorLocator = page.locator(`#${errorID}`);
|
|
66
|
+
await (0, test_1.expect)(errorLocator).toBeVisible();
|
|
67
|
+
const errorText = (await errorLocator.textContent())?.trim() ?? "";
|
|
68
|
+
(0, test_1.expect)(errorText, `"${requiredField.fieldID}" error message should mention it's required`).toMatch(/required/i);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
package/dist/specs/utils.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Locator, type Page } from "@playwright/test";
|
|
2
|
+
import type { ManufacTestFormField } from "../schema";
|
|
2
3
|
export declare const ResponsiveConfig: {
|
|
3
4
|
readonly ScrollSteps: 10;
|
|
4
5
|
readonly ScrollStepDelayMs: 100;
|
|
@@ -156,4 +157,15 @@ export interface SafeAreaOverlap extends ElementDescriptor {
|
|
|
156
157
|
overlapZone: SafeAreaZone;
|
|
157
158
|
}
|
|
158
159
|
export declare function findSafeAreaOverlaps(page: Page): Promise<SafeAreaOverlap[]>;
|
|
160
|
+
interface FillFieldParams {
|
|
161
|
+
page: Page;
|
|
162
|
+
locator: Locator;
|
|
163
|
+
field: ManufacTestFormField;
|
|
164
|
+
}
|
|
165
|
+
export declare function fillField({ field, locator, page }: FillFieldParams): Promise<void>;
|
|
166
|
+
/**
|
|
167
|
+
* Checks whether the browser's native validation (e.g. the `required` attribute) rejects the field's current value.
|
|
168
|
+
* Native validation blocks the submit itself, so no custom error is rendered for it.
|
|
169
|
+
*/
|
|
170
|
+
export declare function hasNativeValidationError(fieldLocator: Locator): Promise<boolean>;
|
|
159
171
|
export {};
|
package/dist/specs/utils.js
CHANGED
|
@@ -33,6 +33,8 @@ exports.traverseSlidesForward = traverseSlidesForward;
|
|
|
33
33
|
exports.checkSwipeBackward = checkSwipeBackward;
|
|
34
34
|
exports.checkAllDots = checkAllDots;
|
|
35
35
|
exports.findSafeAreaOverlaps = findSafeAreaOverlaps;
|
|
36
|
+
exports.fillField = fillField;
|
|
37
|
+
exports.hasNativeValidationError = hasNativeValidationError;
|
|
36
38
|
const test_1 = require("@playwright/test");
|
|
37
39
|
exports.ResponsiveConfig = {
|
|
38
40
|
ScrollSteps: 10,
|
|
@@ -856,3 +858,58 @@ async function findSafeAreaOverlaps(page) {
|
|
|
856
858
|
return overlaps;
|
|
857
859
|
}, { selector: contentSelector });
|
|
858
860
|
}
|
|
861
|
+
const MonthNames = [
|
|
862
|
+
"January",
|
|
863
|
+
"February",
|
|
864
|
+
"March",
|
|
865
|
+
"April",
|
|
866
|
+
"May",
|
|
867
|
+
"June",
|
|
868
|
+
"July",
|
|
869
|
+
"August",
|
|
870
|
+
"September",
|
|
871
|
+
"October",
|
|
872
|
+
"November",
|
|
873
|
+
"December",
|
|
874
|
+
];
|
|
875
|
+
async function fillDatetime({ field, locator, page }) {
|
|
876
|
+
// A fresh page load already leaves the picker empty; nothing to clear or open for.
|
|
877
|
+
if (field.validValue != null && field.validValue !== "") {
|
|
878
|
+
const date = new Date(field.validValue);
|
|
879
|
+
await locator.click();
|
|
880
|
+
const dialog = page.getByRole("dialog");
|
|
881
|
+
await (0, test_1.expect)(dialog).toBeVisible(); // Check dialog for visibility to select months
|
|
882
|
+
const dayLabel = `${date.getDate().toString()} ${MonthNames[date.getMonth()]} ${date.getFullYear().toString()}`;
|
|
883
|
+
await dialog.getByRole("button", { name: dayLabel, exact: true }).click(); // Get the exact date cell with aria-label comparison.
|
|
884
|
+
const hour24 = date.getHours();
|
|
885
|
+
const hour12 = ((hour24 + 11) % 12) + 1;
|
|
886
|
+
const period = hour24 >= 12 ? "PM" : "AM";
|
|
887
|
+
const timeSegments = dialog.getByRole("spinbutton");
|
|
888
|
+
await timeSegments.nth(0).fill(hour12.toString().padStart(2, "0")); // First spin button is hour scroll
|
|
889
|
+
await timeSegments.nth(1).fill(date.getMinutes().toString().padStart(2, "0")); // second spin button is minute scroll
|
|
890
|
+
await dialog.getByRole("textbox").fill(period);
|
|
891
|
+
await dialog.getByRole("button").last().click(); // No specific selectors available for this button like aria-* attributes so using .last as selector
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
async function fillField({ field, locator, page }) {
|
|
895
|
+
const fieldLocator = locator.getByTestId(field.fieldID);
|
|
896
|
+
if (field.interaction === exports.FieldInteractions.Datetime) {
|
|
897
|
+
await fillDatetime({
|
|
898
|
+
field,
|
|
899
|
+
locator: fieldLocator,
|
|
900
|
+
page,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
else if (field.validValue != null) {
|
|
904
|
+
await fieldLocator.fill(field.validValue);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Checks whether the browser's native validation (e.g. the `required` attribute) rejects the field's current value.
|
|
909
|
+
* Native validation blocks the submit itself, so no custom error is rendered for it.
|
|
910
|
+
*/
|
|
911
|
+
async function hasNativeValidationError(fieldLocator) {
|
|
912
|
+
return await fieldLocator.evaluate((element) => {
|
|
913
|
+
return element instanceof HTMLInputElement && !element.checkValidity();
|
|
914
|
+
});
|
|
915
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const schema_1 = require("../schema");
|
|
4
|
+
const test_1 = require("@playwright/test");
|
|
5
|
+
(0, test_1.test)("storybook stories match their baseline screenshots", async ({ page }) => {
|
|
6
|
+
// index.json is generated at runtime by the Storybook dev server (resolved against
|
|
7
|
+
// baseURL in the consuming app's playwright.storybook.config.ts), not a file in the repo.
|
|
8
|
+
const response = await page.request.get("index.json");
|
|
9
|
+
const result = schema_1.StorybookIndexSchema.safeParse(await response.json());
|
|
10
|
+
if (!result.success) {
|
|
11
|
+
throw new Error(`Failed to parse Storybook index: ${result.error.message}`);
|
|
12
|
+
}
|
|
13
|
+
const storyIDs = Object.values(result.data.entries)
|
|
14
|
+
.filter((entry) => {
|
|
15
|
+
return entry.type === "story";
|
|
16
|
+
})
|
|
17
|
+
.map((entry) => {
|
|
18
|
+
return entry.id;
|
|
19
|
+
});
|
|
20
|
+
const errors = [];
|
|
21
|
+
for (const storyID of storyIDs) {
|
|
22
|
+
await test_1.test.step(storyID, async () => {
|
|
23
|
+
try {
|
|
24
|
+
await page.goto(`iframe.html?id=${storyID}&viewMode=story`);
|
|
25
|
+
await (0, test_1.expect)(page.locator("#storybook-root")).toBeVisible();
|
|
26
|
+
await (0, test_1.expect)(page.locator(".sb-preparing-story .sb-loader")).toBeHidden(); // Avoid screenshotting a half-rendered story
|
|
27
|
+
await (0, test_1.expect)(page).toHaveScreenshot(`${storyID}.png`, { fullPage: true, timeout: 10_000 });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
// Collect failures instead of stopping at the first broken story.
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
errors.push(`Story ${storyID} failed: ${message}`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
(0, test_1.expect)(errors, `Visual regression failures:\n${errors.join("\n")}`).toHaveLength(0);
|
|
37
|
+
});
|