@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,945 @@
|
|
|
1
|
+
# CRUD Testing Recipes
|
|
2
|
+
|
|
3
|
+
> **When to use**: You need to test create, read, update, or delete operations on any resource -- forms, tables, lists, cards, inline edits, or bulk actions.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Recipe 1: Creating a Resource (Fill Form, Submit, Verify)
|
|
8
|
+
|
|
9
|
+
### Complete Example
|
|
10
|
+
|
|
11
|
+
**TypeScript**
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { test, expect } from '@playwright/test';
|
|
15
|
+
|
|
16
|
+
test('creates a new product via form', async ({ page }) => {
|
|
17
|
+
await page.goto('/products');
|
|
18
|
+
|
|
19
|
+
// Click the create button
|
|
20
|
+
await page.getByRole('button', { name: 'Add product' }).click();
|
|
21
|
+
|
|
22
|
+
// Fill out the creation form
|
|
23
|
+
await page.getByLabel('Product name').fill('Wireless Keyboard');
|
|
24
|
+
await page.getByLabel('SKU').fill('KB-WIRELESS-001');
|
|
25
|
+
await page.getByLabel('Price').fill('79.99');
|
|
26
|
+
await page.getByLabel('Description').fill('Ergonomic wireless keyboard with backlit keys');
|
|
27
|
+
await page.getByLabel('Category').selectOption('Electronics');
|
|
28
|
+
await page.getByLabel('In stock').check();
|
|
29
|
+
|
|
30
|
+
// Submit
|
|
31
|
+
await page.getByRole('button', { name: 'Save product' }).click();
|
|
32
|
+
|
|
33
|
+
// Verify success notification
|
|
34
|
+
await expect(page.getByRole('alert')).toContainText('Product created successfully');
|
|
35
|
+
|
|
36
|
+
// Verify the new item appears in the list
|
|
37
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
|
|
38
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toContainText('$79.99');
|
|
39
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toContainText('KB-WIRELESS-001');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('shows validation errors for invalid form data', async ({ page }) => {
|
|
43
|
+
await page.goto('/products');
|
|
44
|
+
await page.getByRole('button', { name: 'Add product' }).click();
|
|
45
|
+
|
|
46
|
+
// Submit empty form
|
|
47
|
+
await page.getByRole('button', { name: 'Save product' }).click();
|
|
48
|
+
|
|
49
|
+
// Verify field-level validation messages
|
|
50
|
+
await expect(page.getByText('Product name is required')).toBeVisible();
|
|
51
|
+
await expect(page.getByText('Price is required')).toBeVisible();
|
|
52
|
+
|
|
53
|
+
// Fill in invalid data
|
|
54
|
+
await page.getByLabel('Product name').fill('A'); // too short
|
|
55
|
+
await page.getByLabel('Price').fill('-5');
|
|
56
|
+
|
|
57
|
+
await page.getByRole('button', { name: 'Save product' }).click();
|
|
58
|
+
|
|
59
|
+
await expect(page.getByText('Name must be at least 3 characters')).toBeVisible();
|
|
60
|
+
await expect(page.getByText('Price must be a positive number')).toBeVisible();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('prevents duplicate resource creation', async ({ page }) => {
|
|
64
|
+
await page.goto('/products');
|
|
65
|
+
await page.getByRole('button', { name: 'Add product' }).click();
|
|
66
|
+
|
|
67
|
+
// Try to create a product with a SKU that already exists
|
|
68
|
+
await page.getByLabel('Product name').fill('Duplicate Product');
|
|
69
|
+
await page.getByLabel('SKU').fill('EXISTING-SKU-001');
|
|
70
|
+
await page.getByLabel('Price').fill('29.99');
|
|
71
|
+
await page.getByRole('button', { name: 'Save product' }).click();
|
|
72
|
+
|
|
73
|
+
await expect(page.getByRole('alert')).toContainText(/already exists|duplicate/i);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**JavaScript**
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
const { test, expect } = require('@playwright/test');
|
|
81
|
+
|
|
82
|
+
test('creates a new product via form', async ({ page }) => {
|
|
83
|
+
await page.goto('/products');
|
|
84
|
+
|
|
85
|
+
await page.getByRole('button', { name: 'Add product' }).click();
|
|
86
|
+
|
|
87
|
+
await page.getByLabel('Product name').fill('Wireless Keyboard');
|
|
88
|
+
await page.getByLabel('SKU').fill('KB-WIRELESS-001');
|
|
89
|
+
await page.getByLabel('Price').fill('79.99');
|
|
90
|
+
await page.getByLabel('Description').fill('Ergonomic wireless keyboard with backlit keys');
|
|
91
|
+
await page.getByLabel('Category').selectOption('Electronics');
|
|
92
|
+
await page.getByLabel('In stock').check();
|
|
93
|
+
|
|
94
|
+
await page.getByRole('button', { name: 'Save product' }).click();
|
|
95
|
+
|
|
96
|
+
await expect(page.getByRole('alert')).toContainText('Product created successfully');
|
|
97
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
|
|
98
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toContainText('$79.99');
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Recipe 2: Reading / Listing Resources (Table, Cards, Pagination)
|
|
105
|
+
|
|
106
|
+
### Complete Example
|
|
107
|
+
|
|
108
|
+
**TypeScript**
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import { test, expect } from '@playwright/test';
|
|
112
|
+
|
|
113
|
+
test.describe('reading resources in a table', () => {
|
|
114
|
+
test('displays resources in a table with correct columns', async ({ page }) => {
|
|
115
|
+
await page.goto('/products');
|
|
116
|
+
|
|
117
|
+
// Verify table headers
|
|
118
|
+
const headers = page.getByRole('columnheader');
|
|
119
|
+
await expect(headers).toContainText(['Name', 'SKU', 'Price', 'Category', 'Status']);
|
|
120
|
+
|
|
121
|
+
// Verify at least one row of data exists
|
|
122
|
+
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
123
|
+
await expect(rows.first()).toBeVisible();
|
|
124
|
+
|
|
125
|
+
// Verify specific data in a row
|
|
126
|
+
const firstRow = rows.first();
|
|
127
|
+
await expect(firstRow.getByRole('cell').nth(0)).not.toBeEmpty();
|
|
128
|
+
await expect(firstRow.getByRole('cell').nth(2)).toContainText('$');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('sorts table by column', async ({ page }) => {
|
|
132
|
+
await page.goto('/products');
|
|
133
|
+
|
|
134
|
+
// Click the Price header to sort
|
|
135
|
+
await page.getByRole('columnheader', { name: 'Price' }).click();
|
|
136
|
+
|
|
137
|
+
// Get all prices from the table
|
|
138
|
+
const priceCells = page.getByRole('row')
|
|
139
|
+
.filter({ hasNot: page.getByRole('columnheader') })
|
|
140
|
+
.getByRole('cell')
|
|
141
|
+
.nth(2);
|
|
142
|
+
|
|
143
|
+
const prices = await priceCells.allTextContents();
|
|
144
|
+
const numericPrices = prices.map((p) => parseFloat(p.replace(/[$,]/g, '')));
|
|
145
|
+
|
|
146
|
+
// Verify sorted ascending
|
|
147
|
+
for (let i = 1; i < numericPrices.length; i++) {
|
|
148
|
+
expect(numericPrices[i]).toBeGreaterThanOrEqual(numericPrices[i - 1]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Click again for descending
|
|
152
|
+
await page.getByRole('columnheader', { name: 'Price' }).click();
|
|
153
|
+
|
|
154
|
+
const pricesDesc = await priceCells.allTextContents();
|
|
155
|
+
const numericDesc = pricesDesc.map((p) => parseFloat(p.replace(/[$,]/g, '')));
|
|
156
|
+
|
|
157
|
+
for (let i = 1; i < numericDesc.length; i++) {
|
|
158
|
+
expect(numericDesc[i]).toBeLessThanOrEqual(numericDesc[i - 1]);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('paginates through results', async ({ page }) => {
|
|
163
|
+
await page.goto('/products');
|
|
164
|
+
|
|
165
|
+
// Verify pagination controls are visible
|
|
166
|
+
const pagination = page.getByRole('navigation', { name: /pagination/i });
|
|
167
|
+
await expect(pagination).toBeVisible();
|
|
168
|
+
|
|
169
|
+
// Get first page content
|
|
170
|
+
const firstPageFirstRow = await page
|
|
171
|
+
.getByRole('row')
|
|
172
|
+
.filter({ hasNot: page.getByRole('columnheader') })
|
|
173
|
+
.first()
|
|
174
|
+
.textContent();
|
|
175
|
+
|
|
176
|
+
// Go to page 2
|
|
177
|
+
await pagination.getByRole('button', { name: '2' }).click();
|
|
178
|
+
|
|
179
|
+
// Wait for data to load
|
|
180
|
+
await expect(page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).first())
|
|
181
|
+
.not.toHaveText(firstPageFirstRow!);
|
|
182
|
+
|
|
183
|
+
// Verify page 2 is active
|
|
184
|
+
await expect(pagination.getByRole('button', { name: '2' })).toHaveAttribute(
|
|
185
|
+
'aria-current',
|
|
186
|
+
'page'
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Verify URL updated with page parameter
|
|
190
|
+
await expect(page).toHaveURL(/[?&]page=2/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('shows correct item count', async ({ page }) => {
|
|
194
|
+
await page.goto('/products');
|
|
195
|
+
|
|
196
|
+
// Verify the total count display
|
|
197
|
+
await expect(page.getByText(/showing \d+.+of \d+/i)).toBeVisible();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test.describe('reading resources as cards', () => {
|
|
202
|
+
test('displays resources in a card grid', async ({ page }) => {
|
|
203
|
+
await page.goto('/products?view=grid');
|
|
204
|
+
|
|
205
|
+
// Verify cards are displayed
|
|
206
|
+
const cards = page.getByRole('article');
|
|
207
|
+
await expect(cards.first()).toBeVisible();
|
|
208
|
+
|
|
209
|
+
// Verify card content
|
|
210
|
+
const firstCard = cards.first();
|
|
211
|
+
await expect(firstCard.getByRole('heading')).toBeVisible();
|
|
212
|
+
await expect(firstCard.getByText('$')).toBeVisible();
|
|
213
|
+
await expect(firstCard.getByRole('img')).toBeVisible();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('switches between list and grid view', async ({ page }) => {
|
|
217
|
+
await page.goto('/products');
|
|
218
|
+
|
|
219
|
+
// Start in table/list view
|
|
220
|
+
await expect(page.getByRole('table')).toBeVisible();
|
|
221
|
+
|
|
222
|
+
// Switch to grid
|
|
223
|
+
await page.getByRole('button', { name: /grid view/i }).click();
|
|
224
|
+
await expect(page.getByRole('article').first()).toBeVisible();
|
|
225
|
+
await expect(page.getByRole('table')).not.toBeVisible();
|
|
226
|
+
|
|
227
|
+
// Switch back to list
|
|
228
|
+
await page.getByRole('button', { name: /list view/i }).click();
|
|
229
|
+
await expect(page.getByRole('table')).toBeVisible();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**JavaScript**
|
|
235
|
+
|
|
236
|
+
```javascript
|
|
237
|
+
const { test, expect } = require('@playwright/test');
|
|
238
|
+
|
|
239
|
+
test('displays resources in a table with correct columns', async ({ page }) => {
|
|
240
|
+
await page.goto('/products');
|
|
241
|
+
|
|
242
|
+
const headers = page.getByRole('columnheader');
|
|
243
|
+
await expect(headers).toContainText(['Name', 'SKU', 'Price', 'Category', 'Status']);
|
|
244
|
+
|
|
245
|
+
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
246
|
+
await expect(rows.first()).toBeVisible();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('paginates through results', async ({ page }) => {
|
|
250
|
+
await page.goto('/products');
|
|
251
|
+
|
|
252
|
+
const pagination = page.getByRole('navigation', { name: /pagination/i });
|
|
253
|
+
await expect(pagination).toBeVisible();
|
|
254
|
+
|
|
255
|
+
const firstPageFirstRow = await page
|
|
256
|
+
.getByRole('row')
|
|
257
|
+
.filter({ hasNot: page.getByRole('columnheader') })
|
|
258
|
+
.first()
|
|
259
|
+
.textContent();
|
|
260
|
+
|
|
261
|
+
await pagination.getByRole('button', { name: '2' }).click();
|
|
262
|
+
|
|
263
|
+
await expect(page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).first())
|
|
264
|
+
.not.toHaveText(firstPageFirstRow);
|
|
265
|
+
|
|
266
|
+
await expect(page).toHaveURL(/[?&]page=2/);
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Recipe 3: Updating a Resource (Edit Form, Save, Verify Changes)
|
|
273
|
+
|
|
274
|
+
### Complete Example
|
|
275
|
+
|
|
276
|
+
**TypeScript**
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import { test, expect } from '@playwright/test';
|
|
280
|
+
|
|
281
|
+
test('updates an existing product', async ({ page }) => {
|
|
282
|
+
await page.goto('/products');
|
|
283
|
+
|
|
284
|
+
// Find the target row and click edit
|
|
285
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
286
|
+
await row.getByRole('button', { name: 'Edit' }).click();
|
|
287
|
+
|
|
288
|
+
// Verify the edit form is pre-filled with existing data
|
|
289
|
+
await expect(page.getByLabel('Product name')).toHaveValue('Wireless Keyboard');
|
|
290
|
+
await expect(page.getByLabel('Price')).toHaveValue('79.99');
|
|
291
|
+
|
|
292
|
+
// Update the fields
|
|
293
|
+
await page.getByLabel('Product name').clear();
|
|
294
|
+
await page.getByLabel('Product name').fill('Wireless Keyboard Pro');
|
|
295
|
+
await page.getByLabel('Price').clear();
|
|
296
|
+
await page.getByLabel('Price').fill('99.99');
|
|
297
|
+
|
|
298
|
+
// Save changes
|
|
299
|
+
await page.getByRole('button', { name: 'Save changes' }).click();
|
|
300
|
+
|
|
301
|
+
// Verify success notification
|
|
302
|
+
await expect(page.getByRole('alert')).toContainText('Product updated successfully');
|
|
303
|
+
|
|
304
|
+
// Verify the list reflects the changes
|
|
305
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard Pro/ })).toBeVisible();
|
|
306
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard Pro/ })).toContainText('$99.99');
|
|
307
|
+
|
|
308
|
+
// Verify old name is gone
|
|
309
|
+
await expect(page.getByRole('row', { name: /^Wireless Keyboard$/ })).not.toBeVisible();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('edit form preserves data when navigating away and back', async ({ page }) => {
|
|
313
|
+
await page.goto('/products');
|
|
314
|
+
|
|
315
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
316
|
+
await row.getByRole('button', { name: 'Edit' }).click();
|
|
317
|
+
|
|
318
|
+
// Make changes but do not save
|
|
319
|
+
await page.getByLabel('Product name').clear();
|
|
320
|
+
await page.getByLabel('Product name').fill('Modified Name');
|
|
321
|
+
|
|
322
|
+
// Try navigating away
|
|
323
|
+
await page.getByRole('link', { name: 'Dashboard' }).click();
|
|
324
|
+
|
|
325
|
+
// Expect unsaved changes warning
|
|
326
|
+
page.on('dialog', async (dialog) => {
|
|
327
|
+
expect(dialog.message()).toContain('unsaved changes');
|
|
328
|
+
await dialog.dismiss(); // Stay on page
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Verify data is still there after dismissing navigation
|
|
332
|
+
await expect(page.getByLabel('Product name')).toHaveValue('Modified Name');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('cancelling edit discards changes', async ({ page }) => {
|
|
336
|
+
await page.goto('/products');
|
|
337
|
+
|
|
338
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
339
|
+
await row.getByRole('button', { name: 'Edit' }).click();
|
|
340
|
+
|
|
341
|
+
// Make changes
|
|
342
|
+
await page.getByLabel('Product name').clear();
|
|
343
|
+
await page.getByLabel('Product name').fill('Should Not Save');
|
|
344
|
+
|
|
345
|
+
// Cancel
|
|
346
|
+
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
347
|
+
|
|
348
|
+
// Verify original data is preserved
|
|
349
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
|
|
350
|
+
await expect(page.getByRole('row', { name: /Should Not Save/ })).not.toBeVisible();
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**JavaScript**
|
|
355
|
+
|
|
356
|
+
```javascript
|
|
357
|
+
const { test, expect } = require('@playwright/test');
|
|
358
|
+
|
|
359
|
+
test('updates an existing product', async ({ page }) => {
|
|
360
|
+
await page.goto('/products');
|
|
361
|
+
|
|
362
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
363
|
+
await row.getByRole('button', { name: 'Edit' }).click();
|
|
364
|
+
|
|
365
|
+
await expect(page.getByLabel('Product name')).toHaveValue('Wireless Keyboard');
|
|
366
|
+
await expect(page.getByLabel('Price')).toHaveValue('79.99');
|
|
367
|
+
|
|
368
|
+
await page.getByLabel('Product name').clear();
|
|
369
|
+
await page.getByLabel('Product name').fill('Wireless Keyboard Pro');
|
|
370
|
+
await page.getByLabel('Price').clear();
|
|
371
|
+
await page.getByLabel('Price').fill('99.99');
|
|
372
|
+
|
|
373
|
+
await page.getByRole('button', { name: 'Save changes' }).click();
|
|
374
|
+
|
|
375
|
+
await expect(page.getByRole('alert')).toContainText('Product updated successfully');
|
|
376
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard Pro/ })).toBeVisible();
|
|
377
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard Pro/ })).toContainText('$99.99');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('cancelling edit discards changes', async ({ page }) => {
|
|
381
|
+
await page.goto('/products');
|
|
382
|
+
|
|
383
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
384
|
+
await row.getByRole('button', { name: 'Edit' }).click();
|
|
385
|
+
|
|
386
|
+
await page.getByLabel('Product name').clear();
|
|
387
|
+
await page.getByLabel('Product name').fill('Should Not Save');
|
|
388
|
+
|
|
389
|
+
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
390
|
+
|
|
391
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
|
|
392
|
+
await expect(page.getByRole('row', { name: /Should Not Save/ })).not.toBeVisible();
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Recipe 4: Deleting a Resource (Delete, Confirm Dialog, Verify Removal)
|
|
399
|
+
|
|
400
|
+
### Complete Example
|
|
401
|
+
|
|
402
|
+
**TypeScript**
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
import { test, expect } from '@playwright/test';
|
|
406
|
+
|
|
407
|
+
test('deletes a product with confirmation dialog', async ({ page }) => {
|
|
408
|
+
await page.goto('/products');
|
|
409
|
+
|
|
410
|
+
// Count items before deletion
|
|
411
|
+
const rowsBefore = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
412
|
+
const countBefore = await rowsBefore.count();
|
|
413
|
+
|
|
414
|
+
// Click delete on a specific product
|
|
415
|
+
const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
416
|
+
await expect(targetRow).toBeVisible();
|
|
417
|
+
await targetRow.getByRole('button', { name: 'Delete' }).click();
|
|
418
|
+
|
|
419
|
+
// Confirmation dialog should appear
|
|
420
|
+
const dialog = page.getByRole('dialog');
|
|
421
|
+
await expect(dialog).toBeVisible();
|
|
422
|
+
await expect(dialog).toContainText('Are you sure you want to delete');
|
|
423
|
+
await expect(dialog).toContainText('Wireless Keyboard');
|
|
424
|
+
await expect(dialog.getByText(/cannot be undone/i)).toBeVisible();
|
|
425
|
+
|
|
426
|
+
// Confirm the deletion
|
|
427
|
+
await dialog.getByRole('button', { name: 'Delete' }).click();
|
|
428
|
+
|
|
429
|
+
// Verify the dialog closes
|
|
430
|
+
await expect(dialog).not.toBeVisible();
|
|
431
|
+
|
|
432
|
+
// Verify success notification
|
|
433
|
+
await expect(page.getByRole('alert')).toContainText('Product deleted');
|
|
434
|
+
|
|
435
|
+
// Verify the item is removed from the list
|
|
436
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).not.toBeVisible();
|
|
437
|
+
|
|
438
|
+
// Verify count decreased
|
|
439
|
+
const rowsAfter = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
440
|
+
await expect(rowsAfter).toHaveCount(countBefore - 1);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('cancel delete preserves the resource', async ({ page }) => {
|
|
444
|
+
await page.goto('/products');
|
|
445
|
+
|
|
446
|
+
const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
447
|
+
await targetRow.getByRole('button', { name: 'Delete' }).click();
|
|
448
|
+
|
|
449
|
+
const dialog = page.getByRole('dialog');
|
|
450
|
+
await expect(dialog).toBeVisible();
|
|
451
|
+
|
|
452
|
+
// Cancel the deletion
|
|
453
|
+
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
|
454
|
+
|
|
455
|
+
// Dialog closes, item is still present
|
|
456
|
+
await expect(dialog).not.toBeVisible();
|
|
457
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('handles delete error gracefully', async ({ page }) => {
|
|
461
|
+
// Mock the delete endpoint to fail
|
|
462
|
+
await page.route('**/api/products/*', async (route) => {
|
|
463
|
+
if (route.request().method() === 'DELETE') {
|
|
464
|
+
await route.fulfill({
|
|
465
|
+
status: 409,
|
|
466
|
+
contentType: 'application/json',
|
|
467
|
+
body: JSON.stringify({
|
|
468
|
+
error: 'Cannot delete product with active orders',
|
|
469
|
+
}),
|
|
470
|
+
});
|
|
471
|
+
} else {
|
|
472
|
+
await route.continue();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
await page.goto('/products');
|
|
477
|
+
|
|
478
|
+
const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
479
|
+
await targetRow.getByRole('button', { name: 'Delete' }).click();
|
|
480
|
+
|
|
481
|
+
const dialog = page.getByRole('dialog');
|
|
482
|
+
await dialog.getByRole('button', { name: 'Delete' }).click();
|
|
483
|
+
|
|
484
|
+
// Verify error is shown to user
|
|
485
|
+
await expect(page.getByRole('alert')).toContainText('Cannot delete product with active orders');
|
|
486
|
+
|
|
487
|
+
// Item should still be in the list
|
|
488
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**JavaScript**
|
|
493
|
+
|
|
494
|
+
```javascript
|
|
495
|
+
const { test, expect } = require('@playwright/test');
|
|
496
|
+
|
|
497
|
+
test('deletes a product with confirmation dialog', async ({ page }) => {
|
|
498
|
+
await page.goto('/products');
|
|
499
|
+
|
|
500
|
+
const rowsBefore = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
501
|
+
const countBefore = await rowsBefore.count();
|
|
502
|
+
|
|
503
|
+
const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
504
|
+
await targetRow.getByRole('button', { name: 'Delete' }).click();
|
|
505
|
+
|
|
506
|
+
const dialog = page.getByRole('dialog');
|
|
507
|
+
await expect(dialog).toBeVisible();
|
|
508
|
+
await expect(dialog).toContainText('Wireless Keyboard');
|
|
509
|
+
|
|
510
|
+
await dialog.getByRole('button', { name: 'Delete' }).click();
|
|
511
|
+
|
|
512
|
+
await expect(dialog).not.toBeVisible();
|
|
513
|
+
await expect(page.getByRole('alert')).toContainText('Product deleted');
|
|
514
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).not.toBeVisible();
|
|
515
|
+
|
|
516
|
+
const rowsAfter = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
517
|
+
await expect(rowsAfter).toHaveCount(countBefore - 1);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('cancel delete preserves the resource', async ({ page }) => {
|
|
521
|
+
await page.goto('/products');
|
|
522
|
+
|
|
523
|
+
const targetRow = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
524
|
+
await targetRow.getByRole('button', { name: 'Delete' }).click();
|
|
525
|
+
|
|
526
|
+
const dialog = page.getByRole('dialog');
|
|
527
|
+
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
|
528
|
+
|
|
529
|
+
await expect(dialog).not.toBeVisible();
|
|
530
|
+
await expect(page.getByRole('row', { name: /Wireless Keyboard/ })).toBeVisible();
|
|
531
|
+
});
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## Recipe 5: Inline Editing
|
|
537
|
+
|
|
538
|
+
### Complete Example
|
|
539
|
+
|
|
540
|
+
**TypeScript**
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
import { test, expect } from '@playwright/test';
|
|
544
|
+
|
|
545
|
+
test('edits a field inline by double-clicking', async ({ page }) => {
|
|
546
|
+
await page.goto('/products');
|
|
547
|
+
|
|
548
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
549
|
+
const nameCell = row.getByRole('cell').first();
|
|
550
|
+
|
|
551
|
+
// Double-click to enter edit mode
|
|
552
|
+
await nameCell.dblclick();
|
|
553
|
+
|
|
554
|
+
// The cell should now contain an input
|
|
555
|
+
const inlineInput = nameCell.getByRole('textbox');
|
|
556
|
+
await expect(inlineInput).toBeVisible();
|
|
557
|
+
await expect(inlineInput).toHaveValue('Wireless Keyboard');
|
|
558
|
+
|
|
559
|
+
// Edit the value
|
|
560
|
+
await inlineInput.clear();
|
|
561
|
+
await inlineInput.fill('Wireless Keyboard v2');
|
|
562
|
+
|
|
563
|
+
// Press Enter to save
|
|
564
|
+
await inlineInput.press('Enter');
|
|
565
|
+
|
|
566
|
+
// Verify the cell shows the updated value (no longer an input)
|
|
567
|
+
await expect(nameCell.getByRole('textbox')).not.toBeVisible();
|
|
568
|
+
await expect(nameCell).toHaveText('Wireless Keyboard v2');
|
|
569
|
+
|
|
570
|
+
// Verify a success indicator appears
|
|
571
|
+
await expect(page.getByRole('alert')).toContainText(/saved|updated/i);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test('cancels inline edit with Escape key', async ({ page }) => {
|
|
575
|
+
await page.goto('/products');
|
|
576
|
+
|
|
577
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
578
|
+
const nameCell = row.getByRole('cell').first();
|
|
579
|
+
|
|
580
|
+
await nameCell.dblclick();
|
|
581
|
+
|
|
582
|
+
const inlineInput = nameCell.getByRole('textbox');
|
|
583
|
+
await inlineInput.clear();
|
|
584
|
+
await inlineInput.fill('Temporary Value');
|
|
585
|
+
|
|
586
|
+
// Press Escape to cancel
|
|
587
|
+
await inlineInput.press('Escape');
|
|
588
|
+
|
|
589
|
+
// Verify original value is restored
|
|
590
|
+
await expect(nameCell).toHaveText('Wireless Keyboard');
|
|
591
|
+
await expect(nameCell.getByRole('textbox')).not.toBeVisible();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('inline edit with click-away saves changes', async ({ page }) => {
|
|
595
|
+
await page.goto('/products');
|
|
596
|
+
|
|
597
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
598
|
+
const nameCell = row.getByRole('cell').first();
|
|
599
|
+
|
|
600
|
+
await nameCell.dblclick();
|
|
601
|
+
|
|
602
|
+
const inlineInput = nameCell.getByRole('textbox');
|
|
603
|
+
await inlineInput.clear();
|
|
604
|
+
await inlineInput.fill('Keyboard Updated');
|
|
605
|
+
|
|
606
|
+
// Click somewhere else on the page to trigger save
|
|
607
|
+
await page.getByRole('heading').first().click();
|
|
608
|
+
|
|
609
|
+
await expect(nameCell).toHaveText('Keyboard Updated');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('inline edit shows validation error', async ({ page }) => {
|
|
613
|
+
await page.goto('/products');
|
|
614
|
+
|
|
615
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
616
|
+
const priceCell = row.getByRole('cell').nth(2);
|
|
617
|
+
|
|
618
|
+
await priceCell.dblclick();
|
|
619
|
+
|
|
620
|
+
const inlineInput = priceCell.getByRole('textbox');
|
|
621
|
+
await inlineInput.clear();
|
|
622
|
+
await inlineInput.fill('-10');
|
|
623
|
+
await inlineInput.press('Enter');
|
|
624
|
+
|
|
625
|
+
// Validation error should appear inline
|
|
626
|
+
await expect(priceCell.getByText(/positive number|invalid/i)).toBeVisible();
|
|
627
|
+
|
|
628
|
+
// Input should remain visible for correction
|
|
629
|
+
await expect(inlineInput).toBeVisible();
|
|
630
|
+
});
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
**JavaScript**
|
|
634
|
+
|
|
635
|
+
```javascript
|
|
636
|
+
const { test, expect } = require('@playwright/test');
|
|
637
|
+
|
|
638
|
+
test('edits a field inline by double-clicking', async ({ page }) => {
|
|
639
|
+
await page.goto('/products');
|
|
640
|
+
|
|
641
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
642
|
+
const nameCell = row.getByRole('cell').first();
|
|
643
|
+
|
|
644
|
+
await nameCell.dblclick();
|
|
645
|
+
|
|
646
|
+
const inlineInput = nameCell.getByRole('textbox');
|
|
647
|
+
await expect(inlineInput).toBeVisible();
|
|
648
|
+
await expect(inlineInput).toHaveValue('Wireless Keyboard');
|
|
649
|
+
|
|
650
|
+
await inlineInput.clear();
|
|
651
|
+
await inlineInput.fill('Wireless Keyboard v2');
|
|
652
|
+
await inlineInput.press('Enter');
|
|
653
|
+
|
|
654
|
+
await expect(nameCell.getByRole('textbox')).not.toBeVisible();
|
|
655
|
+
await expect(nameCell).toHaveText('Wireless Keyboard v2');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('cancels inline edit with Escape key', async ({ page }) => {
|
|
659
|
+
await page.goto('/products');
|
|
660
|
+
|
|
661
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
662
|
+
const nameCell = row.getByRole('cell').first();
|
|
663
|
+
|
|
664
|
+
await nameCell.dblclick();
|
|
665
|
+
const inlineInput = nameCell.getByRole('textbox');
|
|
666
|
+
await inlineInput.clear();
|
|
667
|
+
await inlineInput.fill('Temporary Value');
|
|
668
|
+
await inlineInput.press('Escape');
|
|
669
|
+
|
|
670
|
+
await expect(nameCell).toHaveText('Wireless Keyboard');
|
|
671
|
+
});
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
676
|
+
## Recipe 6: Bulk Operations
|
|
677
|
+
|
|
678
|
+
### Complete Example
|
|
679
|
+
|
|
680
|
+
**TypeScript**
|
|
681
|
+
|
|
682
|
+
```typescript
|
|
683
|
+
import { test, expect } from '@playwright/test';
|
|
684
|
+
|
|
685
|
+
test('selects multiple items and performs bulk delete', async ({ page }) => {
|
|
686
|
+
await page.goto('/products');
|
|
687
|
+
|
|
688
|
+
// Select multiple items via checkboxes
|
|
689
|
+
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
690
|
+
|
|
691
|
+
await rows.nth(0).getByRole('checkbox').check();
|
|
692
|
+
await rows.nth(1).getByRole('checkbox').check();
|
|
693
|
+
await rows.nth(2).getByRole('checkbox').check();
|
|
694
|
+
|
|
695
|
+
// Verify selection count is shown
|
|
696
|
+
await expect(page.getByText('3 items selected')).toBeVisible();
|
|
697
|
+
|
|
698
|
+
// Verify bulk action toolbar appears
|
|
699
|
+
const bulkToolbar = page.getByRole('toolbar', { name: /bulk actions/i });
|
|
700
|
+
await expect(bulkToolbar).toBeVisible();
|
|
701
|
+
|
|
702
|
+
// Click bulk delete
|
|
703
|
+
await bulkToolbar.getByRole('button', { name: 'Delete selected' }).click();
|
|
704
|
+
|
|
705
|
+
// Confirm in dialog
|
|
706
|
+
const dialog = page.getByRole('dialog');
|
|
707
|
+
await expect(dialog).toContainText('Delete 3 products');
|
|
708
|
+
await dialog.getByRole('button', { name: 'Delete' }).click();
|
|
709
|
+
|
|
710
|
+
// Verify items removed
|
|
711
|
+
await expect(page.getByRole('alert')).toContainText('3 products deleted');
|
|
712
|
+
|
|
713
|
+
// Verify selection count is cleared
|
|
714
|
+
await expect(page.getByText('items selected')).not.toBeVisible();
|
|
715
|
+
await expect(bulkToolbar).not.toBeVisible();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test('select all and deselect all', async ({ page }) => {
|
|
719
|
+
await page.goto('/products');
|
|
720
|
+
|
|
721
|
+
// Click "Select all" checkbox in the header
|
|
722
|
+
const selectAllCheckbox = page
|
|
723
|
+
.getByRole('row')
|
|
724
|
+
.filter({ has: page.getByRole('columnheader') })
|
|
725
|
+
.getByRole('checkbox');
|
|
726
|
+
|
|
727
|
+
await selectAllCheckbox.check();
|
|
728
|
+
|
|
729
|
+
// All row checkboxes should be checked
|
|
730
|
+
const rowCheckboxes = page
|
|
731
|
+
.getByRole('row')
|
|
732
|
+
.filter({ hasNot: page.getByRole('columnheader') })
|
|
733
|
+
.getByRole('checkbox');
|
|
734
|
+
|
|
735
|
+
const count = await rowCheckboxes.count();
|
|
736
|
+
for (let i = 0; i < count; i++) {
|
|
737
|
+
await expect(rowCheckboxes.nth(i)).toBeChecked();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
await expect(page.getByText(`${count} items selected`)).toBeVisible();
|
|
741
|
+
|
|
742
|
+
// Deselect all
|
|
743
|
+
await selectAllCheckbox.uncheck();
|
|
744
|
+
|
|
745
|
+
for (let i = 0; i < count; i++) {
|
|
746
|
+
await expect(rowCheckboxes.nth(i)).not.toBeChecked();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
await expect(page.getByText('items selected')).not.toBeVisible();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test('bulk status change', async ({ page }) => {
|
|
753
|
+
await page.goto('/products');
|
|
754
|
+
|
|
755
|
+
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
756
|
+
await rows.nth(0).getByRole('checkbox').check();
|
|
757
|
+
await rows.nth(1).getByRole('checkbox').check();
|
|
758
|
+
|
|
759
|
+
const bulkToolbar = page.getByRole('toolbar', { name: /bulk actions/i });
|
|
760
|
+
await bulkToolbar.getByRole('button', { name: 'Change status' }).click();
|
|
761
|
+
|
|
762
|
+
// Select new status from dropdown
|
|
763
|
+
await page.getByRole('menuitem', { name: 'Archived' }).click();
|
|
764
|
+
|
|
765
|
+
await expect(page.getByRole('alert')).toContainText('2 products archived');
|
|
766
|
+
|
|
767
|
+
// Verify status changed in the rows
|
|
768
|
+
await expect(rows.nth(0)).toContainText('Archived');
|
|
769
|
+
await expect(rows.nth(1)).toContainText('Archived');
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test('bulk export selected items', async ({ page }) => {
|
|
773
|
+
await page.goto('/products');
|
|
774
|
+
|
|
775
|
+
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
776
|
+
await rows.nth(0).getByRole('checkbox').check();
|
|
777
|
+
await rows.nth(1).getByRole('checkbox').check();
|
|
778
|
+
|
|
779
|
+
const bulkToolbar = page.getByRole('toolbar', { name: /bulk actions/i });
|
|
780
|
+
|
|
781
|
+
// Start waiting for download before clicking
|
|
782
|
+
const downloadPromise = page.waitForEvent('download');
|
|
783
|
+
await bulkToolbar.getByRole('button', { name: 'Export' }).click();
|
|
784
|
+
|
|
785
|
+
const download = await downloadPromise;
|
|
786
|
+
expect(download.suggestedFilename()).toMatch(/products.*\.csv$/);
|
|
787
|
+
});
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
**JavaScript**
|
|
791
|
+
|
|
792
|
+
```javascript
|
|
793
|
+
const { test, expect } = require('@playwright/test');
|
|
794
|
+
|
|
795
|
+
test('selects multiple items and performs bulk delete', async ({ page }) => {
|
|
796
|
+
await page.goto('/products');
|
|
797
|
+
|
|
798
|
+
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
|
|
799
|
+
await rows.nth(0).getByRole('checkbox').check();
|
|
800
|
+
await rows.nth(1).getByRole('checkbox').check();
|
|
801
|
+
await rows.nth(2).getByRole('checkbox').check();
|
|
802
|
+
|
|
803
|
+
await expect(page.getByText('3 items selected')).toBeVisible();
|
|
804
|
+
|
|
805
|
+
const bulkToolbar = page.getByRole('toolbar', { name: /bulk actions/i });
|
|
806
|
+
await bulkToolbar.getByRole('button', { name: 'Delete selected' }).click();
|
|
807
|
+
|
|
808
|
+
const dialog = page.getByRole('dialog');
|
|
809
|
+
await expect(dialog).toContainText('Delete 3 products');
|
|
810
|
+
await dialog.getByRole('button', { name: 'Delete' }).click();
|
|
811
|
+
|
|
812
|
+
await expect(page.getByRole('alert')).toContainText('3 products deleted');
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('select all and deselect all', async ({ page }) => {
|
|
816
|
+
await page.goto('/products');
|
|
817
|
+
|
|
818
|
+
const selectAllCheckbox = page
|
|
819
|
+
.getByRole('row')
|
|
820
|
+
.filter({ has: page.getByRole('columnheader') })
|
|
821
|
+
.getByRole('checkbox');
|
|
822
|
+
|
|
823
|
+
await selectAllCheckbox.check();
|
|
824
|
+
|
|
825
|
+
const rowCheckboxes = page
|
|
826
|
+
.getByRole('row')
|
|
827
|
+
.filter({ hasNot: page.getByRole('columnheader') })
|
|
828
|
+
.getByRole('checkbox');
|
|
829
|
+
|
|
830
|
+
const count = await rowCheckboxes.count();
|
|
831
|
+
for (let i = 0; i < count; i++) {
|
|
832
|
+
await expect(rowCheckboxes.nth(i)).toBeChecked();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
---
|
|
838
|
+
|
|
839
|
+
## Variations
|
|
840
|
+
|
|
841
|
+
### Create with Multi-Step Wizard
|
|
842
|
+
|
|
843
|
+
```typescript
|
|
844
|
+
test('creates a resource through a multi-step wizard', async ({ page }) => {
|
|
845
|
+
await page.goto('/products');
|
|
846
|
+
await page.getByRole('button', { name: 'Add product' }).click();
|
|
847
|
+
|
|
848
|
+
// Step 1: Basic info
|
|
849
|
+
await expect(page.getByText('Step 1 of 3')).toBeVisible();
|
|
850
|
+
await page.getByLabel('Product name').fill('Wireless Keyboard');
|
|
851
|
+
await page.getByLabel('Category').selectOption('Electronics');
|
|
852
|
+
await page.getByRole('button', { name: 'Next' }).click();
|
|
853
|
+
|
|
854
|
+
// Step 2: Pricing
|
|
855
|
+
await expect(page.getByText('Step 2 of 3')).toBeVisible();
|
|
856
|
+
await page.getByLabel('Price').fill('79.99');
|
|
857
|
+
await page.getByLabel('Tax rate').selectOption('Standard (20%)');
|
|
858
|
+
await page.getByRole('button', { name: 'Next' }).click();
|
|
859
|
+
|
|
860
|
+
// Step 3: Review and confirm
|
|
861
|
+
await expect(page.getByText('Step 3 of 3')).toBeVisible();
|
|
862
|
+
await expect(page.getByText('Wireless Keyboard')).toBeVisible();
|
|
863
|
+
await expect(page.getByText('$79.99')).toBeVisible();
|
|
864
|
+
await page.getByRole('button', { name: 'Create product' }).click();
|
|
865
|
+
|
|
866
|
+
await expect(page.getByRole('alert')).toContainText('Product created');
|
|
867
|
+
});
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### CRUD with API Verification
|
|
871
|
+
|
|
872
|
+
```typescript
|
|
873
|
+
test('create and verify via API', async ({ page, request }) => {
|
|
874
|
+
await page.goto('/products');
|
|
875
|
+
await page.getByRole('button', { name: 'Add product' }).click();
|
|
876
|
+
|
|
877
|
+
await page.getByLabel('Product name').fill('API Verified Product');
|
|
878
|
+
await page.getByLabel('Price').fill('49.99');
|
|
879
|
+
await page.getByRole('button', { name: 'Save product' }).click();
|
|
880
|
+
|
|
881
|
+
await expect(page.getByRole('alert')).toContainText('Product created');
|
|
882
|
+
|
|
883
|
+
// Also verify via API that the data was persisted correctly
|
|
884
|
+
const response = await request.get('/api/products?search=API+Verified+Product');
|
|
885
|
+
const data = await response.json();
|
|
886
|
+
|
|
887
|
+
expect(data.products).toHaveLength(1);
|
|
888
|
+
expect(data.products[0].name).toBe('API Verified Product');
|
|
889
|
+
expect(data.products[0].price).toBe(49.99);
|
|
890
|
+
});
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
### Optimistic UI Updates
|
|
894
|
+
|
|
895
|
+
```typescript
|
|
896
|
+
test('shows optimistic update then confirms', async ({ page }) => {
|
|
897
|
+
// Slow down the API response to observe optimistic behavior
|
|
898
|
+
await page.route('**/api/products/*', async (route) => {
|
|
899
|
+
if (route.request().method() === 'PATCH') {
|
|
900
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
901
|
+
await route.continue();
|
|
902
|
+
} else {
|
|
903
|
+
await route.continue();
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
await page.goto('/products');
|
|
908
|
+
const row = page.getByRole('row', { name: /Wireless Keyboard/ });
|
|
909
|
+
|
|
910
|
+
await row.getByRole('button', { name: 'Toggle status' }).click();
|
|
911
|
+
|
|
912
|
+
// Verify UI updates immediately (optimistic)
|
|
913
|
+
await expect(row.getByText('Active')).toBeVisible();
|
|
914
|
+
|
|
915
|
+
// Verify loading indicator while API catches up
|
|
916
|
+
await expect(row.getByRole('progressbar')).toBeVisible();
|
|
917
|
+
|
|
918
|
+
// After API responds, loading indicator disappears
|
|
919
|
+
await expect(row.getByRole('progressbar')).not.toBeVisible({ timeout: 5000 });
|
|
920
|
+
await expect(row.getByText('Active')).toBeVisible();
|
|
921
|
+
});
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
926
|
+
## Tips
|
|
927
|
+
|
|
928
|
+
1. **Always verify state after mutations**. After a create, update, or delete, assert that the UI reflects the change. Do not assume the success notification alone means the operation worked. Check that the list, table, or card actually changed.
|
|
929
|
+
|
|
930
|
+
2. **Use unique identifiers in test data**. Include timestamps or random strings in test data to prevent collisions: `Keyboard-${Date.now()}`. This avoids flaky tests from leftover data.
|
|
931
|
+
|
|
932
|
+
3. **Test the full lifecycle**. Write a single test that creates, reads, updates, and then deletes a resource. This catches integration issues between operations that isolated tests miss.
|
|
933
|
+
|
|
934
|
+
4. **Clean up test data via API**. Use `test.afterEach` or `test.afterAll` with the `request` fixture to delete resources created during tests, so tests remain independent and repeatable.
|
|
935
|
+
|
|
936
|
+
5. **Prefer `getByRole('row', { name: ... })` for table operations**. This targets accessible row content and is resilient to column reordering. Avoid relying on `nth()` indices for specific data rows since ordering can change.
|
|
937
|
+
|
|
938
|
+
---
|
|
939
|
+
|
|
940
|
+
## Related
|
|
941
|
+
|
|
942
|
+
- `recipes/search-and-filter.md` -- Filter and search within resource lists
|
|
943
|
+
- `recipes/file-upload-download.md` -- Upload files as part of resource creation
|
|
944
|
+
- `patterns/page-objects.md` -- Encapsulate CRUD forms in page objects
|
|
945
|
+
- `foundations/selectors.md` -- Best practices for selecting table cells and form fields
|