@assertrx/dino 1.0.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 +293 -0
- package/extensions/dino.ts +70 -0
- package/extensions/mabl-mcp.ts +306 -0
- package/extensions/playwright-mcp.ts +142 -0
- package/package.json +47 -0
- package/prompts/create-pr.md +110 -0
- package/prompts/generate-bdd-from-url.md +65 -0
- package/prompts/generate-pom-from-url-cli.md +48 -0
- package/prompts/generate-pom-from-url.md +45 -0
- package/prompts/generate-spec-from-feature.md +64 -0
- package/prompts/heal-playwright-failures.md +106 -0
- package/prompts/setup-playwright.md +81 -0
- package/scripts/start-dino.cmd +8 -0
- package/scripts/start-dino.mjs +226 -0
- package/scripts/start-dino.ps1 +9 -0
- package/scripts/start-dino.sh +10 -0
- package/skills/acli/SKILL.md +292 -0
- package/skills/bdd-gherkin/SKILL.md +552 -0
- package/skills/build-review-interface/SKILL.md +96 -0
- package/skills/error-analysis/SKILL.md +164 -0
- package/skills/eval-audit/SKILL.md +183 -0
- package/skills/evaluate-rag/SKILL.md +177 -0
- package/skills/generate-synthetic-data/SKILL.md +131 -0
- package/skills/mabl-mcp/SKILL.md +79 -0
- package/skills/playwright-cli/SKILL.md +246 -0
- package/skills/playwright-cli/advanced-workflows.md +679 -0
- package/skills/playwright-cli/core-commands.md +375 -0
- package/skills/playwright-cli/device-emulation.md +475 -0
- package/skills/playwright-cli/request-mocking.md +373 -0
- package/skills/playwright-cli/running-custom-code.md +553 -0
- package/skills/playwright-cli/screenshots-and-media.md +457 -0
- package/skills/playwright-cli/session-management.md +425 -0
- package/skills/playwright-cli/storage-and-auth.md +487 -0
- package/skills/playwright-cli/test-generation.md +336 -0
- package/skills/playwright-cli/tracing-and-debugging.md +501 -0
- package/skills/playwright-core/SKILL.md +101 -0
- package/skills/playwright-core/accessibility.md +1517 -0
- package/skills/playwright-core/angular.md +1086 -0
- package/skills/playwright-core/api-testing.md +1623 -0
- package/skills/playwright-core/assertions-and-waiting.md +703 -0
- package/skills/playwright-core/auth-flows.md +1081 -0
- package/skills/playwright-core/authentication.md +1409 -0
- package/skills/playwright-core/browser-apis.md +673 -0
- package/skills/playwright-core/browser-extensions.md +319 -0
- package/skills/playwright-core/canvas-and-webgl.md +494 -0
- package/skills/playwright-core/clock-and-time-mocking.md +427 -0
- package/skills/playwright-core/common-pitfalls.md +1318 -0
- package/skills/playwright-core/component-testing.md +1179 -0
- package/skills/playwright-core/configuration.md +731 -0
- package/skills/playwright-core/crud-testing.md +945 -0
- package/skills/playwright-core/debugging.md +827 -0
- package/skills/playwright-core/drag-and-drop.md +992 -0
- package/skills/playwright-core/electron-testing.md +622 -0
- package/skills/playwright-core/error-and-edge-cases.md +1181 -0
- package/skills/playwright-core/error-index.md +1903 -0
- package/skills/playwright-core/file-operations.md +749 -0
- package/skills/playwright-core/file-upload-download.md +1012 -0
- package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
- package/skills/playwright-core/flaky-tests.md +883 -0
- package/skills/playwright-core/forms-and-validation.md +1056 -0
- package/skills/playwright-core/i18n-and-localization.md +622 -0
- package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
- package/skills/playwright-core/locator-strategy.md +588 -0
- package/skills/playwright-core/locators.md +743 -0
- package/skills/playwright-core/mobile-and-responsive.md +1669 -0
- package/skills/playwright-core/multi-context-and-popups.md +581 -0
- package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
- package/skills/playwright-core/network-mocking.md +1395 -0
- package/skills/playwright-core/nextjs.md +1013 -0
- package/skills/playwright-core/performance-testing.md +608 -0
- package/skills/playwright-core/react.md +1082 -0
- package/skills/playwright-core/search-and-filter.md +1366 -0
- package/skills/playwright-core/security-testing.md +624 -0
- package/skills/playwright-core/service-workers-and-pwa.md +524 -0
- package/skills/playwright-core/test-architecture.md +569 -0
- package/skills/playwright-core/test-data-management.md +1209 -0
- package/skills/playwright-core/test-organization.md +947 -0
- package/skills/playwright-core/third-party-integrations.md +754 -0
- package/skills/playwright-core/trace-analysis.md +161 -0
- package/skills/playwright-core/visual-regression.md +1027 -0
- package/skills/playwright-core/vue.md +1162 -0
- package/skills/playwright-core/websockets-and-realtime.md +623 -0
- package/skills/playwright-core/when-to-mock.md +827 -0
- package/skills/playwright-pom/SKILL.md +94 -0
- package/skills/playwright-pom/page-object-model.md +921 -0
- package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
- package/skills/validate-evaluator/SKILL.md +215 -0
- package/skills/write-judge-prompt/SKILL.md +144 -0
- package/themes/dino.json +66 -0
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
# Forms and Validation
|
|
2
|
+
|
|
3
|
+
> **When to use**: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions.
|
|
4
|
+
> **Prerequisites**: [core/locators.md](locators.md), [core/assertions-and-waiting.md](assertions-and-waiting.md)
|
|
5
|
+
|
|
6
|
+
## Quick Reference
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
// Text input
|
|
10
|
+
await page.getByLabel('Name').fill('Jane Doe');
|
|
11
|
+
|
|
12
|
+
// Select dropdown
|
|
13
|
+
await page.getByLabel('Country').selectOption('US');
|
|
14
|
+
await page.getByLabel('Country').selectOption({ label: 'United States' });
|
|
15
|
+
|
|
16
|
+
// Checkbox and radio
|
|
17
|
+
await page.getByLabel('Remember me').check();
|
|
18
|
+
await page.getByLabel('Express shipping').click();
|
|
19
|
+
|
|
20
|
+
// Date input
|
|
21
|
+
await page.getByLabel('Start date').fill('2025-03-15');
|
|
22
|
+
|
|
23
|
+
// Clear a field
|
|
24
|
+
await page.getByLabel('Name').clear();
|
|
25
|
+
|
|
26
|
+
// Submit
|
|
27
|
+
await page.getByRole('button', { name: 'Submit' }).click();
|
|
28
|
+
|
|
29
|
+
// Verify validation error
|
|
30
|
+
await expect(page.getByText('Email is required')).toBeVisible();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Patterns
|
|
34
|
+
|
|
35
|
+
### Filling Basic Form Fields
|
|
36
|
+
|
|
37
|
+
**Use when**: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio.
|
|
38
|
+
**Avoid when**: Never. This is the foundation pattern.
|
|
39
|
+
|
|
40
|
+
**TypeScript**
|
|
41
|
+
```typescript
|
|
42
|
+
import { test, expect } from '@playwright/test';
|
|
43
|
+
|
|
44
|
+
test('fill and submit a registration form', async ({ page }) => {
|
|
45
|
+
await page.goto('/register');
|
|
46
|
+
|
|
47
|
+
// Text inputs — use fill() which clears first, not type()
|
|
48
|
+
await page.getByLabel('First name').fill('Jane');
|
|
49
|
+
await page.getByLabel('Last name').fill('Doe');
|
|
50
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
51
|
+
await page.getByLabel('Password', { exact: true }).fill('S3cureP@ss!');
|
|
52
|
+
await page.getByLabel('Confirm password').fill('S3cureP@ss!');
|
|
53
|
+
|
|
54
|
+
// Textarea
|
|
55
|
+
await page.getByLabel('Bio').fill('Software engineer with 10 years of experience.');
|
|
56
|
+
|
|
57
|
+
// Number input
|
|
58
|
+
await page.getByLabel('Age').fill('32');
|
|
59
|
+
|
|
60
|
+
// Native <select>
|
|
61
|
+
await page.getByLabel('Country').selectOption('US');
|
|
62
|
+
|
|
63
|
+
// Select by visible label text (when value differs from display text)
|
|
64
|
+
await page.getByLabel('State').selectOption({ label: 'California' });
|
|
65
|
+
|
|
66
|
+
// Multi-select
|
|
67
|
+
await page.getByLabel('Interests').selectOption(['coding', 'testing', 'devops']);
|
|
68
|
+
|
|
69
|
+
// Checkbox — use check() not click() (idempotent: won't uncheck if already checked)
|
|
70
|
+
await page.getByLabel('I agree to the terms').check();
|
|
71
|
+
await expect(page.getByLabel('I agree to the terms')).toBeChecked();
|
|
72
|
+
|
|
73
|
+
// Radio button
|
|
74
|
+
await page.getByLabel('Monthly billing').check();
|
|
75
|
+
await expect(page.getByLabel('Monthly billing')).toBeChecked();
|
|
76
|
+
|
|
77
|
+
// Submit
|
|
78
|
+
await page.getByRole('button', { name: 'Create account' }).click();
|
|
79
|
+
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**JavaScript**
|
|
84
|
+
```javascript
|
|
85
|
+
const { test, expect } = require('@playwright/test');
|
|
86
|
+
|
|
87
|
+
test('fill and submit a registration form', async ({ page }) => {
|
|
88
|
+
await page.goto('/register');
|
|
89
|
+
|
|
90
|
+
await page.getByLabel('First name').fill('Jane');
|
|
91
|
+
await page.getByLabel('Last name').fill('Doe');
|
|
92
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
93
|
+
await page.getByLabel('Password', { exact: true }).fill('S3cureP@ss!');
|
|
94
|
+
await page.getByLabel('Confirm password').fill('S3cureP@ss!');
|
|
95
|
+
|
|
96
|
+
await page.getByLabel('Bio').fill('Software engineer with 10 years of experience.');
|
|
97
|
+
await page.getByLabel('Age').fill('32');
|
|
98
|
+
await page.getByLabel('Country').selectOption('US');
|
|
99
|
+
await page.getByLabel('State').selectOption({ label: 'California' });
|
|
100
|
+
await page.getByLabel('Interests').selectOption(['coding', 'testing', 'devops']);
|
|
101
|
+
|
|
102
|
+
await page.getByLabel('I agree to the terms').check();
|
|
103
|
+
await expect(page.getByLabel('I agree to the terms')).toBeChecked();
|
|
104
|
+
|
|
105
|
+
await page.getByLabel('Monthly billing').check();
|
|
106
|
+
await expect(page.getByLabel('Monthly billing')).toBeChecked();
|
|
107
|
+
|
|
108
|
+
await page.getByRole('button', { name: 'Create account' }).click();
|
|
109
|
+
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Date and Time Inputs
|
|
114
|
+
|
|
115
|
+
**Use when**: Testing native `<input type="date">`, `<input type="time">`, `<input type="datetime-local">`, or third-party date pickers.
|
|
116
|
+
**Avoid when**: The date picker is a simple text field with no special input type. Just use `fill()`.
|
|
117
|
+
|
|
118
|
+
**TypeScript**
|
|
119
|
+
```typescript
|
|
120
|
+
import { test, expect } from '@playwright/test';
|
|
121
|
+
|
|
122
|
+
test('fill native date and time inputs', async ({ page }) => {
|
|
123
|
+
await page.goto('/booking');
|
|
124
|
+
|
|
125
|
+
// Native date input — use ISO format YYYY-MM-DD
|
|
126
|
+
await page.getByLabel('Check-in date').fill('2025-06-15');
|
|
127
|
+
await expect(page.getByLabel('Check-in date')).toHaveValue('2025-06-15');
|
|
128
|
+
|
|
129
|
+
// Native time input — use HH:MM format
|
|
130
|
+
await page.getByLabel('Arrival time').fill('14:30');
|
|
131
|
+
|
|
132
|
+
// datetime-local — use YYYY-MM-DDTHH:MM format
|
|
133
|
+
await page.getByLabel('Event start').fill('2025-06-15T09:00');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('interact with a third-party date picker', async ({ page }) => {
|
|
137
|
+
await page.goto('/booking');
|
|
138
|
+
|
|
139
|
+
// Click to open the date picker
|
|
140
|
+
await page.getByLabel('Departure date').click();
|
|
141
|
+
|
|
142
|
+
// Navigate months if needed
|
|
143
|
+
await page.getByRole('button', { name: 'Next month' }).click();
|
|
144
|
+
|
|
145
|
+
// Select a specific day
|
|
146
|
+
await page.getByRole('gridcell', { name: '20' }).click();
|
|
147
|
+
|
|
148
|
+
// Verify the selected date appears in the input
|
|
149
|
+
await expect(page.getByLabel('Departure date')).toHaveValue(/2025/);
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**JavaScript**
|
|
154
|
+
```javascript
|
|
155
|
+
const { test, expect } = require('@playwright/test');
|
|
156
|
+
|
|
157
|
+
test('fill native date and time inputs', async ({ page }) => {
|
|
158
|
+
await page.goto('/booking');
|
|
159
|
+
|
|
160
|
+
await page.getByLabel('Check-in date').fill('2025-06-15');
|
|
161
|
+
await expect(page.getByLabel('Check-in date')).toHaveValue('2025-06-15');
|
|
162
|
+
|
|
163
|
+
await page.getByLabel('Arrival time').fill('14:30');
|
|
164
|
+
await page.getByLabel('Event start').fill('2025-06-15T09:00');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('interact with a third-party date picker', async ({ page }) => {
|
|
168
|
+
await page.goto('/booking');
|
|
169
|
+
|
|
170
|
+
await page.getByLabel('Departure date').click();
|
|
171
|
+
await page.getByRole('button', { name: 'Next month' }).click();
|
|
172
|
+
await page.getByRole('gridcell', { name: '20' }).click();
|
|
173
|
+
|
|
174
|
+
await expect(page.getByLabel('Departure date')).toHaveValue(/2025/);
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Required Field Validation
|
|
179
|
+
|
|
180
|
+
**Use when**: Testing that the form shows appropriate error messages when required fields are empty.
|
|
181
|
+
**Avoid when**: You only care about the happy path. Validation tests should complement, not replace, success path tests.
|
|
182
|
+
|
|
183
|
+
**TypeScript**
|
|
184
|
+
```typescript
|
|
185
|
+
import { test, expect } from '@playwright/test';
|
|
186
|
+
|
|
187
|
+
test('shows validation errors for empty required fields', async ({ page }) => {
|
|
188
|
+
await page.goto('/contact');
|
|
189
|
+
|
|
190
|
+
// Submit without filling anything
|
|
191
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
192
|
+
|
|
193
|
+
// Verify all required field errors appear
|
|
194
|
+
await expect(page.getByText('Name is required')).toBeVisible();
|
|
195
|
+
await expect(page.getByText('Email is required')).toBeVisible();
|
|
196
|
+
await expect(page.getByText('Message is required')).toBeVisible();
|
|
197
|
+
|
|
198
|
+
// Verify the form was NOT submitted (still on the same page)
|
|
199
|
+
await expect(page).toHaveURL(/\/contact/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('clears validation errors when fields are filled', async ({ page }) => {
|
|
203
|
+
await page.goto('/contact');
|
|
204
|
+
|
|
205
|
+
// Trigger errors
|
|
206
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
207
|
+
await expect(page.getByText('Name is required')).toBeVisible();
|
|
208
|
+
|
|
209
|
+
// Fill the field — error should disappear
|
|
210
|
+
await page.getByLabel('Name').fill('Jane Doe');
|
|
211
|
+
|
|
212
|
+
// Use tab or click away to trigger blur validation
|
|
213
|
+
await page.getByLabel('Email').focus();
|
|
214
|
+
|
|
215
|
+
await expect(page.getByText('Name is required')).not.toBeVisible();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('native HTML5 validation with required attribute', async ({ page }) => {
|
|
219
|
+
await page.goto('/simple-form');
|
|
220
|
+
|
|
221
|
+
await page.getByRole('button', { name: 'Submit' }).click();
|
|
222
|
+
|
|
223
|
+
// Check for native validation message via the :invalid pseudo-class
|
|
224
|
+
const emailInput = page.getByLabel('Email');
|
|
225
|
+
const validationMessage = await emailInput.evaluate(
|
|
226
|
+
(el: HTMLInputElement) => el.validationMessage
|
|
227
|
+
);
|
|
228
|
+
expect(validationMessage).toBeTruthy();
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**JavaScript**
|
|
233
|
+
```javascript
|
|
234
|
+
const { test, expect } = require('@playwright/test');
|
|
235
|
+
|
|
236
|
+
test('shows validation errors for empty required fields', async ({ page }) => {
|
|
237
|
+
await page.goto('/contact');
|
|
238
|
+
|
|
239
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
240
|
+
|
|
241
|
+
await expect(page.getByText('Name is required')).toBeVisible();
|
|
242
|
+
await expect(page.getByText('Email is required')).toBeVisible();
|
|
243
|
+
await expect(page.getByText('Message is required')).toBeVisible();
|
|
244
|
+
|
|
245
|
+
await expect(page).toHaveURL(/\/contact/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('clears validation errors when fields are filled', async ({ page }) => {
|
|
249
|
+
await page.goto('/contact');
|
|
250
|
+
|
|
251
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
252
|
+
await expect(page.getByText('Name is required')).toBeVisible();
|
|
253
|
+
|
|
254
|
+
await page.getByLabel('Name').fill('Jane Doe');
|
|
255
|
+
await page.getByLabel('Email').focus();
|
|
256
|
+
|
|
257
|
+
await expect(page.getByText('Name is required')).not.toBeVisible();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('native HTML5 validation with required attribute', async ({ page }) => {
|
|
261
|
+
await page.goto('/simple-form');
|
|
262
|
+
|
|
263
|
+
await page.getByRole('button', { name: 'Submit' }).click();
|
|
264
|
+
|
|
265
|
+
const emailInput = page.getByLabel('Email');
|
|
266
|
+
const validationMessage = await emailInput.evaluate(
|
|
267
|
+
(el) => el.validationMessage
|
|
268
|
+
);
|
|
269
|
+
expect(validationMessage).toBeTruthy();
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Format Validation and Custom Rules
|
|
274
|
+
|
|
275
|
+
**Use when**: Testing email format, phone number format, password strength, and business-specific validation rules.
|
|
276
|
+
**Avoid when**: The validation is purely server-side with no client-side feedback. Test via API instead.
|
|
277
|
+
|
|
278
|
+
**TypeScript**
|
|
279
|
+
```typescript
|
|
280
|
+
import { test, expect } from '@playwright/test';
|
|
281
|
+
|
|
282
|
+
test('validates email format', async ({ page }) => {
|
|
283
|
+
await page.goto('/register');
|
|
284
|
+
|
|
285
|
+
const emailField = page.getByLabel('Email');
|
|
286
|
+
|
|
287
|
+
// Invalid formats
|
|
288
|
+
const invalidEmails = ['not-an-email', 'missing@', '@no-local.com', 'spaces in@email.com'];
|
|
289
|
+
|
|
290
|
+
for (const email of invalidEmails) {
|
|
291
|
+
await emailField.fill(email);
|
|
292
|
+
await emailField.blur();
|
|
293
|
+
await expect(page.getByText('Please enter a valid email')).toBeVisible();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Valid format clears the error
|
|
297
|
+
await emailField.fill('valid@example.com');
|
|
298
|
+
await emailField.blur();
|
|
299
|
+
await expect(page.getByText('Please enter a valid email')).not.toBeVisible();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('validates password strength rules', async ({ page }) => {
|
|
303
|
+
await page.goto('/register');
|
|
304
|
+
|
|
305
|
+
const passwordField = page.getByLabel('Password', { exact: true });
|
|
306
|
+
|
|
307
|
+
// Too short
|
|
308
|
+
await passwordField.fill('Ab1!');
|
|
309
|
+
await passwordField.blur();
|
|
310
|
+
await expect(page.getByText('At least 8 characters')).toBeVisible();
|
|
311
|
+
|
|
312
|
+
// Missing uppercase
|
|
313
|
+
await passwordField.fill('abcdefg1!');
|
|
314
|
+
await passwordField.blur();
|
|
315
|
+
await expect(page.getByText('At least one uppercase letter')).toBeVisible();
|
|
316
|
+
|
|
317
|
+
// Strong password — all checks pass
|
|
318
|
+
await passwordField.fill('Str0ngP@ss!');
|
|
319
|
+
await passwordField.blur();
|
|
320
|
+
await expect(page.getByText(/At least/)).not.toBeVisible();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('validates custom business rule — age range', async ({ page }) => {
|
|
324
|
+
await page.goto('/insurance/quote');
|
|
325
|
+
|
|
326
|
+
await page.getByLabel('Age').fill('15');
|
|
327
|
+
await page.getByLabel('Age').blur();
|
|
328
|
+
await expect(page.getByText('Must be 18 or older')).toBeVisible();
|
|
329
|
+
|
|
330
|
+
await page.getByLabel('Age').fill('150');
|
|
331
|
+
await page.getByLabel('Age').blur();
|
|
332
|
+
await expect(page.getByText('Please enter a valid age')).toBeVisible();
|
|
333
|
+
|
|
334
|
+
await page.getByLabel('Age').fill('30');
|
|
335
|
+
await page.getByLabel('Age').blur();
|
|
336
|
+
await expect(page.getByText(/Must be|valid age/)).not.toBeVisible();
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**JavaScript**
|
|
341
|
+
```javascript
|
|
342
|
+
const { test, expect } = require('@playwright/test');
|
|
343
|
+
|
|
344
|
+
test('validates email format', async ({ page }) => {
|
|
345
|
+
await page.goto('/register');
|
|
346
|
+
|
|
347
|
+
const emailField = page.getByLabel('Email');
|
|
348
|
+
|
|
349
|
+
const invalidEmails = ['not-an-email', 'missing@', '@no-local.com', 'spaces in@email.com'];
|
|
350
|
+
|
|
351
|
+
for (const email of invalidEmails) {
|
|
352
|
+
await emailField.fill(email);
|
|
353
|
+
await emailField.blur();
|
|
354
|
+
await expect(page.getByText('Please enter a valid email')).toBeVisible();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
await emailField.fill('valid@example.com');
|
|
358
|
+
await emailField.blur();
|
|
359
|
+
await expect(page.getByText('Please enter a valid email')).not.toBeVisible();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('validates password strength rules', async ({ page }) => {
|
|
363
|
+
await page.goto('/register');
|
|
364
|
+
|
|
365
|
+
const passwordField = page.getByLabel('Password', { exact: true });
|
|
366
|
+
|
|
367
|
+
await passwordField.fill('Ab1!');
|
|
368
|
+
await passwordField.blur();
|
|
369
|
+
await expect(page.getByText('At least 8 characters')).toBeVisible();
|
|
370
|
+
|
|
371
|
+
await passwordField.fill('abcdefg1!');
|
|
372
|
+
await passwordField.blur();
|
|
373
|
+
await expect(page.getByText('At least one uppercase letter')).toBeVisible();
|
|
374
|
+
|
|
375
|
+
await passwordField.fill('Str0ngP@ss!');
|
|
376
|
+
await passwordField.blur();
|
|
377
|
+
await expect(page.getByText(/At least/)).not.toBeVisible();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('validates custom business rule — age range', async ({ page }) => {
|
|
381
|
+
await page.goto('/insurance/quote');
|
|
382
|
+
|
|
383
|
+
await page.getByLabel('Age').fill('15');
|
|
384
|
+
await page.getByLabel('Age').blur();
|
|
385
|
+
await expect(page.getByText('Must be 18 or older')).toBeVisible();
|
|
386
|
+
|
|
387
|
+
await page.getByLabel('Age').fill('150');
|
|
388
|
+
await page.getByLabel('Age').blur();
|
|
389
|
+
await expect(page.getByText('Please enter a valid age')).toBeVisible();
|
|
390
|
+
|
|
391
|
+
await page.getByLabel('Age').fill('30');
|
|
392
|
+
await page.getByLabel('Age').blur();
|
|
393
|
+
await expect(page.getByText(/Must be|valid age/)).not.toBeVisible();
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Multi-Step Forms and Wizards
|
|
398
|
+
|
|
399
|
+
**Use when**: The form spans multiple pages or steps, with next/previous navigation and per-step validation.
|
|
400
|
+
**Avoid when**: The form is a single page. Use the basic form filling pattern.
|
|
401
|
+
|
|
402
|
+
**TypeScript**
|
|
403
|
+
```typescript
|
|
404
|
+
import { test, expect } from '@playwright/test';
|
|
405
|
+
|
|
406
|
+
test('complete a multi-step checkout wizard', async ({ page }) => {
|
|
407
|
+
await page.goto('/checkout');
|
|
408
|
+
|
|
409
|
+
// Step 1: Shipping
|
|
410
|
+
await test.step('fill shipping information', async () => {
|
|
411
|
+
await expect(page.getByRole('heading', { name: 'Shipping' })).toBeVisible();
|
|
412
|
+
|
|
413
|
+
await page.getByLabel('Address').fill('123 Main St');
|
|
414
|
+
await page.getByLabel('City').fill('Portland');
|
|
415
|
+
await page.getByLabel('State').selectOption('OR');
|
|
416
|
+
await page.getByLabel('ZIP code').fill('97201');
|
|
417
|
+
|
|
418
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Step 2: Payment
|
|
422
|
+
await test.step('fill payment details', async () => {
|
|
423
|
+
await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();
|
|
424
|
+
|
|
425
|
+
await page.getByLabel('Card number').fill('4242424242424242');
|
|
426
|
+
await page.getByLabel('Expiration').fill('12/28');
|
|
427
|
+
await page.getByLabel('CVC').fill('123');
|
|
428
|
+
|
|
429
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Step 3: Review
|
|
433
|
+
await test.step('review and confirm order', async () => {
|
|
434
|
+
await expect(page.getByRole('heading', { name: 'Review' })).toBeVisible();
|
|
435
|
+
|
|
436
|
+
// Verify data from previous steps is shown
|
|
437
|
+
await expect(page.getByText('123 Main St')).toBeVisible();
|
|
438
|
+
await expect(page.getByText('ending in 4242')).toBeVisible();
|
|
439
|
+
|
|
440
|
+
await page.getByRole('button', { name: 'Place order' }).click();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Confirmation
|
|
444
|
+
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('wizard validates each step before proceeding', async ({ page }) => {
|
|
448
|
+
await page.goto('/checkout');
|
|
449
|
+
|
|
450
|
+
// Try to skip step 1 without filling required fields
|
|
451
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
452
|
+
|
|
453
|
+
// Should stay on step 1 with validation errors
|
|
454
|
+
await expect(page.getByRole('heading', { name: 'Shipping' })).toBeVisible();
|
|
455
|
+
await expect(page.getByText('Address is required')).toBeVisible();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('wizard supports going back without losing data', async ({ page }) => {
|
|
459
|
+
await page.goto('/checkout');
|
|
460
|
+
|
|
461
|
+
// Fill step 1
|
|
462
|
+
await page.getByLabel('Address').fill('123 Main St');
|
|
463
|
+
await page.getByLabel('City').fill('Portland');
|
|
464
|
+
await page.getByLabel('State').selectOption('OR');
|
|
465
|
+
await page.getByLabel('ZIP code').fill('97201');
|
|
466
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
467
|
+
|
|
468
|
+
// Go back from step 2
|
|
469
|
+
await page.getByRole('button', { name: 'Back' }).click();
|
|
470
|
+
|
|
471
|
+
// Verify step 1 data is preserved
|
|
472
|
+
await expect(page.getByLabel('Address')).toHaveValue('123 Main St');
|
|
473
|
+
await expect(page.getByLabel('City')).toHaveValue('Portland');
|
|
474
|
+
});
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**JavaScript**
|
|
478
|
+
```javascript
|
|
479
|
+
const { test, expect } = require('@playwright/test');
|
|
480
|
+
|
|
481
|
+
test('complete a multi-step checkout wizard', async ({ page }) => {
|
|
482
|
+
await page.goto('/checkout');
|
|
483
|
+
|
|
484
|
+
await test.step('fill shipping information', async () => {
|
|
485
|
+
await expect(page.getByRole('heading', { name: 'Shipping' })).toBeVisible();
|
|
486
|
+
|
|
487
|
+
await page.getByLabel('Address').fill('123 Main St');
|
|
488
|
+
await page.getByLabel('City').fill('Portland');
|
|
489
|
+
await page.getByLabel('State').selectOption('OR');
|
|
490
|
+
await page.getByLabel('ZIP code').fill('97201');
|
|
491
|
+
|
|
492
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await test.step('fill payment details', async () => {
|
|
496
|
+
await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();
|
|
497
|
+
|
|
498
|
+
await page.getByLabel('Card number').fill('4242424242424242');
|
|
499
|
+
await page.getByLabel('Expiration').fill('12/28');
|
|
500
|
+
await page.getByLabel('CVC').fill('123');
|
|
501
|
+
|
|
502
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
await test.step('review and confirm order', async () => {
|
|
506
|
+
await expect(page.getByRole('heading', { name: 'Review' })).toBeVisible();
|
|
507
|
+
|
|
508
|
+
await expect(page.getByText('123 Main St')).toBeVisible();
|
|
509
|
+
await expect(page.getByText('ending in 4242')).toBeVisible();
|
|
510
|
+
|
|
511
|
+
await page.getByRole('button', { name: 'Place order' }).click();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('wizard validates each step before proceeding', async ({ page }) => {
|
|
518
|
+
await page.goto('/checkout');
|
|
519
|
+
|
|
520
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
521
|
+
|
|
522
|
+
await expect(page.getByRole('heading', { name: 'Shipping' })).toBeVisible();
|
|
523
|
+
await expect(page.getByText('Address is required')).toBeVisible();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('wizard supports going back without losing data', async ({ page }) => {
|
|
527
|
+
await page.goto('/checkout');
|
|
528
|
+
|
|
529
|
+
await page.getByLabel('Address').fill('123 Main St');
|
|
530
|
+
await page.getByLabel('City').fill('Portland');
|
|
531
|
+
await page.getByLabel('State').selectOption('OR');
|
|
532
|
+
await page.getByLabel('ZIP code').fill('97201');
|
|
533
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
534
|
+
|
|
535
|
+
await page.getByRole('button', { name: 'Back' }).click();
|
|
536
|
+
|
|
537
|
+
await expect(page.getByLabel('Address')).toHaveValue('123 Main St');
|
|
538
|
+
await expect(page.getByLabel('City')).toHaveValue('Portland');
|
|
539
|
+
});
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### Auto-Complete and Typeahead Fields
|
|
543
|
+
|
|
544
|
+
**Use when**: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types.
|
|
545
|
+
**Avoid when**: The field is a plain text input with no suggestions.
|
|
546
|
+
|
|
547
|
+
**TypeScript**
|
|
548
|
+
```typescript
|
|
549
|
+
import { test, expect } from '@playwright/test';
|
|
550
|
+
|
|
551
|
+
test('select from auto-complete suggestions', async ({ page }) => {
|
|
552
|
+
await page.goto('/search');
|
|
553
|
+
|
|
554
|
+
const searchField = page.getByRole('combobox', { name: 'Search' });
|
|
555
|
+
|
|
556
|
+
// Type slowly enough for suggestions to appear
|
|
557
|
+
// pressSequentially simulates real keystrokes (triggers keydown/keyup/input events)
|
|
558
|
+
await searchField.pressSequentially('playw', { delay: 100 });
|
|
559
|
+
|
|
560
|
+
// Wait for the suggestion list to appear
|
|
561
|
+
const suggestions = page.getByRole('listbox');
|
|
562
|
+
await expect(suggestions).toBeVisible();
|
|
563
|
+
|
|
564
|
+
// Select a specific suggestion
|
|
565
|
+
await suggestions.getByRole('option', { name: 'Playwright Testing' }).click();
|
|
566
|
+
|
|
567
|
+
// Verify the selection populated the field
|
|
568
|
+
await expect(searchField).toHaveValue('Playwright Testing');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('auto-complete with API-driven suggestions', async ({ page }) => {
|
|
572
|
+
await page.goto('/address-form');
|
|
573
|
+
|
|
574
|
+
const addressField = page.getByLabel('Address');
|
|
575
|
+
await addressField.pressSequentially('123 Ma', { delay: 50 });
|
|
576
|
+
|
|
577
|
+
// Wait for the API-driven suggestion list
|
|
578
|
+
const responsePromise = page.waitForResponse('**/api/address-suggest*');
|
|
579
|
+
await responsePromise;
|
|
580
|
+
|
|
581
|
+
await page.getByRole('option', { name: /123 Main St/ }).click();
|
|
582
|
+
|
|
583
|
+
// Verify dependent fields were auto-populated
|
|
584
|
+
await expect(page.getByLabel('City')).toHaveValue('Portland');
|
|
585
|
+
await expect(page.getByLabel('State')).toHaveValue('OR');
|
|
586
|
+
await expect(page.getByLabel('ZIP code')).toHaveValue('97201');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('dismiss auto-complete and use custom value', async ({ page }) => {
|
|
590
|
+
await page.goto('/tags');
|
|
591
|
+
|
|
592
|
+
const tagInput = page.getByLabel('Add tag');
|
|
593
|
+
await tagInput.pressSequentially('custom-tag');
|
|
594
|
+
|
|
595
|
+
// Dismiss suggestions with Escape
|
|
596
|
+
await tagInput.press('Escape');
|
|
597
|
+
await expect(page.getByRole('listbox')).not.toBeVisible();
|
|
598
|
+
|
|
599
|
+
// Submit custom value with Enter
|
|
600
|
+
await tagInput.press('Enter');
|
|
601
|
+
await expect(page.getByText('custom-tag')).toBeVisible();
|
|
602
|
+
});
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
**JavaScript**
|
|
606
|
+
```javascript
|
|
607
|
+
const { test, expect } = require('@playwright/test');
|
|
608
|
+
|
|
609
|
+
test('select from auto-complete suggestions', async ({ page }) => {
|
|
610
|
+
await page.goto('/search');
|
|
611
|
+
|
|
612
|
+
const searchField = page.getByRole('combobox', { name: 'Search' });
|
|
613
|
+
await searchField.pressSequentially('playw', { delay: 100 });
|
|
614
|
+
|
|
615
|
+
const suggestions = page.getByRole('listbox');
|
|
616
|
+
await expect(suggestions).toBeVisible();
|
|
617
|
+
|
|
618
|
+
await suggestions.getByRole('option', { name: 'Playwright Testing' }).click();
|
|
619
|
+
await expect(searchField).toHaveValue('Playwright Testing');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('auto-complete with API-driven suggestions', async ({ page }) => {
|
|
623
|
+
await page.goto('/address-form');
|
|
624
|
+
|
|
625
|
+
const addressField = page.getByLabel('Address');
|
|
626
|
+
await addressField.pressSequentially('123 Ma', { delay: 50 });
|
|
627
|
+
|
|
628
|
+
const responsePromise = page.waitForResponse('**/api/address-suggest*');
|
|
629
|
+
await responsePromise;
|
|
630
|
+
|
|
631
|
+
await page.getByRole('option', { name: /123 Main St/ }).click();
|
|
632
|
+
|
|
633
|
+
await expect(page.getByLabel('City')).toHaveValue('Portland');
|
|
634
|
+
await expect(page.getByLabel('State')).toHaveValue('OR');
|
|
635
|
+
await expect(page.getByLabel('ZIP code')).toHaveValue('97201');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test('dismiss auto-complete and use custom value', async ({ page }) => {
|
|
639
|
+
await page.goto('/tags');
|
|
640
|
+
|
|
641
|
+
const tagInput = page.getByLabel('Add tag');
|
|
642
|
+
await tagInput.pressSequentially('custom-tag');
|
|
643
|
+
|
|
644
|
+
await tagInput.press('Escape');
|
|
645
|
+
await expect(page.getByRole('listbox')).not.toBeVisible();
|
|
646
|
+
|
|
647
|
+
await tagInput.press('Enter');
|
|
648
|
+
await expect(page.getByText('custom-tag')).toBeVisible();
|
|
649
|
+
});
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### Dynamic Forms — Conditional Fields
|
|
653
|
+
|
|
654
|
+
**Use when**: Form fields appear, disappear, or change based on the value of other fields.
|
|
655
|
+
**Avoid when**: All fields are always visible. Use the basic form filling pattern.
|
|
656
|
+
|
|
657
|
+
**TypeScript**
|
|
658
|
+
```typescript
|
|
659
|
+
import { test, expect } from '@playwright/test';
|
|
660
|
+
|
|
661
|
+
test('conditional fields appear based on selection', async ({ page }) => {
|
|
662
|
+
await page.goto('/insurance/apply');
|
|
663
|
+
|
|
664
|
+
// Selecting "Business" shows additional fields
|
|
665
|
+
await page.getByLabel('Account type').selectOption('business');
|
|
666
|
+
|
|
667
|
+
// Wait for conditional fields to appear
|
|
668
|
+
await expect(page.getByLabel('Company name')).toBeVisible();
|
|
669
|
+
await expect(page.getByLabel('Tax ID')).toBeVisible();
|
|
670
|
+
|
|
671
|
+
await page.getByLabel('Company name').fill('Acme Corp');
|
|
672
|
+
await page.getByLabel('Tax ID').fill('12-3456789');
|
|
673
|
+
|
|
674
|
+
// Switching back to "Personal" hides them
|
|
675
|
+
await page.getByLabel('Account type').selectOption('personal');
|
|
676
|
+
await expect(page.getByLabel('Company name')).not.toBeVisible();
|
|
677
|
+
await expect(page.getByLabel('Tax ID')).not.toBeVisible();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test('checkbox toggles additional section', async ({ page }) => {
|
|
681
|
+
await page.goto('/shipping');
|
|
682
|
+
|
|
683
|
+
// "Different billing address" reveals billing fields
|
|
684
|
+
await page.getByLabel('Use different billing address').check();
|
|
685
|
+
|
|
686
|
+
const billingSection = page.getByRole('group', { name: 'Billing address' });
|
|
687
|
+
await expect(billingSection).toBeVisible();
|
|
688
|
+
|
|
689
|
+
await billingSection.getByLabel('Street').fill('456 Oak Ave');
|
|
690
|
+
await billingSection.getByLabel('City').fill('Seattle');
|
|
691
|
+
|
|
692
|
+
// Unchecking hides the section
|
|
693
|
+
await page.getByLabel('Use different billing address').uncheck();
|
|
694
|
+
await expect(billingSection).not.toBeVisible();
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('dependent dropdown chains', async ({ page }) => {
|
|
698
|
+
await page.goto('/location-picker');
|
|
699
|
+
|
|
700
|
+
// Country selection populates the state dropdown
|
|
701
|
+
await page.getByLabel('Country').selectOption('US');
|
|
702
|
+
|
|
703
|
+
// Wait for the dependent dropdown to be populated
|
|
704
|
+
const stateDropdown = page.getByLabel('State');
|
|
705
|
+
await expect(stateDropdown.getByRole('option')).not.toHaveCount(0);
|
|
706
|
+
|
|
707
|
+
await stateDropdown.selectOption('CA');
|
|
708
|
+
|
|
709
|
+
// State selection populates the city dropdown
|
|
710
|
+
const cityDropdown = page.getByLabel('City');
|
|
711
|
+
await expect(cityDropdown.getByRole('option')).not.toHaveCount(0);
|
|
712
|
+
|
|
713
|
+
await cityDropdown.selectOption({ label: 'Los Angeles' });
|
|
714
|
+
});
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
**JavaScript**
|
|
718
|
+
```javascript
|
|
719
|
+
const { test, expect } = require('@playwright/test');
|
|
720
|
+
|
|
721
|
+
test('conditional fields appear based on selection', async ({ page }) => {
|
|
722
|
+
await page.goto('/insurance/apply');
|
|
723
|
+
|
|
724
|
+
await page.getByLabel('Account type').selectOption('business');
|
|
725
|
+
|
|
726
|
+
await expect(page.getByLabel('Company name')).toBeVisible();
|
|
727
|
+
await expect(page.getByLabel('Tax ID')).toBeVisible();
|
|
728
|
+
|
|
729
|
+
await page.getByLabel('Company name').fill('Acme Corp');
|
|
730
|
+
await page.getByLabel('Tax ID').fill('12-3456789');
|
|
731
|
+
|
|
732
|
+
await page.getByLabel('Account type').selectOption('personal');
|
|
733
|
+
await expect(page.getByLabel('Company name')).not.toBeVisible();
|
|
734
|
+
await expect(page.getByLabel('Tax ID')).not.toBeVisible();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('checkbox toggles additional section', async ({ page }) => {
|
|
738
|
+
await page.goto('/shipping');
|
|
739
|
+
|
|
740
|
+
await page.getByLabel('Use different billing address').check();
|
|
741
|
+
|
|
742
|
+
const billingSection = page.getByRole('group', { name: 'Billing address' });
|
|
743
|
+
await expect(billingSection).toBeVisible();
|
|
744
|
+
|
|
745
|
+
await billingSection.getByLabel('Street').fill('456 Oak Ave');
|
|
746
|
+
await billingSection.getByLabel('City').fill('Seattle');
|
|
747
|
+
|
|
748
|
+
await page.getByLabel('Use different billing address').uncheck();
|
|
749
|
+
await expect(billingSection).not.toBeVisible();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test('dependent dropdown chains', async ({ page }) => {
|
|
753
|
+
await page.goto('/location-picker');
|
|
754
|
+
|
|
755
|
+
await page.getByLabel('Country').selectOption('US');
|
|
756
|
+
|
|
757
|
+
const stateDropdown = page.getByLabel('State');
|
|
758
|
+
await expect(stateDropdown.getByRole('option')).not.toHaveCount(0);
|
|
759
|
+
|
|
760
|
+
await stateDropdown.selectOption('CA');
|
|
761
|
+
|
|
762
|
+
const cityDropdown = page.getByLabel('City');
|
|
763
|
+
await expect(cityDropdown.getByRole('option')).not.toHaveCount(0);
|
|
764
|
+
|
|
765
|
+
await cityDropdown.selectOption({ label: 'Los Angeles' });
|
|
766
|
+
});
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
### Form Submission and Response Handling
|
|
770
|
+
|
|
771
|
+
**Use when**: Testing what happens after a form is submitted — success messages, redirects, error responses from the server, and loading states during submission.
|
|
772
|
+
**Avoid when**: You only care about client-side validation. Test submission separately from validation.
|
|
773
|
+
|
|
774
|
+
**TypeScript**
|
|
775
|
+
```typescript
|
|
776
|
+
import { test, expect } from '@playwright/test';
|
|
777
|
+
|
|
778
|
+
test('successful form submission shows confirmation', async ({ page }) => {
|
|
779
|
+
await page.goto('/contact');
|
|
780
|
+
|
|
781
|
+
await page.getByLabel('Name').fill('Jane Doe');
|
|
782
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
783
|
+
await page.getByLabel('Message').fill('Hello from Playwright');
|
|
784
|
+
|
|
785
|
+
// Wait for the API response during submission
|
|
786
|
+
const responsePromise = page.waitForResponse('**/api/contact');
|
|
787
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
788
|
+
const response = await responsePromise;
|
|
789
|
+
|
|
790
|
+
expect(response.status()).toBe(200);
|
|
791
|
+
await expect(page.getByText('Message sent successfully')).toBeVisible();
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test('form submission shows server-side validation errors', async ({ page }) => {
|
|
795
|
+
await page.goto('/register');
|
|
796
|
+
|
|
797
|
+
await page.getByLabel('Email').fill('taken@example.com');
|
|
798
|
+
await page.getByLabel('Password', { exact: true }).fill('ValidP@ss1');
|
|
799
|
+
await page.getByRole('button', { name: 'Register' }).click();
|
|
800
|
+
|
|
801
|
+
// Server responds with a 409 — email already taken
|
|
802
|
+
await expect(page.getByText('An account with this email already exists')).toBeVisible();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test('form shows loading state during submission', async ({ page }) => {
|
|
806
|
+
await page.goto('/contact');
|
|
807
|
+
|
|
808
|
+
await page.getByLabel('Name').fill('Jane');
|
|
809
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
810
|
+
await page.getByLabel('Message').fill('Test');
|
|
811
|
+
|
|
812
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
813
|
+
|
|
814
|
+
// Button should be disabled during submission
|
|
815
|
+
await expect(page.getByRole('button', { name: /Sending/ })).toBeDisabled();
|
|
816
|
+
|
|
817
|
+
// After completion, button returns to normal
|
|
818
|
+
await expect(page.getByRole('button', { name: 'Send message' })).toBeEnabled();
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test('form redirects after successful submission', async ({ page }) => {
|
|
822
|
+
await page.goto('/login');
|
|
823
|
+
|
|
824
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
825
|
+
await page.getByLabel('Password').fill('password123');
|
|
826
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
827
|
+
|
|
828
|
+
// Verify redirect
|
|
829
|
+
await page.waitForURL('/dashboard');
|
|
830
|
+
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
831
|
+
});
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
**JavaScript**
|
|
835
|
+
```javascript
|
|
836
|
+
const { test, expect } = require('@playwright/test');
|
|
837
|
+
|
|
838
|
+
test('successful form submission shows confirmation', async ({ page }) => {
|
|
839
|
+
await page.goto('/contact');
|
|
840
|
+
|
|
841
|
+
await page.getByLabel('Name').fill('Jane Doe');
|
|
842
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
843
|
+
await page.getByLabel('Message').fill('Hello from Playwright');
|
|
844
|
+
|
|
845
|
+
const responsePromise = page.waitForResponse('**/api/contact');
|
|
846
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
847
|
+
const response = await responsePromise;
|
|
848
|
+
|
|
849
|
+
expect(response.status()).toBe(200);
|
|
850
|
+
await expect(page.getByText('Message sent successfully')).toBeVisible();
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
test('form submission shows server-side validation errors', async ({ page }) => {
|
|
854
|
+
await page.goto('/register');
|
|
855
|
+
|
|
856
|
+
await page.getByLabel('Email').fill('taken@example.com');
|
|
857
|
+
await page.getByLabel('Password', { exact: true }).fill('ValidP@ss1');
|
|
858
|
+
await page.getByRole('button', { name: 'Register' }).click();
|
|
859
|
+
|
|
860
|
+
await expect(page.getByText('An account with this email already exists')).toBeVisible();
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test('form shows loading state during submission', async ({ page }) => {
|
|
864
|
+
await page.goto('/contact');
|
|
865
|
+
|
|
866
|
+
await page.getByLabel('Name').fill('Jane');
|
|
867
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
868
|
+
await page.getByLabel('Message').fill('Test');
|
|
869
|
+
|
|
870
|
+
await page.getByRole('button', { name: 'Send message' }).click();
|
|
871
|
+
|
|
872
|
+
await expect(page.getByRole('button', { name: /Sending/ })).toBeDisabled();
|
|
873
|
+
await expect(page.getByRole('button', { name: 'Send message' })).toBeEnabled();
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test('form redirects after successful submission', async ({ page }) => {
|
|
877
|
+
await page.goto('/login');
|
|
878
|
+
|
|
879
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
880
|
+
await page.getByLabel('Password').fill('password123');
|
|
881
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
882
|
+
|
|
883
|
+
await page.waitForURL('/dashboard');
|
|
884
|
+
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
885
|
+
});
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### Form Reset Testing
|
|
889
|
+
|
|
890
|
+
**Use when**: Testing "clear form" or "reset" functionality, verifying that fields return to their default values.
|
|
891
|
+
**Avoid when**: The form has no reset mechanism.
|
|
892
|
+
|
|
893
|
+
**TypeScript**
|
|
894
|
+
```typescript
|
|
895
|
+
import { test, expect } from '@playwright/test';
|
|
896
|
+
|
|
897
|
+
test('reset button clears all fields to defaults', async ({ page }) => {
|
|
898
|
+
await page.goto('/settings');
|
|
899
|
+
|
|
900
|
+
// Change fields from defaults
|
|
901
|
+
await page.getByLabel('Display name').fill('New Name');
|
|
902
|
+
await page.getByLabel('Theme').selectOption('dark');
|
|
903
|
+
await page.getByLabel('Notifications').uncheck();
|
|
904
|
+
|
|
905
|
+
// Click reset
|
|
906
|
+
await page.getByRole('button', { name: 'Reset' }).click();
|
|
907
|
+
|
|
908
|
+
// Verify fields returned to original values
|
|
909
|
+
await expect(page.getByLabel('Display name')).toHaveValue('');
|
|
910
|
+
await expect(page.getByLabel('Theme')).toHaveValue('light');
|
|
911
|
+
await expect(page.getByLabel('Notifications')).toBeChecked();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test('confirmation dialog before resetting a dirty form', async ({ page }) => {
|
|
915
|
+
await page.goto('/editor');
|
|
916
|
+
|
|
917
|
+
await page.getByLabel('Title').fill('Draft post');
|
|
918
|
+
|
|
919
|
+
// Reset triggers a confirmation dialog
|
|
920
|
+
page.on('dialog', (dialog) => dialog.accept());
|
|
921
|
+
await page.getByRole('button', { name: 'Discard changes' }).click();
|
|
922
|
+
|
|
923
|
+
await expect(page.getByLabel('Title')).toHaveValue('');
|
|
924
|
+
});
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
**JavaScript**
|
|
928
|
+
```javascript
|
|
929
|
+
const { test, expect } = require('@playwright/test');
|
|
930
|
+
|
|
931
|
+
test('reset button clears all fields to defaults', async ({ page }) => {
|
|
932
|
+
await page.goto('/settings');
|
|
933
|
+
|
|
934
|
+
await page.getByLabel('Display name').fill('New Name');
|
|
935
|
+
await page.getByLabel('Theme').selectOption('dark');
|
|
936
|
+
await page.getByLabel('Notifications').uncheck();
|
|
937
|
+
|
|
938
|
+
await page.getByRole('button', { name: 'Reset' }).click();
|
|
939
|
+
|
|
940
|
+
await expect(page.getByLabel('Display name')).toHaveValue('');
|
|
941
|
+
await expect(page.getByLabel('Theme')).toHaveValue('light');
|
|
942
|
+
await expect(page.getByLabel('Notifications')).toBeChecked();
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test('confirmation dialog before resetting a dirty form', async ({ page }) => {
|
|
946
|
+
await page.goto('/editor');
|
|
947
|
+
|
|
948
|
+
await page.getByLabel('Title').fill('Draft post');
|
|
949
|
+
|
|
950
|
+
page.on('dialog', (dialog) => dialog.accept());
|
|
951
|
+
await page.getByRole('button', { name: 'Discard changes' }).click();
|
|
952
|
+
|
|
953
|
+
await expect(page.getByLabel('Title')).toHaveValue('');
|
|
954
|
+
});
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
## Decision Guide
|
|
958
|
+
|
|
959
|
+
| Scenario | Approach | Key API |
|
|
960
|
+
|---|---|---|
|
|
961
|
+
| Standard text input | `fill()` (clears, then types) | `page.getByLabel('Name').fill('Jane')` |
|
|
962
|
+
| Need keystroke events (autocomplete) | `pressSequentially()` with delay | `locator.pressSequentially('text', { delay: 100 })` |
|
|
963
|
+
| Native `<select>` dropdown | `selectOption()` by value or label | `locator.selectOption('US')` or `{ label: 'United States' }` |
|
|
964
|
+
| Custom dropdown (ARIA listbox) | Click trigger, then select option role | `getByRole('option', { name: '...' }).click()` |
|
|
965
|
+
| Checkbox | `check()` / `uncheck()` (idempotent) | `locator.check()` — safe to call even if already checked |
|
|
966
|
+
| Radio button | `check()` on the target radio | `page.getByLabel('Express').check()` |
|
|
967
|
+
| Date input (native) | `fill()` with ISO format | `locator.fill('2025-03-15')` |
|
|
968
|
+
| Date picker (third-party) | Click to open, navigate, select day | `getByRole('gridcell', { name: '15' }).click()` |
|
|
969
|
+
| Validation errors | Submit, then assert error text | `expect(page.getByText('Required')).toBeVisible()` |
|
|
970
|
+
| Multi-step wizard | `test.step()` per step, assert heading | `await test.step('Step 1', async () => { ... })` |
|
|
971
|
+
| Conditional/dynamic fields | Change trigger field, assert new field visibility | `expect(locator).toBeVisible()` / `.not.toBeVisible()` |
|
|
972
|
+
| Form submission | `waitForResponse` + click submit | Register response listener before click |
|
|
973
|
+
| Auto-complete | `pressSequentially()`, wait for listbox, select option | `getByRole('option', { name }).click()` |
|
|
974
|
+
| Form reset | Click reset, assert default values | `expect(locator).toHaveValue('')` |
|
|
975
|
+
|
|
976
|
+
## Anti-Patterns
|
|
977
|
+
|
|
978
|
+
| Don't Do This | Problem | Do This Instead |
|
|
979
|
+
|---|---|---|
|
|
980
|
+
| `await page.getByLabel('Name').type('Jane')` | `type()` appends to existing content; does not clear first | `await page.getByLabel('Name').fill('Jane')` |
|
|
981
|
+
| `await page.getByLabel('Agree').click()` | `click()` toggles — if already checked, it unchecks | `await page.getByLabel('Agree').check()` |
|
|
982
|
+
| `await page.fill('#email', 'test@test.com')` | CSS selector is fragile | `await page.getByLabel('Email').fill('test@test.com')` |
|
|
983
|
+
| `await page.selectOption('select', 'US')` without label | Targets first `<select>` on page; ambiguous | `await page.getByLabel('Country').selectOption('US')` |
|
|
984
|
+
| Testing every invalid input in one test | Test becomes huge, slow, and hard to debug | One test per validation rule or group related rules |
|
|
985
|
+
| `expect(await input.inputValue()).toBe('Jane')` | Resolves once — no retry. Race condition. | `await expect(input).toHaveValue('Jane')` |
|
|
986
|
+
| Filling fields with `page.evaluate()` | Bypasses event handlers (no `input`, `change` events fire) | Use `fill()` or `pressSequentially()` |
|
|
987
|
+
| Not waiting for conditional fields before filling | `fill()` fails on hidden/detached elements | `await expect(field).toBeVisible()` first |
|
|
988
|
+
| Hardcoding wait after selecting a dropdown | `waitForTimeout(500)` is flaky and slow | Wait for the dependent element to appear |
|
|
989
|
+
| Skipping server-side validation tests | Client-side validation can be bypassed | Test both client-side UX and server response |
|
|
990
|
+
|
|
991
|
+
## Troubleshooting
|
|
992
|
+
|
|
993
|
+
### `fill()` does nothing or clears but doesn't type
|
|
994
|
+
|
|
995
|
+
**Cause**: The input field uses a contenteditable div (rich text editors), not a real `<input>` or `<textarea>`.
|
|
996
|
+
|
|
997
|
+
```typescript
|
|
998
|
+
// Check if it is contenteditable
|
|
999
|
+
const isContentEditable = await page.getByTestId('editor').evaluate(
|
|
1000
|
+
(el) => el.getAttribute('contenteditable')
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
// For contenteditable, use pressSequentially or type
|
|
1004
|
+
if (isContentEditable) {
|
|
1005
|
+
await page.getByTestId('editor').click();
|
|
1006
|
+
await page.getByTestId('editor').pressSequentially('Hello world');
|
|
1007
|
+
}
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
### Date picker does not accept `fill()` value
|
|
1011
|
+
|
|
1012
|
+
**Cause**: Third-party date pickers often render custom UI over a hidden input. `fill()` sets the hidden input but the UI does not update.
|
|
1013
|
+
|
|
1014
|
+
```typescript
|
|
1015
|
+
// Interact with the date picker UI instead
|
|
1016
|
+
await page.getByLabel('Date').click(); // Opens the picker
|
|
1017
|
+
await page.getByRole('button', { name: 'Next month' }).click();
|
|
1018
|
+
await page.getByRole('gridcell', { name: '15' }).click();
|
|
1019
|
+
|
|
1020
|
+
// Alternatively, if the library reads from the input on change:
|
|
1021
|
+
await page.getByLabel('Date').fill('2025-06-15');
|
|
1022
|
+
await page.getByLabel('Date').dispatchEvent('change');
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
### `selectOption()` throws "not a <select> element"
|
|
1026
|
+
|
|
1027
|
+
**Cause**: The dropdown is a custom component (ARIA listbox), not a native `<select>`.
|
|
1028
|
+
|
|
1029
|
+
```typescript
|
|
1030
|
+
// For custom dropdowns: click to open, then select from the listbox
|
|
1031
|
+
await page.getByRole('combobox', { name: 'Country' }).click();
|
|
1032
|
+
await page.getByRole('option', { name: 'United States' }).click();
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
### Validation errors do not appear after `fill()` and submit
|
|
1036
|
+
|
|
1037
|
+
**Cause**: The validation triggers on `blur` (focus leaving the field), but `fill()` does not trigger blur automatically.
|
|
1038
|
+
|
|
1039
|
+
```typescript
|
|
1040
|
+
// Trigger blur explicitly
|
|
1041
|
+
await page.getByLabel('Email').fill('invalid');
|
|
1042
|
+
await page.getByLabel('Email').blur();
|
|
1043
|
+
await expect(page.getByText('Please enter a valid email')).toBeVisible();
|
|
1044
|
+
|
|
1045
|
+
// Or move focus to the next field
|
|
1046
|
+
await page.getByLabel('Password').focus();
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
## Related
|
|
1050
|
+
|
|
1051
|
+
- [core/locators.md](locators.md) -- locator strategies for finding form elements
|
|
1052
|
+
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- assertion patterns for verifying form state
|
|
1053
|
+
- [core/file-operations.md](file-operations.md) -- file upload fields in forms
|
|
1054
|
+
- [core/error-and-edge-cases.md](error-and-edge-cases.md) -- testing form error states and edge cases
|
|
1055
|
+
- [core/accessibility.md](accessibility.md) -- ensuring forms are accessible (label associations, ARIA attributes)
|
|
1056
|
+
- [core/network-mocking.md](network-mocking.md) -- mocking form submission API responses
|