@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,1086 @@
|
|
|
1
|
+
# Testing Angular Apps with Playwright
|
|
2
|
+
|
|
3
|
+
> **When to use**: Testing Angular applications -- reactive forms, Angular Material components, Angular Router navigation, lazy-loaded modules, signals, observables, and Zone.js-driven change detection. This guide covers E2E testing patterns specific to Angular behavior.
|
|
4
|
+
> **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md)
|
|
5
|
+
|
|
6
|
+
## Quick Reference
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# Install Playwright in an Angular project
|
|
10
|
+
npm init playwright@latest
|
|
11
|
+
|
|
12
|
+
# Run tests with Angular dev server managed by Playwright
|
|
13
|
+
npx playwright test
|
|
14
|
+
|
|
15
|
+
# Run against a production build (recommended for CI)
|
|
16
|
+
npx playwright test --project=chromium
|
|
17
|
+
|
|
18
|
+
# Debug a single test
|
|
19
|
+
npx playwright test tests/home.spec.ts --headed --debug
|
|
20
|
+
|
|
21
|
+
# Generate tests with codegen
|
|
22
|
+
npx playwright codegen http://localhost:4200
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
### Playwright Config for Angular
|
|
28
|
+
|
|
29
|
+
**TypeScript**
|
|
30
|
+
```typescript
|
|
31
|
+
// playwright.config.ts
|
|
32
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
33
|
+
|
|
34
|
+
export default defineConfig({
|
|
35
|
+
testDir: './e2e',
|
|
36
|
+
testMatch: '**/*.spec.ts',
|
|
37
|
+
fullyParallel: true,
|
|
38
|
+
forbidOnly: !!process.env.CI,
|
|
39
|
+
retries: process.env.CI ? 2 : 0,
|
|
40
|
+
workers: process.env.CI ? '50%' : undefined,
|
|
41
|
+
|
|
42
|
+
use: {
|
|
43
|
+
baseURL: 'http://localhost:4200',
|
|
44
|
+
trace: 'on-first-retry',
|
|
45
|
+
screenshot: 'only-on-failure',
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
projects: [
|
|
49
|
+
{
|
|
50
|
+
name: 'chromium',
|
|
51
|
+
use: { ...devices['Desktop Chrome'] },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'firefox',
|
|
55
|
+
use: { ...devices['Desktop Firefox'] },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'mobile',
|
|
59
|
+
use: { ...devices['iPhone 14'] },
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
|
|
63
|
+
webServer: {
|
|
64
|
+
command: process.env.CI
|
|
65
|
+
? 'npx ng build && npx http-server dist/your-app/browser -p 4200 -s'
|
|
66
|
+
: 'npx ng serve',
|
|
67
|
+
url: 'http://localhost:4200',
|
|
68
|
+
reuseExistingServer: !process.env.CI,
|
|
69
|
+
timeout: 120_000, // Angular builds can be slow
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**JavaScript**
|
|
75
|
+
```javascript
|
|
76
|
+
// playwright.config.js
|
|
77
|
+
const { defineConfig, devices } = require('@playwright/test');
|
|
78
|
+
|
|
79
|
+
module.exports = defineConfig({
|
|
80
|
+
testDir: './e2e',
|
|
81
|
+
testMatch: '**/*.spec.js',
|
|
82
|
+
fullyParallel: true,
|
|
83
|
+
forbidOnly: !!process.env.CI,
|
|
84
|
+
retries: process.env.CI ? 2 : 0,
|
|
85
|
+
workers: process.env.CI ? '50%' : undefined,
|
|
86
|
+
|
|
87
|
+
use: {
|
|
88
|
+
baseURL: 'http://localhost:4200',
|
|
89
|
+
trace: 'on-first-retry',
|
|
90
|
+
screenshot: 'only-on-failure',
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
projects: [
|
|
94
|
+
{
|
|
95
|
+
name: 'chromium',
|
|
96
|
+
use: { ...devices['Desktop Chrome'] },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'firefox',
|
|
100
|
+
use: { ...devices['Desktop Firefox'] },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'mobile',
|
|
104
|
+
use: { ...devices['iPhone 14'] },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
|
|
108
|
+
webServer: {
|
|
109
|
+
command: process.env.CI
|
|
110
|
+
? 'npx ng build && npx http-server dist/your-app/browser -p 4200 -s'
|
|
111
|
+
: 'npx ng serve',
|
|
112
|
+
url: 'http://localhost:4200',
|
|
113
|
+
reuseExistingServer: !process.env.CI,
|
|
114
|
+
timeout: 120_000,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Angular CLI Integration
|
|
120
|
+
|
|
121
|
+
Angular projects that previously used Protractor can adopt Playwright as a direct replacement. The test directory conventionally lives at `e2e/` in Angular projects.
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
your-angular-app/
|
|
125
|
+
src/
|
|
126
|
+
e2e/
|
|
127
|
+
tests/
|
|
128
|
+
home.spec.ts
|
|
129
|
+
auth.spec.ts
|
|
130
|
+
products.spec.ts
|
|
131
|
+
fixtures/
|
|
132
|
+
auth.fixture.ts
|
|
133
|
+
playwright.config.ts
|
|
134
|
+
angular.json
|
|
135
|
+
package.json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Add scripts to `package.json`:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"scripts": {
|
|
143
|
+
"e2e": "playwright test",
|
|
144
|
+
"e2e:headed": "playwright test --headed",
|
|
145
|
+
"e2e:debug": "playwright test --debug",
|
|
146
|
+
"e2e:report": "playwright show-report"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Environment Configuration
|
|
152
|
+
|
|
153
|
+
Angular uses `environment.ts` and `environment.prod.ts` for build-time configuration. For test-specific settings, use environment variables passed through the Playwright config.
|
|
154
|
+
|
|
155
|
+
**TypeScript**
|
|
156
|
+
```typescript
|
|
157
|
+
// playwright.config.ts (excerpt)
|
|
158
|
+
webServer: {
|
|
159
|
+
command: process.env.CI
|
|
160
|
+
? 'npx ng build --configuration=production && npx http-server dist/your-app/browser -p 4200 -s'
|
|
161
|
+
: 'npx ng serve --configuration=development',
|
|
162
|
+
url: 'http://localhost:4200',
|
|
163
|
+
reuseExistingServer: !process.env.CI,
|
|
164
|
+
timeout: 120_000,
|
|
165
|
+
env: {
|
|
166
|
+
NG_APP_API_URL: 'http://localhost:4200/api',
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Patterns
|
|
172
|
+
|
|
173
|
+
### Angular-Specific Locator Strategies
|
|
174
|
+
|
|
175
|
+
**Use when**: Targeting elements in Angular templates. Angular generates specific attribute patterns (`_ngcontent-*`, `_nghost-*`, `ng-reflect-*`) that you must avoid in locators. Always use semantic locators.
|
|
176
|
+
**Avoid when**: You are tempted to use `[_ngcontent-abc123]` or `[ng-reflect-model]` attributes -- they are internal and change on every build.
|
|
177
|
+
|
|
178
|
+
**TypeScript**
|
|
179
|
+
```typescript
|
|
180
|
+
import { test, expect } from '@playwright/test';
|
|
181
|
+
|
|
182
|
+
test.describe('Angular locator strategies', () => {
|
|
183
|
+
test('prefer role-based locators over Angular internals', async ({ page }) => {
|
|
184
|
+
await page.goto('/dashboard');
|
|
185
|
+
|
|
186
|
+
// GOOD: Role-based locators work with Angular Material and native HTML
|
|
187
|
+
await page.getByRole('button', { name: 'Create project' }).click();
|
|
188
|
+
await expect(page.getByRole('heading', { name: 'New Project' })).toBeVisible();
|
|
189
|
+
|
|
190
|
+
// GOOD: Label-based for form fields
|
|
191
|
+
await page.getByLabel('Project name').fill('My Project');
|
|
192
|
+
|
|
193
|
+
// GOOD: Text-based for non-interactive content
|
|
194
|
+
await expect(page.getByText('3 projects total')).toBeVisible();
|
|
195
|
+
|
|
196
|
+
// BAD (never do this):
|
|
197
|
+
// page.locator('[_ngcontent-abc]') -- changes every build
|
|
198
|
+
// page.locator('[ng-reflect-model]') -- debug attribute, stripped in prod
|
|
199
|
+
// page.locator('app-dashboard .mat-card') -- component selector + internal class
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('use test IDs for complex Angular components', async ({ page }) => {
|
|
203
|
+
await page.goto('/analytics');
|
|
204
|
+
|
|
205
|
+
// Angular components with no semantic role need test IDs
|
|
206
|
+
const chart = page.getByTestId('revenue-chart');
|
|
207
|
+
await expect(chart).toBeVisible();
|
|
208
|
+
|
|
209
|
+
// Configure the testIdAttribute if your team uses a different attribute
|
|
210
|
+
// In playwright.config.ts: use: { testIdAttribute: 'data-cy' }
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('scope locators within Angular component boundaries', async ({ page }) => {
|
|
214
|
+
await page.goto('/users');
|
|
215
|
+
|
|
216
|
+
// Scope within a table to find specific rows
|
|
217
|
+
const userTable = page.getByRole('table', { name: 'Users' });
|
|
218
|
+
const adminRow = userTable.getByRole('row').filter({
|
|
219
|
+
has: page.getByRole('cell', { name: 'Admin' }),
|
|
220
|
+
});
|
|
221
|
+
await adminRow.getByRole('button', { name: 'Edit' }).click();
|
|
222
|
+
|
|
223
|
+
await expect(page.getByRole('dialog', { name: 'Edit User' })).toBeVisible();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**JavaScript**
|
|
229
|
+
```javascript
|
|
230
|
+
const { test, expect } = require('@playwright/test');
|
|
231
|
+
|
|
232
|
+
test.describe('Angular locator strategies', () => {
|
|
233
|
+
test('prefer role-based locators over Angular internals', async ({ page }) => {
|
|
234
|
+
await page.goto('/dashboard');
|
|
235
|
+
|
|
236
|
+
await page.getByRole('button', { name: 'Create project' }).click();
|
|
237
|
+
await expect(page.getByRole('heading', { name: 'New Project' })).toBeVisible();
|
|
238
|
+
|
|
239
|
+
await page.getByLabel('Project name').fill('My Project');
|
|
240
|
+
await expect(page.getByText('3 projects total')).toBeVisible();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('use test IDs for complex Angular components', async ({ page }) => {
|
|
244
|
+
await page.goto('/analytics');
|
|
245
|
+
|
|
246
|
+
const chart = page.getByTestId('revenue-chart');
|
|
247
|
+
await expect(chart).toBeVisible();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('scope locators within Angular component boundaries', async ({ page }) => {
|
|
251
|
+
await page.goto('/users');
|
|
252
|
+
|
|
253
|
+
const userTable = page.getByRole('table', { name: 'Users' });
|
|
254
|
+
const adminRow = userTable.getByRole('row').filter({
|
|
255
|
+
has: page.getByRole('cell', { name: 'Admin' }),
|
|
256
|
+
});
|
|
257
|
+
await adminRow.getByRole('button', { name: 'Edit' }).click();
|
|
258
|
+
|
|
259
|
+
await expect(page.getByRole('dialog', { name: 'Edit User' })).toBeVisible();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Testing Reactive Forms
|
|
265
|
+
|
|
266
|
+
**Use when**: Testing Angular reactive forms (`FormGroup`, `FormControl`, `FormArray`). Playwright interacts with the rendered DOM, so reactive forms are transparent -- test the user experience.
|
|
267
|
+
**Avoid when**: Testing form validation logic in isolation -- use Angular TestBed unit tests for that.
|
|
268
|
+
|
|
269
|
+
**TypeScript**
|
|
270
|
+
```typescript
|
|
271
|
+
import { test, expect } from '@playwright/test';
|
|
272
|
+
|
|
273
|
+
test.describe('reactive forms', () => {
|
|
274
|
+
test.beforeEach(async ({ page }) => {
|
|
275
|
+
await page.goto('/register');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('shows validation errors for invalid inputs', async ({ page }) => {
|
|
279
|
+
// Touch and blur each field to trigger Angular's touched + dirty validators
|
|
280
|
+
const emailInput = page.getByLabel('Email');
|
|
281
|
+
await emailInput.click();
|
|
282
|
+
await emailInput.blur();
|
|
283
|
+
|
|
284
|
+
await expect(page.getByText('Email is required')).toBeVisible();
|
|
285
|
+
|
|
286
|
+
await emailInput.fill('not-an-email');
|
|
287
|
+
await emailInput.blur();
|
|
288
|
+
|
|
289
|
+
await expect(page.getByText('Invalid email format')).toBeVisible();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('cross-field validation (password match)', async ({ page }) => {
|
|
293
|
+
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
|
|
294
|
+
await page.getByLabel('Confirm password').fill('different-password');
|
|
295
|
+
await page.getByLabel('Confirm password').blur();
|
|
296
|
+
|
|
297
|
+
await expect(page.getByText('Passwords do not match')).toBeVisible();
|
|
298
|
+
|
|
299
|
+
// Fix the mismatch
|
|
300
|
+
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
|
|
301
|
+
await page.getByLabel('Confirm password').blur();
|
|
302
|
+
|
|
303
|
+
await expect(page.getByText('Passwords do not match')).toBeHidden();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('dynamic FormArray -- add and remove items', async ({ page }) => {
|
|
307
|
+
await page.goto('/profile/edit');
|
|
308
|
+
|
|
309
|
+
// Add a phone number (FormArray push)
|
|
310
|
+
await page.getByRole('button', { name: 'Add phone number' }).click();
|
|
311
|
+
|
|
312
|
+
const phoneInputs = page.getByLabel(/Phone number/);
|
|
313
|
+
await expect(phoneInputs).toHaveCount(2); // default + new one
|
|
314
|
+
|
|
315
|
+
await phoneInputs.nth(1).fill('+1-555-0199');
|
|
316
|
+
|
|
317
|
+
// Remove the first phone number
|
|
318
|
+
await page.getByRole('button', { name: 'Remove phone 1' }).click();
|
|
319
|
+
await expect(phoneInputs).toHaveCount(1);
|
|
320
|
+
await expect(phoneInputs.first()).toHaveValue('+1-555-0199');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('submit button disabled when form is invalid', async ({ page }) => {
|
|
324
|
+
const submitButton = page.getByRole('button', { name: 'Create account' });
|
|
325
|
+
|
|
326
|
+
// Form starts invalid -- button should be disabled
|
|
327
|
+
await expect(submitButton).toBeDisabled();
|
|
328
|
+
|
|
329
|
+
// Fill all required fields
|
|
330
|
+
await page.getByLabel('Full name').fill('Jane Doe');
|
|
331
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
332
|
+
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
|
|
333
|
+
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
|
|
334
|
+
await page.getByLabel('I agree to the terms').check();
|
|
335
|
+
|
|
336
|
+
// Now the form is valid -- button should be enabled
|
|
337
|
+
await expect(submitButton).toBeEnabled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('async validator shows loading state', async ({ page }) => {
|
|
341
|
+
// Slow down the username availability check
|
|
342
|
+
await page.route('**/api/check-username*', async (route) => {
|
|
343
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
344
|
+
await route.fulfill({
|
|
345
|
+
status: 200,
|
|
346
|
+
contentType: 'application/json',
|
|
347
|
+
body: JSON.stringify({ available: true }),
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await page.getByLabel('Username').fill('janedoe');
|
|
352
|
+
await page.getByLabel('Username').blur();
|
|
353
|
+
|
|
354
|
+
// Async validator fires -- shows a loading indicator
|
|
355
|
+
await expect(page.getByTestId('username-checking')).toBeVisible();
|
|
356
|
+
|
|
357
|
+
// After the check completes
|
|
358
|
+
await expect(page.getByTestId('username-checking')).toBeHidden();
|
|
359
|
+
await expect(page.getByText('Username is available')).toBeVisible();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('form submission posts correct data', async ({ page }) => {
|
|
363
|
+
let submittedData: Record<string, unknown> = {};
|
|
364
|
+
await page.route('**/api/register', async (route) => {
|
|
365
|
+
submittedData = route.request().postDataJSON();
|
|
366
|
+
await route.fulfill({
|
|
367
|
+
status: 201,
|
|
368
|
+
contentType: 'application/json',
|
|
369
|
+
body: JSON.stringify({ id: 1 }),
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await page.getByLabel('Full name').fill('Jane Doe');
|
|
374
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
375
|
+
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
|
|
376
|
+
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
|
|
377
|
+
await page.getByLabel('I agree to the terms').check();
|
|
378
|
+
await page.getByRole('button', { name: 'Create account' }).click();
|
|
379
|
+
|
|
380
|
+
expect(submittedData).toMatchObject({
|
|
381
|
+
name: 'Jane Doe',
|
|
382
|
+
email: 'jane@example.com',
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
**JavaScript**
|
|
389
|
+
```javascript
|
|
390
|
+
const { test, expect } = require('@playwright/test');
|
|
391
|
+
|
|
392
|
+
test.describe('reactive forms', () => {
|
|
393
|
+
test.beforeEach(async ({ page }) => {
|
|
394
|
+
await page.goto('/register');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('shows validation errors for invalid inputs', async ({ page }) => {
|
|
398
|
+
const emailInput = page.getByLabel('Email');
|
|
399
|
+
await emailInput.click();
|
|
400
|
+
await emailInput.blur();
|
|
401
|
+
|
|
402
|
+
await expect(page.getByText('Email is required')).toBeVisible();
|
|
403
|
+
|
|
404
|
+
await emailInput.fill('not-an-email');
|
|
405
|
+
await emailInput.blur();
|
|
406
|
+
|
|
407
|
+
await expect(page.getByText('Invalid email format')).toBeVisible();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('cross-field validation (password match)', async ({ page }) => {
|
|
411
|
+
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
|
|
412
|
+
await page.getByLabel('Confirm password').fill('different-password');
|
|
413
|
+
await page.getByLabel('Confirm password').blur();
|
|
414
|
+
|
|
415
|
+
await expect(page.getByText('Passwords do not match')).toBeVisible();
|
|
416
|
+
|
|
417
|
+
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
|
|
418
|
+
await page.getByLabel('Confirm password').blur();
|
|
419
|
+
|
|
420
|
+
await expect(page.getByText('Passwords do not match')).toBeHidden();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('submit button disabled when form is invalid', async ({ page }) => {
|
|
424
|
+
const submitButton = page.getByRole('button', { name: 'Create account' });
|
|
425
|
+
|
|
426
|
+
await expect(submitButton).toBeDisabled();
|
|
427
|
+
|
|
428
|
+
await page.getByLabel('Full name').fill('Jane Doe');
|
|
429
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
430
|
+
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
|
|
431
|
+
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
|
|
432
|
+
await page.getByLabel('I agree to the terms').check();
|
|
433
|
+
|
|
434
|
+
await expect(submitButton).toBeEnabled();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Testing Angular Material Components
|
|
440
|
+
|
|
441
|
+
**Use when**: Testing apps using Angular Material (mat-button, mat-input, mat-select, mat-dialog, mat-table, etc.). Angular Material components use proper ARIA attributes, making them accessible to role-based locators.
|
|
442
|
+
**Avoid when**: Using CSS class selectors like `.mat-mdc-button` or `.mat-option` -- these change between Material versions.
|
|
443
|
+
|
|
444
|
+
**TypeScript**
|
|
445
|
+
```typescript
|
|
446
|
+
import { test, expect } from '@playwright/test';
|
|
447
|
+
|
|
448
|
+
test.describe('Angular Material components', () => {
|
|
449
|
+
test('mat-select dropdown', async ({ page }) => {
|
|
450
|
+
await page.goto('/settings');
|
|
451
|
+
|
|
452
|
+
// Angular Material select has role="combobox"
|
|
453
|
+
await page.getByRole('combobox', { name: 'Theme' }).click();
|
|
454
|
+
|
|
455
|
+
// Options appear in a CDK overlay (similar to a portal)
|
|
456
|
+
await page.getByRole('option', { name: 'Dark' }).click();
|
|
457
|
+
|
|
458
|
+
// Verify the selection
|
|
459
|
+
await expect(page.getByRole('combobox', { name: 'Theme' })).toContainText('Dark');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('mat-autocomplete with type-ahead', async ({ page }) => {
|
|
463
|
+
await page.goto('/users/new');
|
|
464
|
+
|
|
465
|
+
const roleInput = page.getByRole('combobox', { name: 'Role' });
|
|
466
|
+
await roleInput.fill('adm');
|
|
467
|
+
|
|
468
|
+
// Autocomplete suggestions appear in a CDK overlay
|
|
469
|
+
await expect(page.getByRole('option', { name: 'Admin' })).toBeVisible();
|
|
470
|
+
await expect(page.getByRole('option', { name: 'Administrator' })).toBeVisible();
|
|
471
|
+
|
|
472
|
+
await page.getByRole('option', { name: 'Admin' }).click();
|
|
473
|
+
await expect(roleInput).toHaveValue('Admin');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('mat-dialog opens and closes', async ({ page }) => {
|
|
477
|
+
await page.goto('/projects');
|
|
478
|
+
|
|
479
|
+
await page.getByRole('button', { name: 'Delete project' }).first().click();
|
|
480
|
+
|
|
481
|
+
// MatDialog renders as a CDK overlay with role="dialog"
|
|
482
|
+
const dialog = page.getByRole('dialog');
|
|
483
|
+
await expect(dialog).toBeVisible();
|
|
484
|
+
await expect(dialog.getByText('Are you sure?')).toBeVisible();
|
|
485
|
+
|
|
486
|
+
// Cancel
|
|
487
|
+
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
|
488
|
+
await expect(dialog).toBeHidden();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('mat-table sorting', async ({ page }) => {
|
|
492
|
+
await page.goto('/users');
|
|
493
|
+
|
|
494
|
+
// Click the column header to sort
|
|
495
|
+
await page.getByRole('columnheader', { name: 'Name' }).click();
|
|
496
|
+
|
|
497
|
+
// Verify sort indicator
|
|
498
|
+
const header = page.getByRole('columnheader', { name: 'Name' });
|
|
499
|
+
await expect(header).toHaveAttribute('aria-sort', 'ascending');
|
|
500
|
+
|
|
501
|
+
// Verify rows are sorted
|
|
502
|
+
const names = await page.getByRole('cell').filter({
|
|
503
|
+
has: page.locator('[data-column="name"]'),
|
|
504
|
+
}).allTextContents();
|
|
505
|
+
const sortedNames = [...names].sort();
|
|
506
|
+
expect(names).toEqual(sortedNames);
|
|
507
|
+
|
|
508
|
+
// Click again for descending
|
|
509
|
+
await page.getByRole('columnheader', { name: 'Name' }).click();
|
|
510
|
+
await expect(header).toHaveAttribute('aria-sort', 'descending');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('mat-paginator controls table pagination', async ({ page }) => {
|
|
514
|
+
await page.goto('/users');
|
|
515
|
+
|
|
516
|
+
await expect(page.getByText('1 - 10 of 50')).toBeVisible();
|
|
517
|
+
|
|
518
|
+
// Navigate to next page
|
|
519
|
+
await page.getByRole('button', { name: 'Next page' }).click();
|
|
520
|
+
await expect(page.getByText('11 - 20 of 50')).toBeVisible();
|
|
521
|
+
|
|
522
|
+
// Change page size
|
|
523
|
+
await page.getByRole('combobox', { name: 'Items per page' }).click();
|
|
524
|
+
await page.getByRole('option', { name: '25' }).click();
|
|
525
|
+
await expect(page.getByText('1 - 25 of 50')).toBeVisible();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('mat-snack-bar notification appears and dismisses', async ({ page }) => {
|
|
529
|
+
await page.goto('/settings');
|
|
530
|
+
|
|
531
|
+
await page.getByRole('button', { name: 'Save' }).click();
|
|
532
|
+
|
|
533
|
+
// Snackbar appears at the bottom of the screen
|
|
534
|
+
await expect(page.getByText('Settings saved successfully')).toBeVisible();
|
|
535
|
+
|
|
536
|
+
// Dismiss via action button
|
|
537
|
+
await page.getByRole('button', { name: 'Dismiss' }).click();
|
|
538
|
+
await expect(page.getByText('Settings saved successfully')).toBeHidden();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('mat-stepper wizard flow', async ({ page }) => {
|
|
542
|
+
await page.goto('/onboarding');
|
|
543
|
+
|
|
544
|
+
// Step 1: Personal info
|
|
545
|
+
await expect(page.getByText('Step 1 of 3')).toBeVisible();
|
|
546
|
+
await page.getByLabel('Full name').fill('Jane Doe');
|
|
547
|
+
await page.getByRole('button', { name: 'Next' }).click();
|
|
548
|
+
|
|
549
|
+
// Step 2: Company info
|
|
550
|
+
await expect(page.getByText('Step 2 of 3')).toBeVisible();
|
|
551
|
+
await page.getByLabel('Company').fill('Acme Corp');
|
|
552
|
+
await page.getByRole('button', { name: 'Next' }).click();
|
|
553
|
+
|
|
554
|
+
// Step 3: Review
|
|
555
|
+
await expect(page.getByText('Step 3 of 3')).toBeVisible();
|
|
556
|
+
await expect(page.getByText('Jane Doe')).toBeVisible();
|
|
557
|
+
await expect(page.getByText('Acme Corp')).toBeVisible();
|
|
558
|
+
|
|
559
|
+
// Go back to step 1
|
|
560
|
+
await page.getByRole('button', { name: 'Back' }).click();
|
|
561
|
+
await page.getByRole('button', { name: 'Back' }).click();
|
|
562
|
+
await expect(page.getByText('Step 1 of 3')).toBeVisible();
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
**JavaScript**
|
|
568
|
+
```javascript
|
|
569
|
+
const { test, expect } = require('@playwright/test');
|
|
570
|
+
|
|
571
|
+
test.describe('Angular Material components', () => {
|
|
572
|
+
test('mat-select dropdown', async ({ page }) => {
|
|
573
|
+
await page.goto('/settings');
|
|
574
|
+
|
|
575
|
+
await page.getByRole('combobox', { name: 'Theme' }).click();
|
|
576
|
+
await page.getByRole('option', { name: 'Dark' }).click();
|
|
577
|
+
|
|
578
|
+
await expect(page.getByRole('combobox', { name: 'Theme' })).toContainText('Dark');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test('mat-dialog opens and closes', async ({ page }) => {
|
|
582
|
+
await page.goto('/projects');
|
|
583
|
+
|
|
584
|
+
await page.getByRole('button', { name: 'Delete project' }).first().click();
|
|
585
|
+
|
|
586
|
+
const dialog = page.getByRole('dialog');
|
|
587
|
+
await expect(dialog).toBeVisible();
|
|
588
|
+
await expect(dialog.getByText('Are you sure?')).toBeVisible();
|
|
589
|
+
|
|
590
|
+
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
|
591
|
+
await expect(dialog).toBeHidden();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('mat-table sorting', async ({ page }) => {
|
|
595
|
+
await page.goto('/users');
|
|
596
|
+
|
|
597
|
+
await page.getByRole('columnheader', { name: 'Name' }).click();
|
|
598
|
+
|
|
599
|
+
const header = page.getByRole('columnheader', { name: 'Name' });
|
|
600
|
+
await expect(header).toHaveAttribute('aria-sort', 'ascending');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('mat-snack-bar notification appears and dismisses', async ({ page }) => {
|
|
604
|
+
await page.goto('/settings');
|
|
605
|
+
|
|
606
|
+
await page.getByRole('button', { name: 'Save' }).click();
|
|
607
|
+
await expect(page.getByText('Settings saved successfully')).toBeVisible();
|
|
608
|
+
|
|
609
|
+
await page.getByRole('button', { name: 'Dismiss' }).click();
|
|
610
|
+
await expect(page.getByText('Settings saved successfully')).toBeHidden();
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### Testing Angular Router Navigation
|
|
616
|
+
|
|
617
|
+
**Use when**: Testing Angular Router navigation, lazy-loaded routes, route guards, and URL parameter handling.
|
|
618
|
+
**Avoid when**: Testing router configuration in isolation -- use Angular TestBed for that.
|
|
619
|
+
|
|
620
|
+
**TypeScript**
|
|
621
|
+
```typescript
|
|
622
|
+
import { test, expect } from '@playwright/test';
|
|
623
|
+
|
|
624
|
+
test.describe('Angular Router navigation', () => {
|
|
625
|
+
test('lazy-loaded module loads on navigation', async ({ page }) => {
|
|
626
|
+
await page.goto('/');
|
|
627
|
+
|
|
628
|
+
// Navigate to a lazy-loaded route
|
|
629
|
+
await page.getByRole('link', { name: 'Admin' }).click();
|
|
630
|
+
await page.waitForURL('/admin');
|
|
631
|
+
|
|
632
|
+
// The lazy module loads and renders its component
|
|
633
|
+
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test('route guard redirects unauthorized users', async ({ page }) => {
|
|
637
|
+
// Visit a route protected by AuthGuard (canActivate)
|
|
638
|
+
await page.goto('/admin/users');
|
|
639
|
+
|
|
640
|
+
// Guard should redirect to login
|
|
641
|
+
await expect(page).toHaveURL(/\/login/);
|
|
642
|
+
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test('route resolver prefetches data before navigation', async ({ page }) => {
|
|
646
|
+
// Intercept the API call that the resolver makes
|
|
647
|
+
const resolverPromise = page.waitForResponse('**/api/products/*');
|
|
648
|
+
|
|
649
|
+
await page.goto('/products/42');
|
|
650
|
+
|
|
651
|
+
// The resolver fetches data before the component renders
|
|
652
|
+
await resolverPromise;
|
|
653
|
+
|
|
654
|
+
// Component renders with pre-fetched data (no loading spinner)
|
|
655
|
+
await expect(page.getByRole('heading', { level: 1 })).toContainText('Product');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('nested router-outlet renders child components', async ({ page }) => {
|
|
659
|
+
await page.goto('/settings/profile');
|
|
660
|
+
|
|
661
|
+
// Parent layout (SettingsComponent with its own router-outlet)
|
|
662
|
+
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
|
663
|
+
await expect(page.getByRole('navigation', { name: 'Settings' })).toBeVisible();
|
|
664
|
+
|
|
665
|
+
// Child route (ProfileComponent rendered inside nested router-outlet)
|
|
666
|
+
await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();
|
|
667
|
+
|
|
668
|
+
// Navigate to sibling child route
|
|
669
|
+
await page.getByRole('link', { name: 'Security' }).click();
|
|
670
|
+
await page.waitForURL('/settings/security');
|
|
671
|
+
|
|
672
|
+
// Parent persists, child changes
|
|
673
|
+
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
|
674
|
+
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('route parameters update component state', async ({ page }) => {
|
|
678
|
+
await page.goto('/users/1');
|
|
679
|
+
await expect(page.getByRole('heading')).toContainText('User #1');
|
|
680
|
+
|
|
681
|
+
// Navigate to a different user via the URL
|
|
682
|
+
await page.goto('/users/2');
|
|
683
|
+
await expect(page.getByRole('heading')).toContainText('User #2');
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('query parameters drive filter behavior', async ({ page }) => {
|
|
687
|
+
await page.goto('/products?category=electronics&page=2');
|
|
688
|
+
|
|
689
|
+
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
|
|
690
|
+
await expect(page.getByText('Page 2')).toBeVisible();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test('browser back navigates through Angular history', async ({ page }) => {
|
|
694
|
+
await page.goto('/');
|
|
695
|
+
await page.getByRole('link', { name: 'Products' }).click();
|
|
696
|
+
await page.waitForURL('/products');
|
|
697
|
+
await page.getByRole('link', { name: 'About' }).click();
|
|
698
|
+
await page.waitForURL('/about');
|
|
699
|
+
|
|
700
|
+
await page.goBack();
|
|
701
|
+
await expect(page).toHaveURL(/\/products/);
|
|
702
|
+
|
|
703
|
+
await page.goBack();
|
|
704
|
+
await expect(page).toHaveURL(/\/$/);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
**JavaScript**
|
|
710
|
+
```javascript
|
|
711
|
+
const { test, expect } = require('@playwright/test');
|
|
712
|
+
|
|
713
|
+
test.describe('Angular Router navigation', () => {
|
|
714
|
+
test('lazy-loaded module loads on navigation', async ({ page }) => {
|
|
715
|
+
await page.goto('/');
|
|
716
|
+
|
|
717
|
+
await page.getByRole('link', { name: 'Admin' }).click();
|
|
718
|
+
await page.waitForURL('/admin');
|
|
719
|
+
|
|
720
|
+
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('route guard redirects unauthorized users', async ({ page }) => {
|
|
724
|
+
await page.goto('/admin/users');
|
|
725
|
+
|
|
726
|
+
await expect(page).toHaveURL(/\/login/);
|
|
727
|
+
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test('nested router-outlet renders child components', async ({ page }) => {
|
|
731
|
+
await page.goto('/settings/profile');
|
|
732
|
+
|
|
733
|
+
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
|
734
|
+
await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();
|
|
735
|
+
|
|
736
|
+
await page.getByRole('link', { name: 'Security' }).click();
|
|
737
|
+
await page.waitForURL('/settings/security');
|
|
738
|
+
|
|
739
|
+
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
|
740
|
+
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test('browser back navigates through Angular history', async ({ page }) => {
|
|
744
|
+
await page.goto('/');
|
|
745
|
+
await page.getByRole('link', { name: 'Products' }).click();
|
|
746
|
+
await page.waitForURL('/products');
|
|
747
|
+
await page.getByRole('link', { name: 'About' }).click();
|
|
748
|
+
await page.waitForURL('/about');
|
|
749
|
+
|
|
750
|
+
await page.goBack();
|
|
751
|
+
await expect(page).toHaveURL(/\/products/);
|
|
752
|
+
|
|
753
|
+
await page.goBack();
|
|
754
|
+
await expect(page).toHaveURL(/\/$/);
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
### Testing Lazy-Loaded Modules
|
|
760
|
+
|
|
761
|
+
**Use when**: Verifying that Angular lazy-loaded feature modules load correctly when the user navigates to their routes. Lazy-loaded modules introduce network requests for JavaScript chunks.
|
|
762
|
+
**Avoid when**: The module is eagerly loaded -- no separate chunk to load.
|
|
763
|
+
|
|
764
|
+
**TypeScript**
|
|
765
|
+
```typescript
|
|
766
|
+
import { test, expect } from '@playwright/test';
|
|
767
|
+
|
|
768
|
+
test.describe('lazy-loaded modules', () => {
|
|
769
|
+
test('lazy module loads without errors', async ({ page }) => {
|
|
770
|
+
const consoleErrors: string[] = [];
|
|
771
|
+
page.on('console', (msg) => {
|
|
772
|
+
if (msg.type() === 'error') {
|
|
773
|
+
consoleErrors.push(msg.text());
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
await page.goto('/');
|
|
778
|
+
|
|
779
|
+
// Navigate to a lazy-loaded route
|
|
780
|
+
const chunkRequest = page.waitForResponse((response) =>
|
|
781
|
+
response.url().includes('.js') && response.status() === 200
|
|
782
|
+
);
|
|
783
|
+
await page.getByRole('link', { name: 'Reports' }).click();
|
|
784
|
+
await chunkRequest;
|
|
785
|
+
|
|
786
|
+
await page.waitForURL('/reports');
|
|
787
|
+
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
|
|
788
|
+
|
|
789
|
+
// No chunk loading errors
|
|
790
|
+
const chunkErrors = consoleErrors.filter(
|
|
791
|
+
(e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
|
|
792
|
+
);
|
|
793
|
+
expect(chunkErrors).toEqual([]);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('preloaded lazy module navigates instantly', async ({ page }) => {
|
|
797
|
+
await page.goto('/dashboard');
|
|
798
|
+
|
|
799
|
+
// If preloadingStrategy is configured, the module may already be cached
|
|
800
|
+
// Navigate and verify it renders without visible delay
|
|
801
|
+
const startTime = Date.now();
|
|
802
|
+
await page.getByRole('link', { name: 'Reports' }).click();
|
|
803
|
+
await page.waitForURL('/reports');
|
|
804
|
+
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
|
|
805
|
+
const loadTime = Date.now() - startTime;
|
|
806
|
+
|
|
807
|
+
// Preloaded modules should render quickly (not an exact assertion, but a sanity check)
|
|
808
|
+
expect(loadTime).toBeLessThan(3000);
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
**JavaScript**
|
|
814
|
+
```javascript
|
|
815
|
+
const { test, expect } = require('@playwright/test');
|
|
816
|
+
|
|
817
|
+
test.describe('lazy-loaded modules', () => {
|
|
818
|
+
test('lazy module loads without errors', async ({ page }) => {
|
|
819
|
+
const consoleErrors = [];
|
|
820
|
+
page.on('console', (msg) => {
|
|
821
|
+
if (msg.type() === 'error') {
|
|
822
|
+
consoleErrors.push(msg.text());
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
await page.goto('/');
|
|
827
|
+
|
|
828
|
+
await page.getByRole('link', { name: 'Reports' }).click();
|
|
829
|
+
await page.waitForURL('/reports');
|
|
830
|
+
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
|
|
831
|
+
|
|
832
|
+
const chunkErrors = consoleErrors.filter(
|
|
833
|
+
(e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
|
|
834
|
+
);
|
|
835
|
+
expect(chunkErrors).toEqual([]);
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
### Testing Signals and Observables Indirectly
|
|
841
|
+
|
|
842
|
+
**Use when**: Verifying that Angular signals (`signal()`, `computed()`, `effect()`) and RxJS observables produce correct UI updates. Playwright cannot subscribe to observables or read signals directly -- test through the rendered output.
|
|
843
|
+
**Avoid when**: Testing observable transformation logic in isolation -- use Jasmine/Jest with Angular TestBed for that.
|
|
844
|
+
|
|
845
|
+
**TypeScript**
|
|
846
|
+
```typescript
|
|
847
|
+
import { test, expect } from '@playwright/test';
|
|
848
|
+
|
|
849
|
+
test.describe('signals (tested through UI)', () => {
|
|
850
|
+
test('signal-based counter updates the DOM', async ({ page }) => {
|
|
851
|
+
await page.goto('/counter');
|
|
852
|
+
|
|
853
|
+
// The counter uses signal() internally
|
|
854
|
+
await expect(page.getByTestId('count')).toHaveText('0');
|
|
855
|
+
|
|
856
|
+
await page.getByRole('button', { name: 'Increment' }).click();
|
|
857
|
+
await expect(page.getByTestId('count')).toHaveText('1');
|
|
858
|
+
|
|
859
|
+
await page.getByRole('button', { name: 'Increment' }).click();
|
|
860
|
+
await page.getByRole('button', { name: 'Increment' }).click();
|
|
861
|
+
await expect(page.getByTestId('count')).toHaveText('3');
|
|
862
|
+
|
|
863
|
+
await page.getByRole('button', { name: 'Reset' }).click();
|
|
864
|
+
await expect(page.getByTestId('count')).toHaveText('0');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test('computed signal updates derived values', async ({ page }) => {
|
|
868
|
+
await page.goto('/cart');
|
|
869
|
+
|
|
870
|
+
// Cart total is a computed() signal derived from items
|
|
871
|
+
await expect(page.getByTestId('cart-total')).toHaveText('$0.00');
|
|
872
|
+
|
|
873
|
+
// Add item (updates the items signal, which updates the computed total)
|
|
874
|
+
await page.goto('/products');
|
|
875
|
+
await page.getByRole('listitem')
|
|
876
|
+
.filter({ hasText: '$29.99' })
|
|
877
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
878
|
+
.click();
|
|
879
|
+
|
|
880
|
+
await page.getByRole('link', { name: 'Cart' }).click();
|
|
881
|
+
await expect(page.getByTestId('cart-total')).toHaveText('$29.99');
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test.describe('observables (tested through UI)', () => {
|
|
886
|
+
test('real-time data stream updates the UI', async ({ page }) => {
|
|
887
|
+
await page.goto('/dashboard');
|
|
888
|
+
|
|
889
|
+
// The component subscribes to an observable that emits stock prices
|
|
890
|
+
const priceElement = page.getByTestId('stock-price');
|
|
891
|
+
await expect(priceElement).toBeVisible();
|
|
892
|
+
|
|
893
|
+
// Get the initial value
|
|
894
|
+
const initialPrice = await priceElement.textContent();
|
|
895
|
+
|
|
896
|
+
// Wait for the observable to emit a new value
|
|
897
|
+
// Use polling assertion instead of waitForTimeout
|
|
898
|
+
await expect(priceElement).not.toHaveText(initialPrice!, { timeout: 10_000 });
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test('search with debounceTime observable', async ({ page }) => {
|
|
902
|
+
await page.goto('/search');
|
|
903
|
+
|
|
904
|
+
const apiCalls: string[] = [];
|
|
905
|
+
await page.route('**/api/search*', async (route) => {
|
|
906
|
+
apiCalls.push(route.request().url());
|
|
907
|
+
await route.continue();
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
// Type quickly -- the observable's debounceTime should batch
|
|
911
|
+
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('angular', {
|
|
912
|
+
delay: 50,
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
await expect(page.getByRole('listitem')).toHaveCount(5);
|
|
916
|
+
|
|
917
|
+
// debounceTime should prevent a request per keystroke
|
|
918
|
+
expect(apiCalls.length).toBeLessThanOrEqual(2);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test('switchMap cancels previous requests on new input', async ({ page }) => {
|
|
922
|
+
await page.goto('/search');
|
|
923
|
+
|
|
924
|
+
// Type one query
|
|
925
|
+
await page.getByRole('textbox', { name: 'Search' }).fill('first query');
|
|
926
|
+
|
|
927
|
+
// Immediately type a different query before results come back
|
|
928
|
+
await page.getByRole('textbox', { name: 'Search' }).fill('second query');
|
|
929
|
+
|
|
930
|
+
// Results should match the second query, not the first
|
|
931
|
+
await expect(page.getByRole('listitem').first()).toContainText(/second query/i);
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
**JavaScript**
|
|
937
|
+
```javascript
|
|
938
|
+
const { test, expect } = require('@playwright/test');
|
|
939
|
+
|
|
940
|
+
test.describe('signals (tested through UI)', () => {
|
|
941
|
+
test('signal-based counter updates the DOM', async ({ page }) => {
|
|
942
|
+
await page.goto('/counter');
|
|
943
|
+
|
|
944
|
+
await expect(page.getByTestId('count')).toHaveText('0');
|
|
945
|
+
|
|
946
|
+
await page.getByRole('button', { name: 'Increment' }).click();
|
|
947
|
+
await expect(page.getByTestId('count')).toHaveText('1');
|
|
948
|
+
|
|
949
|
+
await page.getByRole('button', { name: 'Increment' }).click();
|
|
950
|
+
await page.getByRole('button', { name: 'Increment' }).click();
|
|
951
|
+
await expect(page.getByTestId('count')).toHaveText('3');
|
|
952
|
+
|
|
953
|
+
await page.getByRole('button', { name: 'Reset' }).click();
|
|
954
|
+
await expect(page.getByTestId('count')).toHaveText('0');
|
|
955
|
+
});
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test.describe('observables (tested through UI)', () => {
|
|
959
|
+
test('search with debounceTime observable', async ({ page }) => {
|
|
960
|
+
await page.goto('/search');
|
|
961
|
+
|
|
962
|
+
const apiCalls = [];
|
|
963
|
+
await page.route('**/api/search*', async (route) => {
|
|
964
|
+
apiCalls.push(route.request().url());
|
|
965
|
+
await route.continue();
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('angular', {
|
|
969
|
+
delay: 50,
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
await expect(page.getByRole('listitem')).toHaveCount(5);
|
|
973
|
+
expect(apiCalls.length).toBeLessThanOrEqual(2);
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
## Framework-Specific Tips
|
|
979
|
+
|
|
980
|
+
### Zone.js Considerations
|
|
981
|
+
|
|
982
|
+
Angular uses Zone.js to detect async operations and trigger change detection. Playwright does not depend on Zone.js -- it interacts with the DOM directly. However, Zone.js can affect test behavior:
|
|
983
|
+
|
|
984
|
+
1. **Change detection timing**: After user interactions (click, fill), Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this -- `expect(locator).toHaveText('new value')` retries until the DOM updates.
|
|
985
|
+
|
|
986
|
+
2. **Zoneless Angular (experimental)**: Angular 17+ supports zoneless change detection. Tests work identically with Playwright because Playwright waits for DOM changes, not Zone.js ticks.
|
|
987
|
+
|
|
988
|
+
3. **Long-running async operations**: If your app has `setInterval` or long-running observables, Zone.js keeps Angular in a "not stable" state. This does not affect Playwright (unlike Protractor, which waited for Angular stability). Playwright simply interacts with whatever is on the screen.
|
|
989
|
+
|
|
990
|
+
### Protractor to Playwright Migration Checklist
|
|
991
|
+
|
|
992
|
+
| Protractor | Playwright Equivalent |
|
|
993
|
+
|---|---|
|
|
994
|
+
| `element(by.css('.btn'))` | `page.locator('.btn')` -- but prefer `page.getByRole('button', { name: '...' })` |
|
|
995
|
+
| `element(by.id('login'))` | `page.getByTestId('login')` or `page.getByRole(...)` |
|
|
996
|
+
| `element(by.buttonText('Submit'))` | `page.getByRole('button', { name: 'Submit' })` |
|
|
997
|
+
| `element(by.model('user.name'))` | `page.getByLabel('Name')` -- Playwright cannot read ng-model |
|
|
998
|
+
| `element(by.binding('user.name'))` | `page.getByText(expectedValue)` -- test the rendered output |
|
|
999
|
+
| `element(by.repeater('item in items'))` | `page.getByRole('listitem')` or `page.getByTestId(...)` |
|
|
1000
|
+
| `browser.waitForAngular()` | Not needed -- Playwright auto-waits; remove all instances |
|
|
1001
|
+
| `browser.sleep(3000)` | `await expect(locator).toBeVisible()` -- never use arbitrary waits |
|
|
1002
|
+
| `browser.get('/path')` | `await page.goto('/path')` |
|
|
1003
|
+
| `protractor.ExpectedConditions` | `await expect(locator).toBeVisible/toBeHidden/toHaveText(...)` |
|
|
1004
|
+
|
|
1005
|
+
### Angular Build Configurations
|
|
1006
|
+
|
|
1007
|
+
| Scenario | Build Command | Notes |
|
|
1008
|
+
|---|---|---|
|
|
1009
|
+
| Local development | `npx ng serve` | Fast rebuild, source maps, no optimization |
|
|
1010
|
+
| CI (production build) | `npx ng build && npx http-server dist/your-app/browser -p 4200 -s` | Tests the real production bundle |
|
|
1011
|
+
| CI (SSR/Universal) | `npx ng build --ssr && node dist/your-app/server/server.mjs` | Tests server-side rendered Angular |
|
|
1012
|
+
| Staging environment | No `webServer` needed | Point `baseURL` to the staging URL |
|
|
1013
|
+
|
|
1014
|
+
The `-s` flag on `http-server` enables SPA fallback (sends `index.html` for all routes), which is essential for Angular Router to work correctly.
|
|
1015
|
+
|
|
1016
|
+
### CDK Overlay Container
|
|
1017
|
+
|
|
1018
|
+
Angular Material and Angular CDK render overlays (dialogs, menus, selects, autocompletes) in a special container outside the component tree. Playwright sees these overlays in the document -- no special handling is needed. Use standard role-based locators:
|
|
1019
|
+
|
|
1020
|
+
```typescript
|
|
1021
|
+
// CDK overlays render into <div class="cdk-overlay-container"> at the body level
|
|
1022
|
+
// Playwright sees them as regular DOM elements
|
|
1023
|
+
const dialog = page.getByRole('dialog');
|
|
1024
|
+
const menu = page.getByRole('menu');
|
|
1025
|
+
const listbox = page.getByRole('listbox');
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
### Testing with Angular SSR (Universal)
|
|
1029
|
+
|
|
1030
|
+
If your Angular app uses server-side rendering:
|
|
1031
|
+
|
|
1032
|
+
```typescript
|
|
1033
|
+
// playwright.config.ts (SSR-specific)
|
|
1034
|
+
webServer: {
|
|
1035
|
+
command: process.env.CI
|
|
1036
|
+
? 'npx ng build --ssr && node dist/your-app/server/server.mjs'
|
|
1037
|
+
: 'npx ng serve --ssr',
|
|
1038
|
+
url: 'http://localhost:4200',
|
|
1039
|
+
reuseExistingServer: !process.env.CI,
|
|
1040
|
+
timeout: 180_000, // SSR builds are slower
|
|
1041
|
+
},
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
Test for hydration issues the same way as with other SSR frameworks:
|
|
1045
|
+
|
|
1046
|
+
```typescript
|
|
1047
|
+
test('no hydration errors after SSR', async ({ page }) => {
|
|
1048
|
+
const errors: string[] = [];
|
|
1049
|
+
page.on('console', (msg) => {
|
|
1050
|
+
if (msg.type() === 'error' && msg.text().includes('hydration')) {
|
|
1051
|
+
errors.push(msg.text());
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
await page.goto('/');
|
|
1056
|
+
await page.getByRole('button', { name: 'Get started' }).click();
|
|
1057
|
+
|
|
1058
|
+
expect(errors).toEqual([]);
|
|
1059
|
+
});
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
## Anti-Patterns
|
|
1063
|
+
|
|
1064
|
+
| Don't Do This | Problem | Do This Instead |
|
|
1065
|
+
|---|---|---|
|
|
1066
|
+
| `page.locator('[_ngcontent-abc123]')` | Angular scoped style attributes are random and change every build | Use `getByRole`, `getByLabel`, `getByText`, `getByTestId` |
|
|
1067
|
+
| `page.locator('[ng-reflect-model="value"]')` | `ng-reflect-*` attributes only exist in dev mode; stripped in production | Test the rendered value: `expect(input).toHaveValue('value')` |
|
|
1068
|
+
| `page.locator('app-my-component')` | Angular component selectors are implementation details | Target the content the component renders using semantic locators |
|
|
1069
|
+
| `page.locator('.mat-mdc-button')` | Angular Material class names change between versions (MDC migration) | `page.getByRole('button', { name: 'Submit' })` |
|
|
1070
|
+
| `page.evaluate(() => (window as any).ng)` to access Angular internals | Depends on debug mode; not available in production builds | Test through the DOM; never access the Angular runtime |
|
|
1071
|
+
| `await page.waitForTimeout(500)` after clicking a button | Zone.js change detection timing varies; arbitrary waits are fragile | `await expect(locator).toHaveText('expected value')` auto-retries |
|
|
1072
|
+
| `browser.waitForAngular()` (Protractor pattern) | Does not exist in Playwright; not needed -- Playwright auto-waits | Remove entirely; use web-first assertions |
|
|
1073
|
+
| Test Angular services by injecting them via `page.evaluate` | Services are not accessible from the browser console in production | Test services indirectly through the UI they power; unit test with TestBed |
|
|
1074
|
+
| Use `ng serve` in CI | Development server is slower, includes debug code, may hide production-only bugs | Use `ng build && http-server` in CI |
|
|
1075
|
+
| Skip testing CDK overlay components (dialogs, selects, menus) | These are the most interactive parts of the app; bugs here are highly visible | Test overlays with role-based locators; they render in the regular DOM |
|
|
1076
|
+
|
|
1077
|
+
## Related
|
|
1078
|
+
|
|
1079
|
+
- [core/locators.md](locators.md) -- locator strategies for Angular Material and CDK components
|
|
1080
|
+
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- auto-waiting assertions that replace Protractor's waitForAngular
|
|
1081
|
+
- [core/forms-and-validation.md](forms-and-validation.md) -- form testing patterns for reactive and template-driven forms
|
|
1082
|
+
- [core/accessibility.md](accessibility.md) -- accessibility testing for Angular Material components
|
|
1083
|
+
- [core/authentication.md](authentication.md) -- authentication with Angular route guards
|
|
1084
|
+
- [migration/from-selenium.md](../migration/from-selenium.md) -- migration patterns applicable to Protractor (Protractor is built on Selenium)
|
|
1085
|
+
- [core/test-architecture.md](test-architecture.md) -- when to use E2E vs unit tests with Angular TestBed
|
|
1086
|
+
- [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI setup with Angular build caching
|