@arcadialdev/arcality 2.2.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/.agents/skills/e2e-testing-expert/SKILL.md +28 -0
- package/.agents/skills/frontend-design/LICENSE.txt +177 -0
- package/.agents/skills/frontend-design/SKILL.md +42 -0
- package/.agents/skills/nodejs-backend-patterns/SKILL.md +639 -0
- package/.agents/skills/nodejs-backend-patterns/references/advanced-patterns.md +430 -0
- package/.agents/skills/playwright-best-practices/LICENSE.md +7 -0
- package/.agents/skills/playwright-best-practices/README.md +147 -0
- package/.agents/skills/playwright-best-practices/SKILL.md +303 -0
- package/.agents/skills/playwright-best-practices/advanced/authentication-flows.md +360 -0
- package/.agents/skills/playwright-best-practices/advanced/authentication.md +871 -0
- package/.agents/skills/playwright-best-practices/advanced/clock-mocking.md +364 -0
- package/.agents/skills/playwright-best-practices/advanced/mobile-testing.md +409 -0
- package/.agents/skills/playwright-best-practices/advanced/multi-context.md +288 -0
- package/.agents/skills/playwright-best-practices/advanced/multi-user.md +393 -0
- package/.agents/skills/playwright-best-practices/advanced/network-advanced.md +452 -0
- package/.agents/skills/playwright-best-practices/advanced/third-party.md +464 -0
- package/.agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md +363 -0
- package/.agents/skills/playwright-best-practices/architecture/test-architecture.md +369 -0
- package/.agents/skills/playwright-best-practices/architecture/when-to-mock.md +383 -0
- package/.agents/skills/playwright-best-practices/browser-apis/browser-apis.md +391 -0
- package/.agents/skills/playwright-best-practices/browser-apis/iframes.md +403 -0
- package/.agents/skills/playwright-best-practices/browser-apis/service-workers.md +504 -0
- package/.agents/skills/playwright-best-practices/browser-apis/websockets.md +403 -0
- package/.agents/skills/playwright-best-practices/core/annotations.md +424 -0
- package/.agents/skills/playwright-best-practices/core/assertions-waiting.md +361 -0
- package/.agents/skills/playwright-best-practices/core/configuration.md +452 -0
- package/.agents/skills/playwright-best-practices/core/fixtures-hooks.md +417 -0
- package/.agents/skills/playwright-best-practices/core/global-setup.md +434 -0
- package/.agents/skills/playwright-best-practices/core/locators.md +242 -0
- package/.agents/skills/playwright-best-practices/core/page-object-model.md +315 -0
- package/.agents/skills/playwright-best-practices/core/projects-dependencies.md +453 -0
- package/.agents/skills/playwright-best-practices/core/test-data.md +492 -0
- package/.agents/skills/playwright-best-practices/core/test-suite-structure.md +361 -0
- package/.agents/skills/playwright-best-practices/core/test-tags.md +298 -0
- package/.agents/skills/playwright-best-practices/debugging/console-errors.md +420 -0
- package/.agents/skills/playwright-best-practices/debugging/debugging.md +504 -0
- package/.agents/skills/playwright-best-practices/debugging/error-testing.md +360 -0
- package/.agents/skills/playwright-best-practices/debugging/flaky-tests.md +496 -0
- package/.agents/skills/playwright-best-practices/frameworks/angular.md +530 -0
- package/.agents/skills/playwright-best-practices/frameworks/nextjs.md +469 -0
- package/.agents/skills/playwright-best-practices/frameworks/react.md +531 -0
- package/.agents/skills/playwright-best-practices/frameworks/vue.md +574 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/ci-cd.md +468 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/docker.md +283 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/github-actions.md +546 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/gitlab.md +397 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/other-providers.md +521 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md +371 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/performance.md +453 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/reporting.md +424 -0
- package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/test-coverage.md +497 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/accessibility.md +359 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/api-testing.md +719 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/browser-extensions.md +506 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md +493 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/component-testing.md +500 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/drag-drop.md +576 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/electron.md +509 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/file-operations.md +377 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/file-upload-download.md +562 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/forms-validation.md +561 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/graphql-testing.md +331 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/i18n.md +508 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/performance-testing.md +476 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/security-testing.md +430 -0
- package/.agents/skills/playwright-best-practices/testing-patterns/visual-regression.md +634 -0
- package/.env.example +21 -0
- package/README.md +30 -0
- package/bin/arcality.mjs +86 -0
- package/package.json +66 -0
- package/playwright.config.ts +12 -0
- package/scripts/cleanup-qmsdev.mjs +63 -0
- package/scripts/discover-view.mjs +52 -0
- package/scripts/extract-view.mjs +64 -0
- package/scripts/gen-and-run.mjs +838 -0
- package/scripts/init.mjs +290 -0
- package/scripts/migrate-to-central-out.mjs +157 -0
- package/scripts/postinstall.mjs +63 -0
- package/scripts/rebrand-report.mjs +241 -0
- package/scripts/setup.mjs +166 -0
- package/src/KnowledgeService.ts +239 -0
- package/src/arcalityClient.mjs +266 -0
- package/src/configLoader.mjs +179 -0
- package/src/configManager.mjs +172 -0
- package/src/consoleBanner.ts +32 -0
- package/src/envSetup.ts +205 -0
- package/src/index.ts +25 -0
- package/src/projectInspector.ts +42 -0
- package/src/services/collectiveMemoryService.ts +178 -0
- package/src/testRunner.ts +201 -0
- package/tests/_helpers/ArcalityReporter.ts +490 -0
- package/tests/_helpers/agentic-runner.spec.ts +741 -0
- package/tests/_helpers/ai-agent-helper.ts +1573 -0
- package/tests/_helpers/discover-view.spec.ts +238 -0
- package/tests/_helpers/extract-view.spec.ts +118 -0
- package/tests/_helpers/qa-tools.ts +333 -0
- package/tests/_helpers/smart-action.spec.ts +1458 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# React Application Testing
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
|
|
5
|
+
1. [Patterns](#patterns)
|
|
6
|
+
2. [Setup](#setup)
|
|
7
|
+
3. [Framework Tips](#framework-tips)
|
|
8
|
+
4. [Anti-Patterns](#anti-patterns)
|
|
9
|
+
5. [Related](#related)
|
|
10
|
+
|
|
11
|
+
> **When to use**: Testing React apps built with Vite, Create React App, or custom bundlers. Covers E2E testing, component testing, React Router navigation, form libraries, portals, error boundaries, and context/state verification.
|
|
12
|
+
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)
|
|
13
|
+
|
|
14
|
+
## Patterns
|
|
15
|
+
|
|
16
|
+
### Testing Context and Global State
|
|
17
|
+
|
|
18
|
+
**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.
|
|
19
|
+
**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { test, expect } from '@playwright/test';
|
|
23
|
+
|
|
24
|
+
test.describe('theme switching', () => {
|
|
25
|
+
test('toggle applies dark mode across pages', async ({ page }) => {
|
|
26
|
+
await page.goto('/preferences');
|
|
27
|
+
|
|
28
|
+
const root = page.locator('html');
|
|
29
|
+
await expect(root).not.toHaveClass(/dark-mode/);
|
|
30
|
+
|
|
31
|
+
await page.getByRole('switch', { name: 'Enable dark theme' }).click();
|
|
32
|
+
await expect(root).toHaveClass(/dark-mode/);
|
|
33
|
+
|
|
34
|
+
await page.getByRole('link', { name: 'Dashboard' }).click();
|
|
35
|
+
await expect(page.locator('html')).toHaveClass(/dark-mode/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test.describe('cart state persistence', () => {
|
|
40
|
+
test('item count updates globally', async ({ page }) => {
|
|
41
|
+
await page.goto('/catalog');
|
|
42
|
+
|
|
43
|
+
const badge = page.getByTestId('cart-badge');
|
|
44
|
+
|
|
45
|
+
await page.getByRole('listitem')
|
|
46
|
+
.filter({ hasText: 'Wireless Headphones' })
|
|
47
|
+
.getByRole('button', { name: 'Add' })
|
|
48
|
+
.click();
|
|
49
|
+
await expect(badge).toHaveText('1');
|
|
50
|
+
|
|
51
|
+
await page.getByRole('link', { name: 'Contact' }).click();
|
|
52
|
+
await expect(badge).toHaveText('1');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.describe('auth state', () => {
|
|
57
|
+
test('login updates header across components', async ({ page }) => {
|
|
58
|
+
await page.goto('/');
|
|
59
|
+
|
|
60
|
+
await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();
|
|
61
|
+
|
|
62
|
+
await page.getByRole('link', { name: 'Login' }).click();
|
|
63
|
+
await page.getByLabel('Username').fill('testuser');
|
|
64
|
+
await page.getByLabel('Password').fill('secret123');
|
|
65
|
+
await page.getByRole('button', { name: 'Submit' }).click();
|
|
66
|
+
|
|
67
|
+
await expect(page.getByRole('link', { name: 'Login' })).toBeHidden();
|
|
68
|
+
await expect(page.getByText('testuser')).toBeVisible();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### React Router Navigation
|
|
74
|
+
|
|
75
|
+
**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.
|
|
76
|
+
**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { test, expect } from '@playwright/test';
|
|
80
|
+
|
|
81
|
+
test.describe('client routing', () => {
|
|
82
|
+
test('navigation preserves SPA state', async ({ page }) => {
|
|
83
|
+
await page.goto('/');
|
|
84
|
+
|
|
85
|
+
await page.evaluate(() => {
|
|
86
|
+
(window as any).__spaMarker = 'active';
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await page.getByRole('link', { name: 'Inventory' }).click();
|
|
90
|
+
await page.waitForURL('/inventory');
|
|
91
|
+
|
|
92
|
+
const marker = await page.evaluate(() => (window as any).__spaMarker);
|
|
93
|
+
expect(marker).toBe('active');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('query params filter content', async ({ page }) => {
|
|
97
|
+
await page.goto('/items?type=books');
|
|
98
|
+
|
|
99
|
+
await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible();
|
|
100
|
+
|
|
101
|
+
await page.getByRole('link', { name: 'Music' }).click();
|
|
102
|
+
await page.waitForURL('/items?type=music');
|
|
103
|
+
|
|
104
|
+
await expect(page.getByRole('heading', { name: 'Music' })).toBeVisible();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('nested routes render layouts', async ({ page }) => {
|
|
108
|
+
await page.goto('/account/security');
|
|
109
|
+
|
|
110
|
+
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
|
|
111
|
+
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
|
|
112
|
+
|
|
113
|
+
await page.getByRole('link', { name: 'Privacy' }).click();
|
|
114
|
+
await page.waitForURL('/account/privacy');
|
|
115
|
+
|
|
116
|
+
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
|
|
117
|
+
await expect(page.getByRole('heading', { name: 'Privacy', level: 2 })).toBeVisible();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('history navigation works', async ({ page }) => {
|
|
121
|
+
await page.goto('/');
|
|
122
|
+
await page.getByRole('link', { name: 'Inventory' }).click();
|
|
123
|
+
await page.waitForURL('/inventory');
|
|
124
|
+
await page.getByRole('link', { name: 'Help' }).click();
|
|
125
|
+
await page.waitForURL('/help');
|
|
126
|
+
|
|
127
|
+
await page.goBack();
|
|
128
|
+
await expect(page).toHaveURL(/\/inventory/);
|
|
129
|
+
|
|
130
|
+
await page.goBack();
|
|
131
|
+
await expect(page).toHaveURL(/\/$/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('protected route redirects', async ({ page }) => {
|
|
135
|
+
await page.goto('/admin/users');
|
|
136
|
+
|
|
137
|
+
await expect(page).toHaveURL(/\/login/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('unknown route shows 404', async ({ page }) => {
|
|
141
|
+
await page.goto('/nonexistent-path');
|
|
142
|
+
|
|
143
|
+
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Testing Hooks Through UI
|
|
149
|
+
|
|
150
|
+
**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.
|
|
151
|
+
**Avoid when**: Hook logic is pure computation—use unit tests instead.
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { test, expect } from '@playwright/test';
|
|
155
|
+
|
|
156
|
+
test.describe('useDebounce via SearchBox', () => {
|
|
157
|
+
test('batches rapid input', async ({ page }) => {
|
|
158
|
+
await page.goto('/search');
|
|
159
|
+
|
|
160
|
+
const apiCalls: string[] = [];
|
|
161
|
+
await page.route('**/api/query*', async (route) => {
|
|
162
|
+
apiCalls.push(route.request().url());
|
|
163
|
+
await route.continue();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('testing', {
|
|
167
|
+
delay: 40,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await expect(page.getByRole('listitem')).toHaveCount(3);
|
|
171
|
+
expect(apiCalls.length).toBeLessThanOrEqual(2);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test.describe('usePagination via DataGrid', () => {
|
|
176
|
+
test('page controls work', async ({ page }) => {
|
|
177
|
+
await page.goto('/records');
|
|
178
|
+
|
|
179
|
+
await expect(page.getByText('Page 1 of 10')).toBeVisible();
|
|
180
|
+
|
|
181
|
+
await page.getByRole('button', { name: 'Next' }).click();
|
|
182
|
+
await expect(page.getByText('Page 2 of 10')).toBeVisible();
|
|
183
|
+
|
|
184
|
+
await page.getByRole('button', { name: 'Previous' }).click();
|
|
185
|
+
await expect(page.getByText('Page 1 of 10')).toBeVisible();
|
|
186
|
+
await expect(page.getByRole('button', { name: 'Previous' })).toBeDisabled();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Form Libraries (React Hook Form, Formik)
|
|
192
|
+
|
|
193
|
+
**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { test, expect } from '@playwright/test';
|
|
197
|
+
|
|
198
|
+
test.describe('signup form', () => {
|
|
199
|
+
test.beforeEach(async ({ page }) => {
|
|
200
|
+
await page.goto('/signup');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('validation on empty submit', async ({ page }) => {
|
|
204
|
+
await page.getByRole('button', { name: 'Register' }).click();
|
|
205
|
+
|
|
206
|
+
await expect(page.getByText('Email required')).toBeVisible();
|
|
207
|
+
await expect(page.getByText('Password required')).toBeVisible();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('inline validation on blur', async ({ page }) => {
|
|
211
|
+
const email = page.getByLabel('Email');
|
|
212
|
+
await email.fill('invalid');
|
|
213
|
+
await email.blur();
|
|
214
|
+
|
|
215
|
+
await expect(page.getByText('Invalid email format')).toBeVisible();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('password strength indicator', async ({ page }) => {
|
|
219
|
+
const pwd = page.getByLabel('Password', { exact: true });
|
|
220
|
+
|
|
221
|
+
await pwd.fill('weak');
|
|
222
|
+
await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/);
|
|
223
|
+
|
|
224
|
+
await pwd.fill('StrongPass1!');
|
|
225
|
+
await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('successful submission redirects', async ({ page }) => {
|
|
229
|
+
await page.getByLabel('Name').fill('Alice');
|
|
230
|
+
await page.getByLabel('Email').fill('alice@test.com');
|
|
231
|
+
await page.getByLabel('Password', { exact: true }).fill('Secure123!');
|
|
232
|
+
await page.getByLabel('Confirm').fill('Secure123!');
|
|
233
|
+
await page.getByLabel('Accept terms').check();
|
|
234
|
+
|
|
235
|
+
await page.getByRole('button', { name: 'Register' }).click();
|
|
236
|
+
|
|
237
|
+
await page.waitForURL('/welcome');
|
|
238
|
+
await expect(page.getByText('Hello, Alice')).toBeVisible();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('submit button disabled during request', async ({ page }) => {
|
|
242
|
+
await page.route('**/api/signup', async (route) => {
|
|
243
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
244
|
+
await route.fulfill({ status: 201, json: { id: 1 } });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await page.getByLabel('Name').fill('Bob');
|
|
248
|
+
await page.getByLabel('Email').fill('bob@test.com');
|
|
249
|
+
await page.getByLabel('Password', { exact: true }).fill('Secure123!');
|
|
250
|
+
await page.getByLabel('Confirm').fill('Secure123!');
|
|
251
|
+
await page.getByLabel('Accept terms').check();
|
|
252
|
+
|
|
253
|
+
await page.getByRole('button', { name: 'Register' }).click();
|
|
254
|
+
|
|
255
|
+
await expect(page.getByRole('button', { name: /Registering|Loading/ })).toBeDisabled();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Portals (Modals, Tooltips, Dropdowns)
|
|
261
|
+
|
|
262
|
+
**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
import { test, expect } from '@playwright/test';
|
|
266
|
+
|
|
267
|
+
test.describe('portal components', () => {
|
|
268
|
+
test('modal interaction', async ({ page }) => {
|
|
269
|
+
await page.goto('/items');
|
|
270
|
+
|
|
271
|
+
await page.getByRole('button', { name: 'Remove' }).first().click();
|
|
272
|
+
|
|
273
|
+
const dialog = page.getByRole('dialog', { name: 'Confirm removal' });
|
|
274
|
+
await expect(dialog).toBeVisible();
|
|
275
|
+
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
|
|
276
|
+
|
|
277
|
+
await dialog.getByRole('button', { name: 'Remove' }).click();
|
|
278
|
+
await expect(dialog).toBeHidden();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('escape closes modal', async ({ page }) => {
|
|
282
|
+
await page.goto('/items');
|
|
283
|
+
await page.getByRole('button', { name: 'Remove' }).first().click();
|
|
284
|
+
|
|
285
|
+
const dialog = page.getByRole('dialog');
|
|
286
|
+
await expect(dialog).toBeVisible();
|
|
287
|
+
|
|
288
|
+
await page.keyboard.press('Escape');
|
|
289
|
+
await expect(dialog).toBeHidden();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('tooltip on hover', async ({ page }) => {
|
|
293
|
+
await page.goto('/panel');
|
|
294
|
+
|
|
295
|
+
await page.getByRole('button', { name: 'Help' }).hover();
|
|
296
|
+
await expect(page.getByRole('tooltip')).toBeVisible();
|
|
297
|
+
|
|
298
|
+
await page.mouse.move(0, 0);
|
|
299
|
+
await expect(page.getByRole('tooltip')).toBeHidden();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('dropdown menu', async ({ page }) => {
|
|
303
|
+
await page.goto('/panel');
|
|
304
|
+
|
|
305
|
+
await page.getByRole('button', { name: 'Actions' }).click();
|
|
306
|
+
|
|
307
|
+
const menu = page.getByRole('menu');
|
|
308
|
+
await expect(menu).toBeVisible();
|
|
309
|
+
|
|
310
|
+
await menu.getByRole('menuitem', { name: 'Rename' }).click();
|
|
311
|
+
await expect(menu).toBeHidden();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('toast auto-dismisses', async ({ page }) => {
|
|
315
|
+
await page.goto('/preferences');
|
|
316
|
+
|
|
317
|
+
await page.getByRole('button', { name: 'Save' }).click();
|
|
318
|
+
await expect(page.getByText('Preferences saved')).toBeVisible();
|
|
319
|
+
|
|
320
|
+
await expect(page.getByText('Preferences saved')).toBeHidden({ timeout: 8000 });
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Error Boundaries
|
|
326
|
+
|
|
327
|
+
**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.
|
|
328
|
+
**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
import { test, expect } from '@playwright/test';
|
|
332
|
+
|
|
333
|
+
test.describe('error boundary', () => {
|
|
334
|
+
test('shows fallback on crash', async ({ page }) => {
|
|
335
|
+
await page.route('**/api/widgets', (route) => {
|
|
336
|
+
route.fulfill({
|
|
337
|
+
status: 200,
|
|
338
|
+
json: { widgets: null },
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await page.goto('/panel');
|
|
343
|
+
|
|
344
|
+
await expect(page.getByText('Something went wrong')).toBeVisible();
|
|
345
|
+
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
|
346
|
+
await expect(page.getByRole('navigation')).toBeVisible();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('retry recovers component', async ({ page }) => {
|
|
350
|
+
let calls = 0;
|
|
351
|
+
await page.route('**/api/widgets', (route) => {
|
|
352
|
+
calls++;
|
|
353
|
+
if (calls === 1) {
|
|
354
|
+
route.fulfill({ status: 200, json: { widgets: null } });
|
|
355
|
+
} else {
|
|
356
|
+
route.fulfill({ status: 200, json: { widgets: [{ id: 1, name: 'Chart' }] } });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await page.goto('/panel');
|
|
361
|
+
|
|
362
|
+
await expect(page.getByText('Something went wrong')).toBeVisible();
|
|
363
|
+
|
|
364
|
+
await page.getByRole('button', { name: 'Retry' }).click();
|
|
365
|
+
|
|
366
|
+
await expect(page.getByText('Something went wrong')).toBeHidden();
|
|
367
|
+
await expect(page.getByText('Chart')).toBeVisible();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Component Testing (Experimental)
|
|
373
|
+
|
|
374
|
+
**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.
|
|
375
|
+
**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
// playwright-ct.config.ts
|
|
379
|
+
import { defineConfig, devices } from '@playwright/experimental-ct-react';
|
|
380
|
+
|
|
381
|
+
export default defineConfig({
|
|
382
|
+
testDir: './tests/components',
|
|
383
|
+
testMatch: '**/*.ct.ts',
|
|
384
|
+
use: {
|
|
385
|
+
trace: 'on-first-retry',
|
|
386
|
+
ctPort: 3100,
|
|
387
|
+
},
|
|
388
|
+
projects: [
|
|
389
|
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
390
|
+
],
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
// tests/components/Stepper.ct.ts
|
|
396
|
+
import { test, expect } from '@playwright/experimental-ct-react';
|
|
397
|
+
import Stepper from '../../src/components/Stepper';
|
|
398
|
+
|
|
399
|
+
test('increments on click', async ({ mount }) => {
|
|
400
|
+
const component = await mount(<Stepper initial={0} />);
|
|
401
|
+
|
|
402
|
+
await expect(component.getByText('Value: 0')).toBeVisible();
|
|
403
|
+
await component.getByRole('button', { name: '+' }).click();
|
|
404
|
+
await expect(component.getByText('Value: 1')).toBeVisible();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('fires onChange callback', async ({ mount }) => {
|
|
408
|
+
const values: number[] = [];
|
|
409
|
+
const component = await mount(
|
|
410
|
+
<Stepper initial={0} onChange={(v) => values.push(v)} />
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
await component.getByRole('button', { name: '+' }).click();
|
|
414
|
+
await component.getByRole('button', { name: '+' }).click();
|
|
415
|
+
|
|
416
|
+
expect(values).toEqual([1, 2]);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('respects min boundary', async ({ mount }) => {
|
|
420
|
+
const component = await mount(<Stepper initial={0} min={0} />);
|
|
421
|
+
|
|
422
|
+
await expect(component.getByRole('button', { name: '-' })).toBeDisabled();
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
## Setup
|
|
427
|
+
|
|
428
|
+
### E2E Config (Vite)
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
// playwright.config.ts
|
|
432
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
433
|
+
|
|
434
|
+
export default defineConfig({
|
|
435
|
+
testDir: './tests',
|
|
436
|
+
fullyParallel: true,
|
|
437
|
+
forbidOnly: !!process.env.CI,
|
|
438
|
+
retries: process.env.CI ? 2 : 0,
|
|
439
|
+
workers: process.env.CI ? '50%' : undefined,
|
|
440
|
+
|
|
441
|
+
use: {
|
|
442
|
+
baseURL: 'http://localhost:5173',
|
|
443
|
+
trace: 'on-first-retry',
|
|
444
|
+
screenshot: 'only-on-failure',
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
projects: [
|
|
448
|
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
449
|
+
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
|
450
|
+
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
|
|
451
|
+
],
|
|
452
|
+
|
|
453
|
+
webServer: {
|
|
454
|
+
command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',
|
|
455
|
+
url: 'http://localhost:5173',
|
|
456
|
+
reuseExistingServer: !process.env.CI,
|
|
457
|
+
timeout: 120_000,
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### CRA vs Vite Differences
|
|
463
|
+
|
|
464
|
+
| Aspect | Create React App | Vite |
|
|
465
|
+
|---|---|---|
|
|
466
|
+
| Default port | `3000` | `5173` |
|
|
467
|
+
| Build output | `build/` | `dist/` |
|
|
468
|
+
| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |
|
|
469
|
+
| Env var prefix | `REACT_APP_*` | `VITE_*` |
|
|
470
|
+
|
|
471
|
+
## Framework Tips
|
|
472
|
+
|
|
473
|
+
### Strict Mode Double Effects
|
|
474
|
+
|
|
475
|
+
React Strict Mode runs effects twice in development. Tests should be resilient:
|
|
476
|
+
|
|
477
|
+
- Don't assert exact API call counts in dev mode
|
|
478
|
+
- Run against production build for call count assertions, or account for double invocations
|
|
479
|
+
|
|
480
|
+
### Suspense and Lazy Components
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
test('lazy route loads content', async ({ page }) => {
|
|
484
|
+
await page.goto('/');
|
|
485
|
+
|
|
486
|
+
await page.getByRole('link', { name: 'Analytics' }).click();
|
|
487
|
+
|
|
488
|
+
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### Detecting Memory Leaks
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
test('no unmounted state warnings', async ({ page }) => {
|
|
496
|
+
const warnings: string[] = [];
|
|
497
|
+
page.on('console', (msg) => {
|
|
498
|
+
if (msg.type() === 'warning' && msg.text().includes('unmounted')) {
|
|
499
|
+
warnings.push(msg.text());
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
await page.goto('/panel');
|
|
504
|
+
await page.getByRole('link', { name: 'Settings' }).click();
|
|
505
|
+
await page.goBack();
|
|
506
|
+
await page.getByRole('link', { name: 'Profile' }).click();
|
|
507
|
+
|
|
508
|
+
expect(warnings).toEqual([]);
|
|
509
|
+
});
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
## Anti-Patterns
|
|
513
|
+
|
|
514
|
+
| Don't | Problem | Do Instead |
|
|
515
|
+
|---|---|---|
|
|
516
|
+
| `page.evaluate(() => store.getState())` | Couples tests to implementation | Assert on UI: `expect(badge).toHaveText('3')` |
|
|
517
|
+
| Import components in E2E tests | E2E runs in Node, not browser | Use `@playwright/experimental-ct-react` for components |
|
|
518
|
+
| `page.waitForTimeout(500)` after state changes | Timing varies across machines | `expect(locator).toHaveText('value')` auto-retries |
|
|
519
|
+
| `page.locator('.MuiButton-root')` | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |
|
|
520
|
+
| Test every component with CT | Overhead for simple components | CT for complex widgets, unit tests for logic, E2E for flows |
|
|
521
|
+
| Skip keyboard navigation tests | Accessibility regressions common | Test Tab, Enter, Escape, Arrow interactions |
|
|
522
|
+
| Assert on `__REACT_FIBER__` internals | Not stable across versions | Only interact with rendered DOM |
|
|
523
|
+
|
|
524
|
+
## Related
|
|
525
|
+
|
|
526
|
+
- [locators.md](../core/locators.md) — locator strategies for any React component library
|
|
527
|
+
- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes
|
|
528
|
+
- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns
|
|
529
|
+
- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing
|
|
530
|
+
- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions
|
|
531
|
+
- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR
|