@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,1409 @@
1
+ # Authentication Testing
2
+
3
+ > **When to use**: Any app that has login, session management, or protected routes. Authentication is the most common source of slow test suites -- get this right and your entire suite speeds up.
4
+ > **Prerequisites**: [core/configuration.md](configuration.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md)
5
+
6
+ ## Quick Reference
7
+
8
+ ```typescript
9
+ // Storage state reuse — the #1 pattern for fast auth
10
+ await page.goto('/login');
11
+ await page.getByLabel('Email').fill('user@test.com');
12
+ await page.getByLabel('Password').fill('password');
13
+ await page.getByRole('button', { name: 'Sign in' }).click();
14
+ await page.context().storageState({ path: '.auth/user.json' });
15
+
16
+ // Reuse in config — every test starts authenticated, zero login overhead
17
+ { use: { storageState: '.auth/user.json' } }
18
+
19
+ // API login — skip the UI entirely
20
+ const context = await browser.newContext();
21
+ const response = await context.request.post('/api/auth/login', {
22
+ data: { email: 'user@test.com', password: 'password' },
23
+ });
24
+ await context.storageState({ path: '.auth/user.json' });
25
+ ```
26
+
27
+ ## Patterns
28
+
29
+ ### Storage State Reuse
30
+
31
+ **Use when**: You need authenticated tests and want to avoid logging in before every test. This is the default pattern for nearly every project.
32
+ **Avoid when**: Tests require completely fresh sessions with no prior state, or you are testing the login flow itself.
33
+
34
+ Playwright's `storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.
35
+
36
+ **TypeScript**
37
+ ```typescript
38
+ // scripts/save-auth-state.ts — run once to generate the state file
39
+ import { chromium } from '@playwright/test';
40
+
41
+ async function saveAuthState() {
42
+ const browser = await chromium.launch();
43
+ const context = await browser.newContext();
44
+ const page = await context.newPage();
45
+
46
+ await page.goto('http://localhost:3000/login');
47
+ await page.getByLabel('Email').fill('user@test.com');
48
+ await page.getByLabel('Password').fill('s3cure!Pass');
49
+ await page.getByRole('button', { name: 'Sign in' }).click();
50
+ await page.waitForURL('/dashboard');
51
+
52
+ // Save cookies + localStorage to a file
53
+ await context.storageState({ path: '.auth/user.json' });
54
+ await browser.close();
55
+ }
56
+
57
+ saveAuthState();
58
+ ```
59
+
60
+ ```typescript
61
+ // playwright.config.ts — load saved state for all tests
62
+ import { defineConfig } from '@playwright/test';
63
+
64
+ export default defineConfig({
65
+ use: {
66
+ baseURL: 'http://localhost:3000',
67
+ storageState: '.auth/user.json',
68
+ },
69
+ });
70
+ ```
71
+
72
+ ```typescript
73
+ // tests/dashboard.spec.ts — test starts already logged in
74
+ import { test, expect } from '@playwright/test';
75
+
76
+ test('authenticated user sees dashboard', async ({ page }) => {
77
+ await page.goto('/dashboard');
78
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
79
+ // No login step needed — storageState handles it
80
+ });
81
+ ```
82
+
83
+ **JavaScript**
84
+ ```javascript
85
+ // scripts/save-auth-state.js
86
+ const { chromium } = require('@playwright/test');
87
+
88
+ async function saveAuthState() {
89
+ const browser = await chromium.launch();
90
+ const context = await browser.newContext();
91
+ const page = await context.newPage();
92
+
93
+ await page.goto('http://localhost:3000/login');
94
+ await page.getByLabel('Email').fill('user@test.com');
95
+ await page.getByLabel('Password').fill('s3cure!Pass');
96
+ await page.getByRole('button', { name: 'Sign in' }).click();
97
+ await page.waitForURL('/dashboard');
98
+
99
+ await context.storageState({ path: '.auth/user.json' });
100
+ await browser.close();
101
+ }
102
+
103
+ saveAuthState();
104
+ ```
105
+
106
+ ```javascript
107
+ // playwright.config.js
108
+ const { defineConfig } = require('@playwright/test');
109
+
110
+ module.exports = defineConfig({
111
+ use: {
112
+ baseURL: 'http://localhost:3000',
113
+ storageState: '.auth/user.json',
114
+ },
115
+ });
116
+ ```
117
+
118
+ ### In-Place State Refresh With `setStorageState()` (Playwright 1.59+)
119
+
120
+ **Use when**: You already have a browser context open and want to replace its cookies and local storage without destroying the context and creating a new one.
121
+ **Avoid when**: Starting a brand-new isolated context is simpler, or when the test itself is validating the login flow from scratch.
122
+
123
+ Playwright 1.59 added `browserContext.setStorageState()`, which clears the current cookies, local storage, and IndexedDB state and applies a new storage snapshot in-place.
124
+
125
+ **TypeScript**
126
+ ```typescript
127
+ import { test, expect } from '@playwright/test';
128
+
129
+ test('swap to a different authenticated state without recreating the context', async ({ page, context }) => {
130
+ await page.goto('/dashboard');
131
+
132
+ await context.setStorageState('.auth/admin.json');
133
+ await page.reload();
134
+
135
+ await expect(page.getByRole('heading', { name: 'Admin dashboard' })).toBeVisible();
136
+ });
137
+ ```
138
+
139
+ **JavaScript**
140
+ ```javascript
141
+ const { test, expect } = require('@playwright/test');
142
+
143
+ test('swap to a different authenticated state without recreating the context', async ({ page, context }) => {
144
+ await page.goto('/dashboard');
145
+
146
+ await context.setStorageState('.auth/admin.json');
147
+ await page.reload();
148
+
149
+ await expect(page.getByRole('heading', { name: 'Admin dashboard' })).toBeVisible();
150
+ });
151
+ ```
152
+
153
+ ### Global Setup Authentication
154
+
155
+ **Use when**: You want to authenticate once before the entire test suite runs, then reuse that session everywhere. The standard Playwright-recommended approach.
156
+ **Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.
157
+
158
+ Global setup runs once per `npx playwright test` invocation. It logs in, saves the state, and every test project that references that state file starts authenticated.
159
+
160
+ **TypeScript**
161
+ ```typescript
162
+ // global-setup.ts
163
+ import { chromium, type FullConfig } from '@playwright/test';
164
+
165
+ async function globalSetup(config: FullConfig) {
166
+ const { baseURL } = config.projects[0].use;
167
+ const browser = await chromium.launch();
168
+ const context = await browser.newContext();
169
+ const page = await context.newPage();
170
+
171
+ await page.goto(`${baseURL}/login`);
172
+ await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
173
+ await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
174
+ await page.getByRole('button', { name: 'Sign in' }).click();
175
+ await page.waitForURL('**/dashboard');
176
+
177
+ await context.storageState({ path: '.auth/user.json' });
178
+ await browser.close();
179
+ }
180
+
181
+ export default globalSetup;
182
+ ```
183
+
184
+ ```typescript
185
+ // playwright.config.ts
186
+ import { defineConfig } from '@playwright/test';
187
+
188
+ export default defineConfig({
189
+ globalSetup: require.resolve('./global-setup'),
190
+ use: {
191
+ baseURL: 'http://localhost:3000',
192
+ storageState: '.auth/user.json',
193
+ },
194
+ });
195
+ ```
196
+
197
+ **JavaScript**
198
+ ```javascript
199
+ // global-setup.js
200
+ const { chromium } = require('@playwright/test');
201
+
202
+ async function globalSetup(config) {
203
+ const { baseURL } = config.projects[0].use;
204
+ const browser = await chromium.launch();
205
+ const context = await browser.newContext();
206
+ const page = await context.newPage();
207
+
208
+ await page.goto(`${baseURL}/login`);
209
+ await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL);
210
+ await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD);
211
+ await page.getByRole('button', { name: 'Sign in' }).click();
212
+ await page.waitForURL('**/dashboard');
213
+
214
+ await context.storageState({ path: '.auth/user.json' });
215
+ await browser.close();
216
+ }
217
+
218
+ module.exports = globalSetup;
219
+ ```
220
+
221
+ ```javascript
222
+ // playwright.config.js
223
+ const { defineConfig } = require('@playwright/test');
224
+
225
+ module.exports = defineConfig({
226
+ globalSetup: require.resolve('./global-setup'),
227
+ use: {
228
+ baseURL: 'http://localhost:3000',
229
+ storageState: '.auth/user.json',
230
+ },
231
+ });
232
+ ```
233
+
234
+ **Important**: Add `.auth/` to your `.gitignore`. Auth state files contain session tokens and should never be committed.
235
+
236
+ ### Per-Worker Authentication
237
+
238
+ **Use when**: Each parallel worker needs its own authenticated session to avoid race conditions. Essential when tests modify user state (profile updates, settings changes, data mutations).
239
+ **Avoid when**: Tests are read-only and a shared session is safe, or you only run tests serially.
240
+
241
+ Worker-scoped fixtures run once per worker process, not once per test. This gives each parallel worker its own isolated session.
242
+
243
+ **TypeScript**
244
+ ```typescript
245
+ // fixtures/auth.ts
246
+ import { test as base, type BrowserContext } from '@playwright/test';
247
+
248
+ type AuthFixtures = {
249
+ authenticatedContext: BrowserContext;
250
+ };
251
+
252
+ export const test = base.extend<{}, AuthFixtures>({
253
+ authenticatedContext: [async ({ browser }, use) => {
254
+ const context = await browser.newContext();
255
+ const page = await context.newPage();
256
+
257
+ await page.goto('/login');
258
+ await page.getByLabel('Email').fill(`worker-${test.info().parallelIndex}@test.com`);
259
+ await page.getByLabel('Password').fill('password');
260
+ await page.getByRole('button', { name: 'Sign in' }).click();
261
+ await page.waitForURL('/dashboard');
262
+ await page.close();
263
+
264
+ await use(context);
265
+ await context.close();
266
+ }, { scope: 'worker' }],
267
+ });
268
+
269
+ export { expect } from '@playwright/test';
270
+ ```
271
+
272
+ ```typescript
273
+ // tests/profile.spec.ts
274
+ import { test, expect } from '../fixtures/auth';
275
+
276
+ test('update profile name', async ({ authenticatedContext }) => {
277
+ const page = await authenticatedContext.newPage();
278
+ await page.goto('/settings/profile');
279
+ await page.getByLabel('Display name').fill('Updated Name');
280
+ await page.getByRole('button', { name: 'Save' }).click();
281
+ await expect(page.getByText('Profile updated')).toBeVisible();
282
+ });
283
+ ```
284
+
285
+ **JavaScript**
286
+ ```javascript
287
+ // fixtures/auth.js
288
+ const { test: base } = require('@playwright/test');
289
+
290
+ const test = base.extend({
291
+ authenticatedContext: [async ({ browser }, use) => {
292
+ const context = await browser.newContext();
293
+ const page = await context.newPage();
294
+
295
+ await page.goto('/login');
296
+ await page.getByLabel('Email').fill(`worker-${test.info().parallelIndex}@test.com`);
297
+ await page.getByLabel('Password').fill('password');
298
+ await page.getByRole('button', { name: 'Sign in' }).click();
299
+ await page.waitForURL('/dashboard');
300
+ await page.close();
301
+
302
+ await use(context);
303
+ await context.close();
304
+ }, { scope: 'worker' }],
305
+ });
306
+
307
+ module.exports = { test, expect: require('@playwright/test').expect };
308
+ ```
309
+
310
+ ### Multiple Roles
311
+
312
+ **Use when**: Your app has role-based access control and you need to test that admins, regular users, and viewers see different things.
313
+ **Avoid when**: Your app has a single user role.
314
+
315
+ Use separate Playwright projects with different storage states, one per role. This is cleaner than switching roles within tests.
316
+
317
+ **TypeScript**
318
+ ```typescript
319
+ // global-setup.ts — authenticate all roles
320
+ import { chromium, type FullConfig } from '@playwright/test';
321
+
322
+ const users = [
323
+ { role: 'admin', email: 'admin@test.com', password: process.env.ADMIN_PASSWORD! },
324
+ { role: 'user', email: 'user@test.com', password: process.env.USER_PASSWORD! },
325
+ { role: 'viewer', email: 'viewer@test.com', password: process.env.VIEWER_PASSWORD! },
326
+ ];
327
+
328
+ async function globalSetup(config: FullConfig) {
329
+ const { baseURL } = config.projects[0].use;
330
+
331
+ for (const { role, email, password } of users) {
332
+ const browser = await chromium.launch();
333
+ const context = await browser.newContext();
334
+ const page = await context.newPage();
335
+
336
+ await page.goto(`${baseURL}/login`);
337
+ await page.getByLabel('Email').fill(email);
338
+ await page.getByLabel('Password').fill(password);
339
+ await page.getByRole('button', { name: 'Sign in' }).click();
340
+ await page.waitForURL('**/dashboard');
341
+
342
+ await context.storageState({ path: `.auth/${role}.json` });
343
+ await browser.close();
344
+ }
345
+ }
346
+
347
+ export default globalSetup;
348
+ ```
349
+
350
+ ```typescript
351
+ // playwright.config.ts — one project per role
352
+ import { defineConfig } from '@playwright/test';
353
+
354
+ export default defineConfig({
355
+ globalSetup: require.resolve('./global-setup'),
356
+ projects: [
357
+ {
358
+ name: 'admin',
359
+ use: { storageState: '.auth/admin.json' },
360
+ testMatch: '**/*.admin.spec.ts',
361
+ },
362
+ {
363
+ name: 'user',
364
+ use: { storageState: '.auth/user.json' },
365
+ testMatch: '**/*.user.spec.ts',
366
+ },
367
+ {
368
+ name: 'viewer',
369
+ use: { storageState: '.auth/viewer.json' },
370
+ testMatch: '**/*.viewer.spec.ts',
371
+ },
372
+ {
373
+ name: 'unauthenticated',
374
+ use: { storageState: { cookies: [], origins: [] } },
375
+ testMatch: '**/*.anon.spec.ts',
376
+ },
377
+ ],
378
+ });
379
+ ```
380
+
381
+ ```typescript
382
+ // tests/admin-panel.admin.spec.ts
383
+ import { test, expect } from '@playwright/test';
384
+
385
+ test('admin can access user management', async ({ page }) => {
386
+ await page.goto('/admin/users');
387
+ await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible();
388
+ await expect(page.getByRole('button', { name: 'Delete user' })).toBeEnabled();
389
+ });
390
+ ```
391
+
392
+ ```typescript
393
+ // tests/admin-panel.viewer.spec.ts
394
+ import { test, expect } from '@playwright/test';
395
+
396
+ test('viewer cannot access admin panel', async ({ page }) => {
397
+ await page.goto('/admin/users');
398
+ // Should redirect to forbidden or dashboard
399
+ await expect(page.getByText('Access denied')).toBeVisible();
400
+ });
401
+ ```
402
+
403
+ **JavaScript**
404
+ ```javascript
405
+ // global-setup.js
406
+ const { chromium } = require('@playwright/test');
407
+
408
+ const users = [
409
+ { role: 'admin', email: 'admin@test.com', password: process.env.ADMIN_PASSWORD },
410
+ { role: 'user', email: 'user@test.com', password: process.env.USER_PASSWORD },
411
+ { role: 'viewer', email: 'viewer@test.com', password: process.env.VIEWER_PASSWORD },
412
+ ];
413
+
414
+ async function globalSetup(config) {
415
+ const { baseURL } = config.projects[0].use;
416
+
417
+ for (const { role, email, password } of users) {
418
+ const browser = await chromium.launch();
419
+ const context = await browser.newContext();
420
+ const page = await context.newPage();
421
+
422
+ await page.goto(`${baseURL}/login`);
423
+ await page.getByLabel('Email').fill(email);
424
+ await page.getByLabel('Password').fill(password);
425
+ await page.getByRole('button', { name: 'Sign in' }).click();
426
+ await page.waitForURL('**/dashboard');
427
+
428
+ await context.storageState({ path: `.auth/${role}.json` });
429
+ await browser.close();
430
+ }
431
+ }
432
+
433
+ module.exports = globalSetup;
434
+ ```
435
+
436
+ ```javascript
437
+ // playwright.config.js
438
+ const { defineConfig } = require('@playwright/test');
439
+
440
+ module.exports = defineConfig({
441
+ globalSetup: require.resolve('./global-setup'),
442
+ projects: [
443
+ {
444
+ name: 'admin',
445
+ use: { storageState: '.auth/admin.json' },
446
+ testMatch: '**/*.admin.spec.js',
447
+ },
448
+ {
449
+ name: 'user',
450
+ use: { storageState: '.auth/user.json' },
451
+ testMatch: '**/*.user.spec.js',
452
+ },
453
+ {
454
+ name: 'viewer',
455
+ use: { storageState: '.auth/viewer.json' },
456
+ testMatch: '**/*.viewer.spec.js',
457
+ },
458
+ {
459
+ name: 'unauthenticated',
460
+ use: { storageState: { cookies: [], origins: [] } },
461
+ testMatch: '**/*.anon.spec.js',
462
+ },
463
+ ],
464
+ });
465
+ ```
466
+
467
+ **Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.
468
+
469
+ ```typescript
470
+ // fixtures/auth.ts — role-based fixture
471
+ import { test as base, type Page } from '@playwright/test';
472
+ import fs from 'fs';
473
+
474
+ type RoleFixtures = {
475
+ loginAs: (role: 'admin' | 'user' | 'viewer') => Promise<Page>;
476
+ };
477
+
478
+ export const test = base.extend<RoleFixtures>({
479
+ loginAs: async ({ browser }, use) => {
480
+ const pages: Page[] = [];
481
+
482
+ await use(async (role) => {
483
+ const statePath = `.auth/${role}.json`;
484
+ if (!fs.existsSync(statePath)) {
485
+ throw new Error(`Auth state for role "${role}" not found at ${statePath}. Run global setup first.`);
486
+ }
487
+ const context = await browser.newContext({ storageState: statePath });
488
+ const page = await context.newPage();
489
+ pages.push(page);
490
+ return page;
491
+ });
492
+
493
+ for (const page of pages) {
494
+ await page.context().close();
495
+ }
496
+ },
497
+ });
498
+
499
+ export { expect } from '@playwright/test';
500
+ ```
501
+
502
+ ```typescript
503
+ // tests/role-comparison.spec.ts
504
+ import { test, expect } from '../fixtures/auth';
505
+
506
+ test('admin sees delete button, viewer does not', async ({ loginAs }) => {
507
+ const adminPage = await loginAs('admin');
508
+ await adminPage.goto('/admin/users');
509
+ await expect(adminPage.getByRole('button', { name: 'Delete user' })).toBeVisible();
510
+
511
+ const viewerPage = await loginAs('viewer');
512
+ await viewerPage.goto('/admin/users');
513
+ await expect(viewerPage.getByText('Access denied')).toBeVisible();
514
+ });
515
+ ```
516
+
517
+ ### OAuth/SSO Mocking
518
+
519
+ **Use when**: Your app authenticates via a third-party OAuth provider (Google, GitHub, Okta, Auth0) and you cannot or should not hit the real provider in tests.
520
+ **Avoid when**: You have a dedicated test tenant on the OAuth provider and want true end-to-end coverage of the OAuth flow.
521
+
522
+ The strategy: intercept the OAuth callback route and inject a valid session directly, bypassing the provider entirely.
523
+
524
+ **TypeScript**
525
+ ```typescript
526
+ // fixtures/oauth-mock.ts
527
+ import { test as base } from '@playwright/test';
528
+
529
+ export const test = base.extend({
530
+ // Mock OAuth by intercepting the callback and injecting session cookies
531
+ page: async ({ page }, use) => {
532
+ // Intercept the OAuth redirect to the provider
533
+ await page.route('**/auth/callback**', async (route) => {
534
+ // Instead of going to Google/GitHub, call your own backend
535
+ // with a test token that your backend recognizes in test mode
536
+ const url = new URL(route.request().url());
537
+ url.searchParams.set('code', 'test-oauth-code');
538
+ url.searchParams.set('state', url.searchParams.get('state') || '');
539
+
540
+ await route.continue({ url: url.toString() });
541
+ });
542
+
543
+ await use(page);
544
+ },
545
+ });
546
+
547
+ export { expect } from '@playwright/test';
548
+ ```
549
+
550
+ ```typescript
551
+ // tests/oauth-login.spec.ts — approach 1: mock the callback route
552
+ import { test, expect } from '@playwright/test';
553
+
554
+ test('login via mocked OAuth flow', async ({ page }) => {
555
+ // Intercept the redirect to the OAuth provider
556
+ await page.route('https://accounts.google.com/**', async (route) => {
557
+ // Redirect back to your app's callback with a fake auth code
558
+ const callbackUrl = new URL('http://localhost:3000/auth/callback');
559
+ callbackUrl.searchParams.set('code', 'mock-auth-code-12345');
560
+ callbackUrl.searchParams.set('state', 'expected-state-value');
561
+ await route.fulfill({
562
+ status: 302,
563
+ headers: { location: callbackUrl.toString() },
564
+ });
565
+ });
566
+
567
+ await page.goto('/login');
568
+ await page.getByRole('button', { name: 'Sign in with Google' }).click();
569
+
570
+ // Your backend must accept "mock-auth-code-12345" in test mode
571
+ // and exchange it for a real session
572
+ await page.waitForURL('/dashboard');
573
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
574
+ });
575
+ ```
576
+
577
+ ```typescript
578
+ // tests/oauth-login.spec.ts — approach 2: API-based session injection
579
+ import { test, expect } from '@playwright/test';
580
+
581
+ test('bypass OAuth entirely via API session injection', async ({ page, request }) => {
582
+ // Call a test-only endpoint that creates a session without OAuth
583
+ // Your backend exposes this only when NODE_ENV=test
584
+ const response = await request.post('/api/test/create-session', {
585
+ data: {
586
+ email: 'oauth-user@test.com',
587
+ provider: 'google',
588
+ role: 'user',
589
+ },
590
+ });
591
+ expect(response.ok()).toBeTruthy();
592
+
593
+ // The response sets session cookies; storageState captures them
594
+ await page.context().storageState({ path: '.auth/oauth-user.json' });
595
+
596
+ // Now navigate — already authenticated
597
+ await page.goto('/dashboard');
598
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
599
+ });
600
+ ```
601
+
602
+ **JavaScript**
603
+ ```javascript
604
+ const { test, expect } = require('@playwright/test');
605
+
606
+ test('login via mocked OAuth flow', async ({ page }) => {
607
+ await page.route('https://accounts.google.com/**', async (route) => {
608
+ const callbackUrl = new URL('http://localhost:3000/auth/callback');
609
+ callbackUrl.searchParams.set('code', 'mock-auth-code-12345');
610
+ callbackUrl.searchParams.set('state', 'expected-state-value');
611
+ await route.fulfill({
612
+ status: 302,
613
+ headers: { location: callbackUrl.toString() },
614
+ });
615
+ });
616
+
617
+ await page.goto('/login');
618
+ await page.getByRole('button', { name: 'Sign in with Google' }).click();
619
+
620
+ await page.waitForURL('/dashboard');
621
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
622
+ });
623
+ ```
624
+
625
+ ```javascript
626
+ test('bypass OAuth entirely via API session injection', async ({ page, request }) => {
627
+ const response = await request.post('/api/test/create-session', {
628
+ data: {
629
+ email: 'oauth-user@test.com',
630
+ provider: 'google',
631
+ role: 'user',
632
+ },
633
+ });
634
+ expect(response.ok()).toBeTruthy();
635
+
636
+ await page.goto('/dashboard');
637
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
638
+ });
639
+ ```
640
+
641
+ **Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code. Never ship test auth endpoints to production.
642
+
643
+ ### MFA Handling
644
+
645
+ **Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes) and you need to handle it in tests.
646
+ **Avoid when**: MFA is optional and you can disable it for test accounts.
647
+
648
+ **Strategy 1**: Generate real TOTP codes from a shared secret. This is the most reliable approach.
649
+
650
+ **TypeScript**
651
+ ```typescript
652
+ // helpers/totp.ts
653
+ import * as OTPAuth from 'otpauth';
654
+
655
+ export function generateTOTP(secret: string): string {
656
+ const totp = new OTPAuth.TOTP({
657
+ secret: OTPAuth.Secret.fromBase32(secret),
658
+ digits: 6,
659
+ period: 30,
660
+ algorithm: 'SHA1',
661
+ });
662
+ return totp.generate();
663
+ }
664
+ ```
665
+
666
+ ```typescript
667
+ // tests/mfa-login.spec.ts
668
+ import { test, expect } from '@playwright/test';
669
+ import { generateTOTP } from '../helpers/totp';
670
+
671
+ test('login with TOTP two-factor auth', async ({ page }) => {
672
+ await page.goto('/login');
673
+ await page.getByLabel('Email').fill('mfa-user@test.com');
674
+ await page.getByLabel('Password').fill('password');
675
+ await page.getByRole('button', { name: 'Sign in' }).click();
676
+
677
+ // MFA challenge screen
678
+ await expect(page.getByText('Enter your authentication code')).toBeVisible();
679
+
680
+ // Generate a valid TOTP from the test account's secret
681
+ const code = generateTOTP(process.env.MFA_TOTP_SECRET!);
682
+ await page.getByLabel('Authentication code').fill(code);
683
+ await page.getByRole('button', { name: 'Verify' }).click();
684
+
685
+ await page.waitForURL('/dashboard');
686
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
687
+ });
688
+ ```
689
+
690
+ **JavaScript**
691
+ ```javascript
692
+ // helpers/totp.js
693
+ const OTPAuth = require('otpauth');
694
+
695
+ function generateTOTP(secret) {
696
+ const totp = new OTPAuth.TOTP({
697
+ secret: OTPAuth.Secret.fromBase32(secret),
698
+ digits: 6,
699
+ period: 30,
700
+ algorithm: 'SHA1',
701
+ });
702
+ return totp.generate();
703
+ }
704
+
705
+ module.exports = { generateTOTP };
706
+ ```
707
+
708
+ ```javascript
709
+ // tests/mfa-login.spec.js
710
+ const { test, expect } = require('@playwright/test');
711
+ const { generateTOTP } = require('../helpers/totp');
712
+
713
+ test('login with TOTP two-factor auth', async ({ page }) => {
714
+ await page.goto('/login');
715
+ await page.getByLabel('Email').fill('mfa-user@test.com');
716
+ await page.getByLabel('Password').fill('password');
717
+ await page.getByRole('button', { name: 'Sign in' }).click();
718
+
719
+ await expect(page.getByText('Enter your authentication code')).toBeVisible();
720
+
721
+ const code = generateTOTP(process.env.MFA_TOTP_SECRET);
722
+ await page.getByLabel('Authentication code').fill(code);
723
+ await page.getByRole('button', { name: 'Verify' }).click();
724
+
725
+ await page.waitForURL('/dashboard');
726
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
727
+ });
728
+ ```
729
+
730
+ **Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.
731
+
732
+ ```typescript
733
+ // Simpler but less realistic — backend accepts bypass code in test mode
734
+ test('login with MFA bypass code', async ({ page }) => {
735
+ await page.goto('/login');
736
+ await page.getByLabel('Email').fill('mfa-user@test.com');
737
+ await page.getByLabel('Password').fill('password');
738
+ await page.getByRole('button', { name: 'Sign in' }).click();
739
+
740
+ // Backend accepts "000000" as a valid MFA code in test environment
741
+ await page.getByLabel('Authentication code').fill('000000');
742
+ await page.getByRole('button', { name: 'Verify' }).click();
743
+
744
+ await page.waitForURL('/dashboard');
745
+ });
746
+ ```
747
+
748
+ **Strategy 3**: Disable MFA for test accounts at the infrastructure level. Set up dedicated test users with MFA disabled. This is the simplest approach when feasible.
749
+
750
+ ### Session Refresh
751
+
752
+ **Use when**: Your tokens expire during long test runs or your app uses short-lived JWTs that need refreshing.
753
+ **Avoid when**: Your test suite runs in under a few minutes and tokens outlast the entire run.
754
+
755
+ **TypeScript**
756
+ ```typescript
757
+ // fixtures/auth-with-refresh.ts
758
+ import { test as base, type BrowserContext } from '@playwright/test';
759
+ import fs from 'fs';
760
+
761
+ type AuthFixtures = {
762
+ authenticatedPage: import('@playwright/test').Page;
763
+ };
764
+
765
+ export const test = base.extend<AuthFixtures>({
766
+ authenticatedPage: async ({ browser }, use) => {
767
+ const statePath = '.auth/user.json';
768
+
769
+ // Check if the stored session is still valid
770
+ let context: BrowserContext;
771
+ if (fs.existsSync(statePath)) {
772
+ context = await browser.newContext({ storageState: statePath });
773
+ const page = await context.newPage();
774
+
775
+ // Quick health check — does the session still work?
776
+ const response = await page.request.get('/api/auth/me');
777
+ if (response.ok()) {
778
+ await use(page);
779
+ await context.close();
780
+ return;
781
+ }
782
+ // Session expired — close and re-authenticate
783
+ await context.close();
784
+ }
785
+
786
+ // Re-authenticate
787
+ context = await browser.newContext();
788
+ const page = await context.newPage();
789
+ await page.goto('/login');
790
+ await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
791
+ await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
792
+ await page.getByRole('button', { name: 'Sign in' }).click();
793
+ await page.waitForURL('/dashboard');
794
+
795
+ // Save refreshed state for subsequent tests
796
+ await context.storageState({ path: statePath });
797
+
798
+ await use(page);
799
+ await context.close();
800
+ },
801
+ });
802
+
803
+ export { expect } from '@playwright/test';
804
+ ```
805
+
806
+ ```typescript
807
+ // Alternative: intercept token refresh requests to ensure they work
808
+ import { test, expect } from '@playwright/test';
809
+
810
+ test('app refreshes expired token automatically', async ({ page }) => {
811
+ await page.goto('/dashboard');
812
+
813
+ // Simulate token expiration by clearing the access token cookie
814
+ await page.context().clearCookies({ name: 'access_token' });
815
+
816
+ // Next API call should trigger a token refresh via refresh_token cookie
817
+ const refreshPromise = page.waitForResponse('**/api/auth/refresh');
818
+ await page.getByRole('button', { name: 'Load data' }).click();
819
+ const refreshResponse = await refreshPromise;
820
+
821
+ expect(refreshResponse.status()).toBe(200);
822
+ await expect(page.getByTestId('data-table')).toBeVisible();
823
+ });
824
+ ```
825
+
826
+ **JavaScript**
827
+ ```javascript
828
+ // fixtures/auth-with-refresh.js
829
+ const { test: base } = require('@playwright/test');
830
+ const fs = require('fs');
831
+
832
+ const test = base.extend({
833
+ authenticatedPage: async ({ browser }, use) => {
834
+ const statePath = '.auth/user.json';
835
+
836
+ if (fs.existsSync(statePath)) {
837
+ const context = await browser.newContext({ storageState: statePath });
838
+ const page = await context.newPage();
839
+ const response = await page.request.get('/api/auth/me');
840
+
841
+ if (response.ok()) {
842
+ await use(page);
843
+ await context.close();
844
+ return;
845
+ }
846
+ await context.close();
847
+ }
848
+
849
+ const context = await browser.newContext();
850
+ const page = await context.newPage();
851
+ await page.goto('/login');
852
+ await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL);
853
+ await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD);
854
+ await page.getByRole('button', { name: 'Sign in' }).click();
855
+ await page.waitForURL('/dashboard');
856
+ await context.storageState({ path: statePath });
857
+
858
+ await use(page);
859
+ await context.close();
860
+ },
861
+ });
862
+
863
+ module.exports = { test, expect: require('@playwright/test').expect };
864
+ ```
865
+
866
+ ### Login Page Object
867
+
868
+ **Use when**: Multiple test files need to log in and you want consistent, maintainable login logic with proper error handling.
869
+ **Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests (still useful for testing the login page itself).
870
+
871
+ **TypeScript**
872
+ ```typescript
873
+ // page-objects/LoginPage.ts
874
+ import { type Page, type Locator, expect } from '@playwright/test';
875
+
876
+ export class LoginPage {
877
+ readonly page: Page;
878
+ readonly emailInput: Locator;
879
+ readonly passwordInput: Locator;
880
+ readonly signInButton: Locator;
881
+ readonly errorMessage: Locator;
882
+ readonly forgotPasswordLink: Locator;
883
+
884
+ constructor(page: Page) {
885
+ this.page = page;
886
+ this.emailInput = page.getByLabel('Email');
887
+ this.passwordInput = page.getByLabel('Password');
888
+ this.signInButton = page.getByRole('button', { name: 'Sign in' });
889
+ this.errorMessage = page.getByRole('alert');
890
+ this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot password' });
891
+ }
892
+
893
+ async goto() {
894
+ await this.page.goto('/login');
895
+ await expect(this.signInButton).toBeVisible();
896
+ }
897
+
898
+ async login(email: string, password: string) {
899
+ await this.emailInput.fill(email);
900
+ await this.passwordInput.fill(password);
901
+ await this.signInButton.click();
902
+ }
903
+
904
+ async loginAndWaitForDashboard(email: string, password: string) {
905
+ await this.login(email, password);
906
+ await this.page.waitForURL('/dashboard');
907
+ }
908
+
909
+ async expectError(message: string | RegExp) {
910
+ await expect(this.errorMessage).toContainText(message);
911
+ }
912
+
913
+ async expectFieldError(field: 'email' | 'password', message: string) {
914
+ const input = field === 'email' ? this.emailInput : this.passwordInput;
915
+ await expect(input).toHaveAttribute('aria-invalid', 'true');
916
+ // Error message associated with the field
917
+ const errorId = await input.getAttribute('aria-describedby');
918
+ if (errorId) {
919
+ await expect(this.page.locator(`#${errorId}`)).toContainText(message);
920
+ }
921
+ }
922
+ }
923
+ ```
924
+
925
+ ```typescript
926
+ // tests/login.spec.ts
927
+ import { test, expect } from '@playwright/test';
928
+ import { LoginPage } from '../page-objects/LoginPage';
929
+
930
+ // These tests run WITHOUT storageState (unauthenticated)
931
+ test.use({ storageState: { cookies: [], origins: [] } });
932
+
933
+ test.describe('login page', () => {
934
+ let loginPage: LoginPage;
935
+
936
+ test.beforeEach(async ({ page }) => {
937
+ loginPage = new LoginPage(page);
938
+ await loginPage.goto();
939
+ });
940
+
941
+ test('successful login redirects to dashboard', async ({ page }) => {
942
+ await loginPage.loginAndWaitForDashboard('user@test.com', 'password');
943
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
944
+ });
945
+
946
+ test('wrong password shows error', async () => {
947
+ await loginPage.login('user@test.com', 'wrong-password');
948
+ await loginPage.expectError('Invalid email or password');
949
+ });
950
+
951
+ test('empty fields show validation errors', async () => {
952
+ await loginPage.signInButton.click();
953
+ await loginPage.expectFieldError('email', 'Email is required');
954
+ });
955
+
956
+ test('forgot password link navigates correctly', async ({ page }) => {
957
+ await loginPage.forgotPasswordLink.click();
958
+ await page.waitForURL('/forgot-password');
959
+ await expect(page.getByRole('heading', { name: 'Reset password' })).toBeVisible();
960
+ });
961
+ });
962
+ ```
963
+
964
+ **JavaScript**
965
+ ```javascript
966
+ // page-objects/LoginPage.js
967
+ const { expect } = require('@playwright/test');
968
+
969
+ class LoginPage {
970
+ constructor(page) {
971
+ this.page = page;
972
+ this.emailInput = page.getByLabel('Email');
973
+ this.passwordInput = page.getByLabel('Password');
974
+ this.signInButton = page.getByRole('button', { name: 'Sign in' });
975
+ this.errorMessage = page.getByRole('alert');
976
+ this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot password' });
977
+ }
978
+
979
+ async goto() {
980
+ await this.page.goto('/login');
981
+ await expect(this.signInButton).toBeVisible();
982
+ }
983
+
984
+ async login(email, password) {
985
+ await this.emailInput.fill(email);
986
+ await this.passwordInput.fill(password);
987
+ await this.signInButton.click();
988
+ }
989
+
990
+ async loginAndWaitForDashboard(email, password) {
991
+ await this.login(email, password);
992
+ await this.page.waitForURL('/dashboard');
993
+ }
994
+
995
+ async expectError(message) {
996
+ await expect(this.errorMessage).toContainText(message);
997
+ }
998
+
999
+ async expectFieldError(field, message) {
1000
+ const input = field === 'email' ? this.emailInput : this.passwordInput;
1001
+ await expect(input).toHaveAttribute('aria-invalid', 'true');
1002
+ const errorId = await input.getAttribute('aria-describedby');
1003
+ if (errorId) {
1004
+ await expect(this.page.locator(`#${errorId}`)).toContainText(message);
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ module.exports = { LoginPage };
1010
+ ```
1011
+
1012
+ ```javascript
1013
+ // tests/login.spec.js
1014
+ const { test, expect } = require('@playwright/test');
1015
+ const { LoginPage } = require('../page-objects/LoginPage');
1016
+
1017
+ test.use({ storageState: { cookies: [], origins: [] } });
1018
+
1019
+ test.describe('login page', () => {
1020
+ let loginPage;
1021
+
1022
+ test.beforeEach(async ({ page }) => {
1023
+ loginPage = new LoginPage(page);
1024
+ await loginPage.goto();
1025
+ });
1026
+
1027
+ test('successful login redirects to dashboard', async ({ page }) => {
1028
+ await loginPage.loginAndWaitForDashboard('user@test.com', 'password');
1029
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
1030
+ });
1031
+
1032
+ test('wrong password shows error', async () => {
1033
+ await loginPage.login('user@test.com', 'wrong-password');
1034
+ await loginPage.expectError('Invalid email or password');
1035
+ });
1036
+
1037
+ test('empty fields show validation errors', async () => {
1038
+ await loginPage.signInButton.click();
1039
+ await loginPage.expectFieldError('email', 'Email is required');
1040
+ });
1041
+ });
1042
+ ```
1043
+
1044
+ ### API-Based Login
1045
+
1046
+ **Use when**: You want the fastest possible authentication without any browser interaction. Ideal for generating `storageState` files in global setup or fixtures.
1047
+ **Avoid when**: You are specifically testing the login UI. Use the Login Page Object pattern instead.
1048
+
1049
+ API login is typically 5-10x faster than UI login. Use `request.post()` to hit your auth endpoint directly, then capture the resulting cookies.
1050
+
1051
+ **TypeScript**
1052
+ ```typescript
1053
+ // global-setup.ts — API-based login (fastest)
1054
+ import { request, type FullConfig } from '@playwright/test';
1055
+
1056
+ async function globalSetup(config: FullConfig) {
1057
+ const { baseURL } = config.projects[0].use;
1058
+
1059
+ const requestContext = await request.newContext({ baseURL });
1060
+
1061
+ // Login via API — no browser needed
1062
+ const response = await requestContext.post('/api/auth/login', {
1063
+ data: {
1064
+ email: process.env.TEST_USER_EMAIL!,
1065
+ password: process.env.TEST_USER_PASSWORD!,
1066
+ },
1067
+ });
1068
+
1069
+ if (!response.ok()) {
1070
+ throw new Error(`API login failed: ${response.status()} ${await response.text()}`);
1071
+ }
1072
+
1073
+ // Save the session cookies from the API response
1074
+ await requestContext.storageState({ path: '.auth/user.json' });
1075
+ await requestContext.dispose();
1076
+ }
1077
+
1078
+ export default globalSetup;
1079
+ ```
1080
+
1081
+ ```typescript
1082
+ // fixtures/api-auth.ts — fixture version for per-test authentication
1083
+ import { test as base } from '@playwright/test';
1084
+
1085
+ export const test = base.extend({
1086
+ authenticatedPage: async ({ browser, playwright }, use) => {
1087
+ // Create API context and login
1088
+ const apiContext = await playwright.request.newContext({
1089
+ baseURL: 'http://localhost:3000',
1090
+ });
1091
+
1092
+ await apiContext.post('/api/auth/login', {
1093
+ data: {
1094
+ email: 'user@test.com',
1095
+ password: 'password',
1096
+ },
1097
+ });
1098
+
1099
+ // Transfer API session to browser context
1100
+ const state = await apiContext.storageState();
1101
+ const context = await browser.newContext({ storageState: state });
1102
+ const page = await context.newPage();
1103
+
1104
+ await use(page);
1105
+
1106
+ await context.close();
1107
+ await apiContext.dispose();
1108
+ },
1109
+ });
1110
+
1111
+ export { expect } from '@playwright/test';
1112
+ ```
1113
+
1114
+ **JavaScript**
1115
+ ```javascript
1116
+ // global-setup.js — API-based login
1117
+ const { request } = require('@playwright/test');
1118
+
1119
+ async function globalSetup(config) {
1120
+ const { baseURL } = config.projects[0].use;
1121
+ const requestContext = await request.newContext({ baseURL });
1122
+
1123
+ const response = await requestContext.post('/api/auth/login', {
1124
+ data: {
1125
+ email: process.env.TEST_USER_EMAIL,
1126
+ password: process.env.TEST_USER_PASSWORD,
1127
+ },
1128
+ });
1129
+
1130
+ if (!response.ok()) {
1131
+ throw new Error(`API login failed: ${response.status()} ${await response.text()}`);
1132
+ }
1133
+
1134
+ await requestContext.storageState({ path: '.auth/user.json' });
1135
+ await requestContext.dispose();
1136
+ }
1137
+
1138
+ module.exports = globalSetup;
1139
+ ```
1140
+
1141
+ ```javascript
1142
+ // fixtures/api-auth.js
1143
+ const { test: base } = require('@playwright/test');
1144
+
1145
+ const test = base.extend({
1146
+ authenticatedPage: async ({ browser, playwright }, use) => {
1147
+ const apiContext = await playwright.request.newContext({
1148
+ baseURL: 'http://localhost:3000',
1149
+ });
1150
+
1151
+ await apiContext.post('/api/auth/login', {
1152
+ data: { email: 'user@test.com', password: 'password' },
1153
+ });
1154
+
1155
+ const state = await apiContext.storageState();
1156
+ const context = await browser.newContext({ storageState: state });
1157
+ const page = await context.newPage();
1158
+
1159
+ await use(page);
1160
+
1161
+ await context.close();
1162
+ await apiContext.dispose();
1163
+ },
1164
+ });
1165
+
1166
+ module.exports = { test, expect: require('@playwright/test').expect };
1167
+ ```
1168
+
1169
+ ### Unauthenticated Tests
1170
+
1171
+ **Use when**: Testing the login page, signup flow, password reset, public pages, authentication error handling, or redirect behavior for unauthenticated users.
1172
+ **Avoid when**: The test requires a logged-in user.
1173
+
1174
+ When your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.
1175
+
1176
+ **TypeScript**
1177
+ ```typescript
1178
+ // tests/public-pages.spec.ts
1179
+ import { test, expect } from '@playwright/test';
1180
+
1181
+ // Override storageState to empty — no cookies, no session
1182
+ test.use({ storageState: { cookies: [], origins: [] } });
1183
+
1184
+ test.describe('unauthenticated access', () => {
1185
+ test('homepage is accessible without login', async ({ page }) => {
1186
+ await page.goto('/');
1187
+ await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
1188
+ await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
1189
+ });
1190
+
1191
+ test('protected route redirects to login', async ({ page }) => {
1192
+ await page.goto('/dashboard');
1193
+ await page.waitForURL('**/login**');
1194
+ // Verify redirect preserves the intended destination
1195
+ expect(page.url()).toContain('redirect=%2Fdashboard');
1196
+ });
1197
+
1198
+ test('expired session shows re-login prompt', async ({ page, context }) => {
1199
+ // Start with a valid session, then invalidate it
1200
+ await page.goto('/dashboard');
1201
+
1202
+ // Clear all cookies to simulate session expiration
1203
+ await context.clearCookies();
1204
+
1205
+ // Next navigation should detect the invalid session
1206
+ await page.goto('/settings');
1207
+ await page.waitForURL('**/login**');
1208
+ await expect(page.getByText('Your session has expired')).toBeVisible();
1209
+ });
1210
+
1211
+ test('login page shows social auth options', async ({ page }) => {
1212
+ await page.goto('/login');
1213
+ await expect(page.getByRole('button', { name: 'Sign in with Google' })).toBeVisible();
1214
+ await expect(page.getByRole('button', { name: 'Sign in with GitHub' })).toBeVisible();
1215
+ });
1216
+
1217
+ test('signup flow creates account', async ({ page }) => {
1218
+ await page.goto('/signup');
1219
+ await page.getByLabel('Name').fill('New User');
1220
+ await page.getByLabel('Email').fill(`test-${Date.now()}@test.com`);
1221
+ await page.getByLabel('Password', { exact: true }).fill('s3cure!Pass');
1222
+ await page.getByLabel('Confirm password').fill('s3cure!Pass');
1223
+ await page.getByRole('button', { name: 'Create account' }).click();
1224
+
1225
+ await page.waitForURL('/onboarding');
1226
+ await expect(page.getByText('Welcome, New User')).toBeVisible();
1227
+ });
1228
+ });
1229
+ ```
1230
+
1231
+ **JavaScript**
1232
+ ```javascript
1233
+ // tests/public-pages.spec.js
1234
+ const { test, expect } = require('@playwright/test');
1235
+
1236
+ test.use({ storageState: { cookies: [], origins: [] } });
1237
+
1238
+ test.describe('unauthenticated access', () => {
1239
+ test('homepage is accessible without login', async ({ page }) => {
1240
+ await page.goto('/');
1241
+ await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
1242
+ await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
1243
+ });
1244
+
1245
+ test('protected route redirects to login', async ({ page }) => {
1246
+ await page.goto('/dashboard');
1247
+ await page.waitForURL('**/login**');
1248
+ expect(page.url()).toContain('redirect=%2Fdashboard');
1249
+ });
1250
+
1251
+ test('signup flow creates account', async ({ page }) => {
1252
+ await page.goto('/signup');
1253
+ await page.getByLabel('Name').fill('New User');
1254
+ await page.getByLabel('Email').fill(`test-${Date.now()}@test.com`);
1255
+ await page.getByLabel('Password', { exact: true }).fill('s3cure!Pass');
1256
+ await page.getByLabel('Confirm password').fill('s3cure!Pass');
1257
+ await page.getByRole('button', { name: 'Create account' }).click();
1258
+
1259
+ await page.waitForURL('/onboarding');
1260
+ await expect(page.getByText('Welcome, New User')).toBeVisible();
1261
+ });
1262
+ });
1263
+ ```
1264
+
1265
+ ## Decision Guide
1266
+
1267
+ | Scenario | Approach | Speed | Isolation | When to Choose |
1268
+ |---|---|---|---|---|
1269
+ | Most tests need auth | Global setup + `storageState` | Fastest | Shared session | Default for nearly every project. Login happens once, all tests reuse the session file. |
1270
+ | Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data that could conflict between parallel workers. |
1271
+ | Multiple user roles | Per-project `storageState` | Fastest | Per role | App has admin/user/viewer roles. Each project gets its own state file from global setup. |
1272
+ | Testing the login page | No `storageState` | N/A | Full | Use `test.use({ storageState: { cookies: [], origins: [] } })` to override defaults. |
1273
+ | OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI. Mock the redirect or use API session injection. |
1274
+ | MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes from a shared secret, or use a test-mode bypass code. |
1275
+ | Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use and re-authenticates if expired. |
1276
+ | Single test needs different user | `loginAs(role)` fixture | Moderate | Per call | Rare: prefer per-project roles. Use when a single test must compare two roles side by side. |
1277
+ | API-first app (no login UI) | API login via `request.post()` | Fastest | Per test | No browser needed for auth. Hit the API directly and capture cookies. |
1278
+ | CI with long-running suites | API login + session check | Fastest | Per worker | Combine API login speed with session refresh for reliability over long runs. |
1279
+
1280
+ ### UI Login vs API Login vs Storage State
1281
+
1282
+ ```
1283
+ Need to test the login page itself?
1284
+ ├── Yes → UI login with LoginPage POM, no storageState
1285
+ └── No → Do you have a login API endpoint?
1286
+ ├── Yes → API login in global setup, save storageState (fastest)
1287
+ └── No → UI login in global setup, save storageState
1288
+ └── Tokens expire quickly?
1289
+ ├── Yes → Add session refresh fixture
1290
+ └── No → Standard storageState reuse is fine
1291
+ ```
1292
+
1293
+ ## Anti-Patterns
1294
+
1295
+ | Don't Do This | Problem | Do This Instead |
1296
+ |---|---|---|
1297
+ | Log in via UI before every test | Adds 2-5 seconds per test. A suite of 200 tests wastes 7-17 minutes just logging in. | Use `storageState` to skip login entirely. Log in once in global setup. |
1298
+ | Share a single auth state file across parallel workers that mutate state | Race conditions: worker A changes the password while worker B is mid-test. | Use per-worker fixtures with `{ scope: 'worker' }` or per-worker test accounts. |
1299
+ | Hardcode credentials in test files | Security risk. Credentials leak into version control and CI logs. | Use environment variables (`process.env.TEST_USER_PASSWORD`) and `.env` files. |
1300
+ | Ignore token expiration | Tests fail intermittently with 401 errors after running for a while. | Add a session validity check in your auth fixture and re-authenticate when expired. |
1301
+ | Hit real OAuth providers in CI | Flaky: provider rate limits, CAPTCHA, network issues. Slow. May violate ToS. | Mock the OAuth callback or use API session injection with a test-only endpoint. |
1302
+ | Use `page.waitForTimeout(2000)` after login | Arbitrary delay. Too slow in fast environments, too short in slow ones. | `await page.waitForURL('/dashboard')` or `await expect(heading).toBeVisible()`. |
1303
+ | Store `.auth/*.json` files in git | Tokens and cookies in version control. Security incident waiting to happen. | Add `.auth/` to `.gitignore`. Generate auth state in CI as part of the test run. |
1304
+ | Create one "god" test account with all permissions | Cannot test role-based access control. Bugs in permission checks go undetected. | Create separate accounts per role (admin, user, viewer) with appropriate permissions. |
1305
+ | Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated. You end up re-logging in constantly. | Pass `storageState` when creating the context: `browser.newContext({ storageState: '.auth/user.json' })`. |
1306
+ | Test MFA by disabling it everywhere | You never test the MFA flow. Real users hit MFA bugs you never catch. | Use TOTP generation from a shared secret for at least one test. Bypass MFA for the rest. |
1307
+
1308
+ ## Troubleshooting
1309
+
1310
+ ### Global setup fails with "Target page, context or browser has been closed"
1311
+
1312
+ **Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called. Common when the login URL requires HTTPS but your test server uses HTTP.
1313
+
1314
+ **Fix**:
1315
+ - Add `await page.waitForURL()` after the login action to ensure navigation completed.
1316
+ - Check that `baseURL` in your config matches the actual server URL and protocol.
1317
+ - Add error handling to global setup with a meaningful error message:
1318
+
1319
+ ```typescript
1320
+ const response = await page.waitForResponse('**/api/auth/**');
1321
+ if (!response.ok()) {
1322
+ throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`);
1323
+ }
1324
+ ```
1325
+
1326
+ ### Tests fail with 401 Unauthorized after running for a while
1327
+
1328
+ **Cause**: The session token saved in `storageState` has expired. Short-lived JWTs (15-minute expiry) will not survive a long test suite.
1329
+
1330
+ **Fix**:
1331
+ - Use the session refresh fixture pattern (see Session Refresh section).
1332
+ - Increase token expiry in test environment configuration.
1333
+ - Switch to API-based login in a worker-scoped fixture that re-authenticates per worker.
1334
+
1335
+ ### `storageState` file is empty or contains no cookies
1336
+
1337
+ **Cause**: `storageState()` was called before the login response set cookies. The login may be async (POST returns, then a redirect sets the cookie).
1338
+
1339
+ **Fix**:
1340
+ - Wait for the post-login page to load: `await page.waitForURL('/dashboard')`.
1341
+ - Wait for a specific cookie: use `await context.cookies()` in a polling check.
1342
+ - Verify cookies exist before saving:
1343
+
1344
+ ```typescript
1345
+ const cookies = await context.cookies();
1346
+ if (cookies.length === 0) {
1347
+ throw new Error('No cookies found after login. Check that the login flow sets cookies.');
1348
+ }
1349
+ await context.storageState({ path: '.auth/user.json' });
1350
+ ```
1351
+
1352
+ ### Different browsers get different cookies (cross-browser auth issues)
1353
+
1354
+ **Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior. Chromium and Firefox handle third-party cookies differently.
1355
+
1356
+ **Fix**:
1357
+ - Generate separate auth state files per browser project.
1358
+ - Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS.
1359
+ - In `playwright.config`, set separate `storageState` paths per project:
1360
+
1361
+ ```typescript
1362
+ projects: [
1363
+ {
1364
+ name: 'chromium',
1365
+ use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-user.json' },
1366
+ },
1367
+ {
1368
+ name: 'firefox',
1369
+ use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-user.json' },
1370
+ },
1371
+ ],
1372
+ ```
1373
+
1374
+ ### Parallel tests interfere with each other's sessions
1375
+
1376
+ **Cause**: Multiple workers share the same test account and one worker's actions (logout, password change, session invalidation) affect others.
1377
+
1378
+ **Fix**:
1379
+ - Use per-worker test accounts: `worker-${test.info().parallelIndex}@test.com`.
1380
+ - Use the per-worker authentication fixture pattern (see Per-Worker Authentication section).
1381
+ - Make tests idempotent: each test should work regardless of what other tests did.
1382
+
1383
+ ### OAuth mock does not work — still redirects to real provider
1384
+
1385
+ **Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect, or the route pattern does not match the actual redirect URL.
1386
+
1387
+ **Fix**:
1388
+ - Register route handlers before any navigation: call `page.route()` before `page.goto()`.
1389
+ - Log the actual redirect URL to verify the pattern:
1390
+
1391
+ ```typescript
1392
+ page.on('request', (req) => {
1393
+ if (req.url().includes('oauth') || req.url().includes('accounts.google')) {
1394
+ console.log('OAuth request:', req.url());
1395
+ }
1396
+ });
1397
+ ```
1398
+
1399
+ - Use a broad pattern first (`**accounts.google.com**`) then narrow it down.
1400
+
1401
+ ## Related
1402
+
1403
+ - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- custom fixtures for auth setup and teardown
1404
+ - [core/configuration.md](configuration.md) -- `storageState`, projects, and global setup configuration
1405
+ - [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) -- global setup patterns and project dependencies
1406
+ - [core/network-mocking.md](network-mocking.md) -- route interception patterns used in OAuth mocking
1407
+ - [core/api-testing.md](api-testing.md) -- API request context used in API-based login
1408
+ - [core/flaky-tests.md](flaky-tests.md) -- diagnosing auth-related flakiness
1409
+ - [core/auth-flows.md](auth-flows.md) -- complete recipes for specific auth providers (Auth0, Okta, Firebase)