@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.
Files changed (89) hide show
  1. package/README.md +293 -0
  2. package/extensions/dino.ts +70 -0
  3. package/extensions/mabl-mcp.ts +306 -0
  4. package/extensions/playwright-mcp.ts +142 -0
  5. package/package.json +47 -0
  6. package/prompts/create-pr.md +110 -0
  7. package/prompts/generate-bdd-from-url.md +65 -0
  8. package/prompts/generate-pom-from-url-cli.md +48 -0
  9. package/prompts/generate-pom-from-url.md +45 -0
  10. package/prompts/generate-spec-from-feature.md +64 -0
  11. package/prompts/heal-playwright-failures.md +106 -0
  12. package/prompts/setup-playwright.md +81 -0
  13. package/scripts/start-dino.cmd +8 -0
  14. package/scripts/start-dino.mjs +226 -0
  15. package/scripts/start-dino.ps1 +9 -0
  16. package/scripts/start-dino.sh +10 -0
  17. package/skills/acli/SKILL.md +292 -0
  18. package/skills/bdd-gherkin/SKILL.md +552 -0
  19. package/skills/build-review-interface/SKILL.md +96 -0
  20. package/skills/error-analysis/SKILL.md +164 -0
  21. package/skills/eval-audit/SKILL.md +183 -0
  22. package/skills/evaluate-rag/SKILL.md +177 -0
  23. package/skills/generate-synthetic-data/SKILL.md +131 -0
  24. package/skills/mabl-mcp/SKILL.md +79 -0
  25. package/skills/playwright-cli/SKILL.md +246 -0
  26. package/skills/playwright-cli/advanced-workflows.md +679 -0
  27. package/skills/playwright-cli/core-commands.md +375 -0
  28. package/skills/playwright-cli/device-emulation.md +475 -0
  29. package/skills/playwright-cli/request-mocking.md +373 -0
  30. package/skills/playwright-cli/running-custom-code.md +553 -0
  31. package/skills/playwright-cli/screenshots-and-media.md +457 -0
  32. package/skills/playwright-cli/session-management.md +425 -0
  33. package/skills/playwright-cli/storage-and-auth.md +487 -0
  34. package/skills/playwright-cli/test-generation.md +336 -0
  35. package/skills/playwright-cli/tracing-and-debugging.md +501 -0
  36. package/skills/playwright-core/SKILL.md +101 -0
  37. package/skills/playwright-core/accessibility.md +1517 -0
  38. package/skills/playwright-core/angular.md +1086 -0
  39. package/skills/playwright-core/api-testing.md +1623 -0
  40. package/skills/playwright-core/assertions-and-waiting.md +703 -0
  41. package/skills/playwright-core/auth-flows.md +1081 -0
  42. package/skills/playwright-core/authentication.md +1409 -0
  43. package/skills/playwright-core/browser-apis.md +673 -0
  44. package/skills/playwright-core/browser-extensions.md +319 -0
  45. package/skills/playwright-core/canvas-and-webgl.md +494 -0
  46. package/skills/playwright-core/clock-and-time-mocking.md +427 -0
  47. package/skills/playwright-core/common-pitfalls.md +1318 -0
  48. package/skills/playwright-core/component-testing.md +1179 -0
  49. package/skills/playwright-core/configuration.md +731 -0
  50. package/skills/playwright-core/crud-testing.md +945 -0
  51. package/skills/playwright-core/debugging.md +827 -0
  52. package/skills/playwright-core/drag-and-drop.md +992 -0
  53. package/skills/playwright-core/electron-testing.md +622 -0
  54. package/skills/playwright-core/error-and-edge-cases.md +1181 -0
  55. package/skills/playwright-core/error-index.md +1903 -0
  56. package/skills/playwright-core/file-operations.md +749 -0
  57. package/skills/playwright-core/file-upload-download.md +1012 -0
  58. package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
  59. package/skills/playwright-core/flaky-tests.md +883 -0
  60. package/skills/playwright-core/forms-and-validation.md +1056 -0
  61. package/skills/playwright-core/i18n-and-localization.md +622 -0
  62. package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
  63. package/skills/playwright-core/locator-strategy.md +588 -0
  64. package/skills/playwright-core/locators.md +743 -0
  65. package/skills/playwright-core/mobile-and-responsive.md +1669 -0
  66. package/skills/playwright-core/multi-context-and-popups.md +581 -0
  67. package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
  68. package/skills/playwright-core/network-mocking.md +1395 -0
  69. package/skills/playwright-core/nextjs.md +1013 -0
  70. package/skills/playwright-core/performance-testing.md +608 -0
  71. package/skills/playwright-core/react.md +1082 -0
  72. package/skills/playwright-core/search-and-filter.md +1366 -0
  73. package/skills/playwright-core/security-testing.md +624 -0
  74. package/skills/playwright-core/service-workers-and-pwa.md +524 -0
  75. package/skills/playwright-core/test-architecture.md +569 -0
  76. package/skills/playwright-core/test-data-management.md +1209 -0
  77. package/skills/playwright-core/test-organization.md +947 -0
  78. package/skills/playwright-core/third-party-integrations.md +754 -0
  79. package/skills/playwright-core/trace-analysis.md +161 -0
  80. package/skills/playwright-core/visual-regression.md +1027 -0
  81. package/skills/playwright-core/vue.md +1162 -0
  82. package/skills/playwright-core/websockets-and-realtime.md +623 -0
  83. package/skills/playwright-core/when-to-mock.md +827 -0
  84. package/skills/playwright-pom/SKILL.md +94 -0
  85. package/skills/playwright-pom/page-object-model.md +921 -0
  86. package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
  87. package/skills/validate-evaluator/SKILL.md +215 -0
  88. package/skills/write-judge-prompt/SKILL.md +144 -0
  89. 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