@assertrx/dino 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -0
- package/extensions/dino.ts +70 -0
- package/extensions/mabl-mcp.ts +306 -0
- package/extensions/playwright-mcp.ts +142 -0
- package/package.json +47 -0
- package/prompts/create-pr.md +110 -0
- package/prompts/generate-bdd-from-url.md +65 -0
- package/prompts/generate-pom-from-url-cli.md +48 -0
- package/prompts/generate-pom-from-url.md +45 -0
- package/prompts/generate-spec-from-feature.md +64 -0
- package/prompts/heal-playwright-failures.md +106 -0
- package/prompts/setup-playwright.md +81 -0
- package/scripts/start-dino.cmd +8 -0
- package/scripts/start-dino.mjs +226 -0
- package/scripts/start-dino.ps1 +9 -0
- package/scripts/start-dino.sh +10 -0
- package/skills/acli/SKILL.md +292 -0
- package/skills/bdd-gherkin/SKILL.md +552 -0
- package/skills/build-review-interface/SKILL.md +96 -0
- package/skills/error-analysis/SKILL.md +164 -0
- package/skills/eval-audit/SKILL.md +183 -0
- package/skills/evaluate-rag/SKILL.md +177 -0
- package/skills/generate-synthetic-data/SKILL.md +131 -0
- package/skills/mabl-mcp/SKILL.md +79 -0
- package/skills/playwright-cli/SKILL.md +246 -0
- package/skills/playwright-cli/advanced-workflows.md +679 -0
- package/skills/playwright-cli/core-commands.md +375 -0
- package/skills/playwright-cli/device-emulation.md +475 -0
- package/skills/playwright-cli/request-mocking.md +373 -0
- package/skills/playwright-cli/running-custom-code.md +553 -0
- package/skills/playwright-cli/screenshots-and-media.md +457 -0
- package/skills/playwright-cli/session-management.md +425 -0
- package/skills/playwright-cli/storage-and-auth.md +487 -0
- package/skills/playwright-cli/test-generation.md +336 -0
- package/skills/playwright-cli/tracing-and-debugging.md +501 -0
- package/skills/playwright-core/SKILL.md +101 -0
- package/skills/playwright-core/accessibility.md +1517 -0
- package/skills/playwright-core/angular.md +1086 -0
- package/skills/playwright-core/api-testing.md +1623 -0
- package/skills/playwright-core/assertions-and-waiting.md +703 -0
- package/skills/playwright-core/auth-flows.md +1081 -0
- package/skills/playwright-core/authentication.md +1409 -0
- package/skills/playwright-core/browser-apis.md +673 -0
- package/skills/playwright-core/browser-extensions.md +319 -0
- package/skills/playwright-core/canvas-and-webgl.md +494 -0
- package/skills/playwright-core/clock-and-time-mocking.md +427 -0
- package/skills/playwright-core/common-pitfalls.md +1318 -0
- package/skills/playwright-core/component-testing.md +1179 -0
- package/skills/playwright-core/configuration.md +731 -0
- package/skills/playwright-core/crud-testing.md +945 -0
- package/skills/playwright-core/debugging.md +827 -0
- package/skills/playwright-core/drag-and-drop.md +992 -0
- package/skills/playwright-core/electron-testing.md +622 -0
- package/skills/playwright-core/error-and-edge-cases.md +1181 -0
- package/skills/playwright-core/error-index.md +1903 -0
- package/skills/playwright-core/file-operations.md +749 -0
- package/skills/playwright-core/file-upload-download.md +1012 -0
- package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
- package/skills/playwright-core/flaky-tests.md +883 -0
- package/skills/playwright-core/forms-and-validation.md +1056 -0
- package/skills/playwright-core/i18n-and-localization.md +622 -0
- package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
- package/skills/playwright-core/locator-strategy.md +588 -0
- package/skills/playwright-core/locators.md +743 -0
- package/skills/playwright-core/mobile-and-responsive.md +1669 -0
- package/skills/playwright-core/multi-context-and-popups.md +581 -0
- package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
- package/skills/playwright-core/network-mocking.md +1395 -0
- package/skills/playwright-core/nextjs.md +1013 -0
- package/skills/playwright-core/performance-testing.md +608 -0
- package/skills/playwright-core/react.md +1082 -0
- package/skills/playwright-core/search-and-filter.md +1366 -0
- package/skills/playwright-core/security-testing.md +624 -0
- package/skills/playwright-core/service-workers-and-pwa.md +524 -0
- package/skills/playwright-core/test-architecture.md +569 -0
- package/skills/playwright-core/test-data-management.md +1209 -0
- package/skills/playwright-core/test-organization.md +947 -0
- package/skills/playwright-core/third-party-integrations.md +754 -0
- package/skills/playwright-core/trace-analysis.md +161 -0
- package/skills/playwright-core/visual-regression.md +1027 -0
- package/skills/playwright-core/vue.md +1162 -0
- package/skills/playwright-core/websockets-and-realtime.md +623 -0
- package/skills/playwright-core/when-to-mock.md +827 -0
- package/skills/playwright-pom/SKILL.md +94 -0
- package/skills/playwright-pom/page-object-model.md +921 -0
- package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
- package/skills/validate-evaluator/SKILL.md +215 -0
- package/skills/write-judge-prompt/SKILL.md +144 -0
- package/themes/dino.json +66 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
# Authentication Flow Recipes
|
|
2
|
+
|
|
3
|
+
> **When to use**: You need to test login, signup, logout, session management, or any authentication-related user flow.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Recipe 1: Basic Login
|
|
8
|
+
|
|
9
|
+
### Complete Example
|
|
10
|
+
|
|
11
|
+
**TypeScript**
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { test, expect } from '@playwright/test';
|
|
15
|
+
|
|
16
|
+
test('logs in with valid credentials', async ({ page }) => {
|
|
17
|
+
await page.goto('/login');
|
|
18
|
+
|
|
19
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
20
|
+
await page.getByLabel('Password').fill('SecurePass123!');
|
|
21
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
22
|
+
|
|
23
|
+
// Wait for navigation to complete after login
|
|
24
|
+
await expect(page).toHaveURL('/dashboard');
|
|
25
|
+
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
26
|
+
await expect(page.getByText('Welcome, user@example.com')).toBeVisible();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('shows error for invalid credentials', async ({ page }) => {
|
|
30
|
+
await page.goto('/login');
|
|
31
|
+
|
|
32
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
33
|
+
await page.getByLabel('Password').fill('WrongPassword');
|
|
34
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
35
|
+
|
|
36
|
+
await expect(page.getByRole('alert')).toContainText('Invalid email or password');
|
|
37
|
+
// Should remain on login page
|
|
38
|
+
await expect(page).toHaveURL('/login');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('shows validation errors for empty fields', async ({ page }) => {
|
|
42
|
+
await page.goto('/login');
|
|
43
|
+
|
|
44
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
45
|
+
|
|
46
|
+
await expect(page.getByText('Email is required')).toBeVisible();
|
|
47
|
+
await expect(page.getByText('Password is required')).toBeVisible();
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**JavaScript**
|
|
52
|
+
|
|
53
|
+
```javascript
|
|
54
|
+
const { test, expect } = require('@playwright/test');
|
|
55
|
+
|
|
56
|
+
test('logs in with valid credentials', async ({ page }) => {
|
|
57
|
+
await page.goto('/login');
|
|
58
|
+
|
|
59
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
60
|
+
await page.getByLabel('Password').fill('SecurePass123!');
|
|
61
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
62
|
+
|
|
63
|
+
await expect(page).toHaveURL('/dashboard');
|
|
64
|
+
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
65
|
+
await expect(page.getByText('Welcome, user@example.com')).toBeVisible();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('shows error for invalid credentials', async ({ page }) => {
|
|
69
|
+
await page.goto('/login');
|
|
70
|
+
|
|
71
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
72
|
+
await page.getByLabel('Password').fill('WrongPassword');
|
|
73
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
74
|
+
|
|
75
|
+
await expect(page.getByRole('alert')).toContainText('Invalid email or password');
|
|
76
|
+
await expect(page).toHaveURL('/login');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('shows validation errors for empty fields', async ({ page }) => {
|
|
80
|
+
await page.goto('/login');
|
|
81
|
+
|
|
82
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
83
|
+
|
|
84
|
+
await expect(page.getByText('Email is required')).toBeVisible();
|
|
85
|
+
await expect(page.getByText('Password is required')).toBeVisible();
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Recipe 2: Login with "Remember Me"
|
|
92
|
+
|
|
93
|
+
### Complete Example
|
|
94
|
+
|
|
95
|
+
**TypeScript**
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { test, expect, type BrowserContext } from '@playwright/test';
|
|
99
|
+
|
|
100
|
+
test('remember me persists session across browser restarts', async ({ browser }) => {
|
|
101
|
+
// First session: log in with "remember me" checked
|
|
102
|
+
const context1 = await browser.newContext();
|
|
103
|
+
const page1 = await context1.newPage();
|
|
104
|
+
|
|
105
|
+
await page1.goto('/login');
|
|
106
|
+
await page1.getByLabel('Email').fill('user@example.com');
|
|
107
|
+
await page1.getByLabel('Password').fill('SecurePass123!');
|
|
108
|
+
await page1.getByLabel('Remember me').check();
|
|
109
|
+
await page1.getByRole('button', { name: 'Sign in' }).click();
|
|
110
|
+
|
|
111
|
+
await expect(page1).toHaveURL('/dashboard');
|
|
112
|
+
|
|
113
|
+
// Save storage state (cookies + localStorage)
|
|
114
|
+
const storageState = await context1.storageState();
|
|
115
|
+
await context1.close();
|
|
116
|
+
|
|
117
|
+
// Second session: restore state to simulate browser restart
|
|
118
|
+
const context2 = await browser.newContext({ storageState });
|
|
119
|
+
const page2 = await context2.newPage();
|
|
120
|
+
|
|
121
|
+
await page2.goto('/dashboard');
|
|
122
|
+
|
|
123
|
+
// Should still be logged in without re-authenticating
|
|
124
|
+
await expect(page2).toHaveURL('/dashboard');
|
|
125
|
+
await expect(page2.getByText('Welcome, user@example.com')).toBeVisible();
|
|
126
|
+
|
|
127
|
+
await context2.close();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('no remember me does not persist session', async ({ browser }) => {
|
|
131
|
+
const context1 = await browser.newContext();
|
|
132
|
+
const page1 = await context1.newPage();
|
|
133
|
+
|
|
134
|
+
await page1.goto('/login');
|
|
135
|
+
await page1.getByLabel('Email').fill('user@example.com');
|
|
136
|
+
await page1.getByLabel('Password').fill('SecurePass123!');
|
|
137
|
+
// Explicitly leave "Remember me" unchecked
|
|
138
|
+
await expect(page1.getByLabel('Remember me')).not.toBeChecked();
|
|
139
|
+
await page1.getByRole('button', { name: 'Sign in' }).click();
|
|
140
|
+
|
|
141
|
+
await expect(page1).toHaveURL('/dashboard');
|
|
142
|
+
|
|
143
|
+
// Capture only cookies (not sessionStorage) to simulate new tab/window
|
|
144
|
+
const cookies = await context1.cookies();
|
|
145
|
+
await context1.close();
|
|
146
|
+
|
|
147
|
+
// New context with only persistent cookies (session cookies are excluded)
|
|
148
|
+
const persistentCookies = cookies.filter(c => c.expires > 0);
|
|
149
|
+
const context2 = await browser.newContext();
|
|
150
|
+
await context2.addCookies(persistentCookies);
|
|
151
|
+
const page2 = await context2.newPage();
|
|
152
|
+
|
|
153
|
+
await page2.goto('/dashboard');
|
|
154
|
+
|
|
155
|
+
// Should redirect to login since session was not persisted
|
|
156
|
+
await expect(page2).toHaveURL(/\/login/);
|
|
157
|
+
|
|
158
|
+
await context2.close();
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**JavaScript**
|
|
163
|
+
|
|
164
|
+
```javascript
|
|
165
|
+
const { test, expect } = require('@playwright/test');
|
|
166
|
+
|
|
167
|
+
test('remember me persists session across browser restarts', async ({ browser }) => {
|
|
168
|
+
const context1 = await browser.newContext();
|
|
169
|
+
const page1 = await context1.newPage();
|
|
170
|
+
|
|
171
|
+
await page1.goto('/login');
|
|
172
|
+
await page1.getByLabel('Email').fill('user@example.com');
|
|
173
|
+
await page1.getByLabel('Password').fill('SecurePass123!');
|
|
174
|
+
await page1.getByLabel('Remember me').check();
|
|
175
|
+
await page1.getByRole('button', { name: 'Sign in' }).click();
|
|
176
|
+
|
|
177
|
+
await expect(page1).toHaveURL('/dashboard');
|
|
178
|
+
|
|
179
|
+
const storageState = await context1.storageState();
|
|
180
|
+
await context1.close();
|
|
181
|
+
|
|
182
|
+
const context2 = await browser.newContext({ storageState });
|
|
183
|
+
const page2 = await context2.newPage();
|
|
184
|
+
|
|
185
|
+
await page2.goto('/dashboard');
|
|
186
|
+
|
|
187
|
+
await expect(page2).toHaveURL('/dashboard');
|
|
188
|
+
await expect(page2.getByText('Welcome, user@example.com')).toBeVisible();
|
|
189
|
+
|
|
190
|
+
await context2.close();
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Recipe 3: Signup with Email Verification (Mocked)
|
|
197
|
+
|
|
198
|
+
### Complete Example
|
|
199
|
+
|
|
200
|
+
**TypeScript**
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
import { test, expect } from '@playwright/test';
|
|
204
|
+
|
|
205
|
+
test('completes signup flow with mocked email verification', async ({ page }) => {
|
|
206
|
+
// Intercept the verification email API so we can capture the token
|
|
207
|
+
let verificationToken = '';
|
|
208
|
+
await page.route('**/api/auth/send-verification', async (route) => {
|
|
209
|
+
// Let the request go through to the server
|
|
210
|
+
const response = await route.fetch();
|
|
211
|
+
const body = await response.json();
|
|
212
|
+
|
|
213
|
+
// Capture the token from the API response (test environments expose this)
|
|
214
|
+
verificationToken = body.verificationToken;
|
|
215
|
+
|
|
216
|
+
await route.fulfill({ response });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Step 1: Fill out signup form
|
|
220
|
+
await page.goto('/signup');
|
|
221
|
+
|
|
222
|
+
await page.getByLabel('Full name').fill('Jane Tester');
|
|
223
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
224
|
+
await page.getByLabel('Password', { exact: true }).fill('SecurePass123!');
|
|
225
|
+
await page.getByLabel('Confirm password').fill('SecurePass123!');
|
|
226
|
+
await page.getByLabel('I agree to the Terms of Service').check();
|
|
227
|
+
await page.getByRole('button', { name: 'Create account' }).click();
|
|
228
|
+
|
|
229
|
+
// Step 2: Verify "check your email" screen appears
|
|
230
|
+
await expect(page.getByText('Check your email')).toBeVisible();
|
|
231
|
+
await expect(page.getByText('jane@example.com')).toBeVisible();
|
|
232
|
+
|
|
233
|
+
// Step 3: Navigate directly to verification URL using captured token
|
|
234
|
+
expect(verificationToken).toBeTruthy();
|
|
235
|
+
await page.goto(`/verify-email?token=${verificationToken}`);
|
|
236
|
+
|
|
237
|
+
// Step 4: Verify account is now active
|
|
238
|
+
await expect(page.getByText('Email verified successfully')).toBeVisible();
|
|
239
|
+
await expect(page).toHaveURL('/login');
|
|
240
|
+
|
|
241
|
+
// Step 5: Log in with the new account
|
|
242
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
243
|
+
await page.getByLabel('Password').fill('SecurePass123!');
|
|
244
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
245
|
+
|
|
246
|
+
await expect(page).toHaveURL('/dashboard');
|
|
247
|
+
await expect(page.getByText('Welcome, Jane Tester')).toBeVisible();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('signup with fully mocked email API (no server dependency)', async ({ page }) => {
|
|
251
|
+
const fakeToken = 'test-verification-token-12345';
|
|
252
|
+
|
|
253
|
+
// Mock the signup API endpoint
|
|
254
|
+
await page.route('**/api/auth/signup', async (route) => {
|
|
255
|
+
await route.fulfill({
|
|
256
|
+
status: 200,
|
|
257
|
+
contentType: 'application/json',
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
message: 'Verification email sent',
|
|
260
|
+
verificationToken: fakeToken,
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Mock the verification endpoint
|
|
266
|
+
await page.route(`**/api/auth/verify-email?token=${fakeToken}`, async (route) => {
|
|
267
|
+
await route.fulfill({
|
|
268
|
+
status: 200,
|
|
269
|
+
contentType: 'application/json',
|
|
270
|
+
body: JSON.stringify({ message: 'Email verified', redirectTo: '/login' }),
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await page.goto('/signup');
|
|
275
|
+
|
|
276
|
+
await page.getByLabel('Full name').fill('Jane Tester');
|
|
277
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
278
|
+
await page.getByLabel('Password', { exact: true }).fill('SecurePass123!');
|
|
279
|
+
await page.getByLabel('Confirm password').fill('SecurePass123!');
|
|
280
|
+
await page.getByLabel('I agree to the Terms of Service').check();
|
|
281
|
+
await page.getByRole('button', { name: 'Create account' }).click();
|
|
282
|
+
|
|
283
|
+
await expect(page.getByText('Check your email')).toBeVisible();
|
|
284
|
+
|
|
285
|
+
// Simulate clicking the verification link from the email
|
|
286
|
+
await page.goto(`/verify-email?token=${fakeToken}`);
|
|
287
|
+
|
|
288
|
+
await expect(page.getByText('Email verified successfully')).toBeVisible();
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**JavaScript**
|
|
293
|
+
|
|
294
|
+
```javascript
|
|
295
|
+
const { test, expect } = require('@playwright/test');
|
|
296
|
+
|
|
297
|
+
test('completes signup flow with mocked email verification', async ({ page }) => {
|
|
298
|
+
let verificationToken = '';
|
|
299
|
+
await page.route('**/api/auth/send-verification', async (route) => {
|
|
300
|
+
const response = await route.fetch();
|
|
301
|
+
const body = await response.json();
|
|
302
|
+
verificationToken = body.verificationToken;
|
|
303
|
+
await route.fulfill({ response });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await page.goto('/signup');
|
|
307
|
+
|
|
308
|
+
await page.getByLabel('Full name').fill('Jane Tester');
|
|
309
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
310
|
+
await page.getByLabel('Password', { exact: true }).fill('SecurePass123!');
|
|
311
|
+
await page.getByLabel('Confirm password').fill('SecurePass123!');
|
|
312
|
+
await page.getByLabel('I agree to the Terms of Service').check();
|
|
313
|
+
await page.getByRole('button', { name: 'Create account' }).click();
|
|
314
|
+
|
|
315
|
+
await expect(page.getByText('Check your email')).toBeVisible();
|
|
316
|
+
|
|
317
|
+
expect(verificationToken).toBeTruthy();
|
|
318
|
+
await page.goto(`/verify-email?token=${verificationToken}`);
|
|
319
|
+
|
|
320
|
+
await expect(page.getByText('Email verified successfully')).toBeVisible();
|
|
321
|
+
await expect(page).toHaveURL('/login');
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Recipe 4: Password Reset Flow
|
|
328
|
+
|
|
329
|
+
### Complete Example
|
|
330
|
+
|
|
331
|
+
**TypeScript**
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
import { test, expect } from '@playwright/test';
|
|
335
|
+
|
|
336
|
+
test('completes password reset flow', async ({ page }) => {
|
|
337
|
+
let resetToken = '';
|
|
338
|
+
|
|
339
|
+
// Intercept the password reset API to capture the reset token
|
|
340
|
+
await page.route('**/api/auth/forgot-password', async (route) => {
|
|
341
|
+
const response = await route.fetch();
|
|
342
|
+
const body = await response.json();
|
|
343
|
+
resetToken = body.resetToken;
|
|
344
|
+
await route.fulfill({ response });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Step 1: Request password reset
|
|
348
|
+
await page.goto('/forgot-password');
|
|
349
|
+
|
|
350
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
351
|
+
await page.getByRole('button', { name: 'Send reset link' }).click();
|
|
352
|
+
|
|
353
|
+
await expect(page.getByText('Reset link sent')).toBeVisible();
|
|
354
|
+
await expect(page.getByText('user@example.com')).toBeVisible();
|
|
355
|
+
|
|
356
|
+
// Step 2: Navigate to the reset page with token
|
|
357
|
+
expect(resetToken).toBeTruthy();
|
|
358
|
+
await page.goto(`/reset-password?token=${resetToken}`);
|
|
359
|
+
|
|
360
|
+
// Step 3: Set new password
|
|
361
|
+
await page.getByLabel('New password', { exact: true }).fill('NewSecurePass456!');
|
|
362
|
+
await page.getByLabel('Confirm new password').fill('NewSecurePass456!');
|
|
363
|
+
await page.getByRole('button', { name: 'Reset password' }).click();
|
|
364
|
+
|
|
365
|
+
await expect(page.getByText('Password reset successfully')).toBeVisible();
|
|
366
|
+
|
|
367
|
+
// Step 4: Log in with new password
|
|
368
|
+
await page.goto('/login');
|
|
369
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
370
|
+
await page.getByLabel('Password').fill('NewSecurePass456!');
|
|
371
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
372
|
+
|
|
373
|
+
await expect(page).toHaveURL('/dashboard');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('password reset with expired token shows error', async ({ page }) => {
|
|
377
|
+
await page.goto('/reset-password?token=expired-token-123');
|
|
378
|
+
|
|
379
|
+
await page.getByLabel('New password', { exact: true }).fill('NewSecurePass456!');
|
|
380
|
+
await page.getByLabel('Confirm new password').fill('NewSecurePass456!');
|
|
381
|
+
await page.getByRole('button', { name: 'Reset password' }).click();
|
|
382
|
+
|
|
383
|
+
await expect(page.getByRole('alert')).toContainText(
|
|
384
|
+
/token has expired|link is no longer valid/i
|
|
385
|
+
);
|
|
386
|
+
await expect(page.getByRole('link', { name: 'Request a new reset link' })).toBeVisible();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('password reset enforces password strength requirements', async ({ page }) => {
|
|
390
|
+
await page.goto('/reset-password?token=valid-token');
|
|
391
|
+
|
|
392
|
+
// Try a weak password
|
|
393
|
+
await page.getByLabel('New password', { exact: true }).fill('123');
|
|
394
|
+
await page.getByLabel('Confirm new password').fill('123');
|
|
395
|
+
await page.getByRole('button', { name: 'Reset password' }).click();
|
|
396
|
+
|
|
397
|
+
await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
|
|
398
|
+
});
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**JavaScript**
|
|
402
|
+
|
|
403
|
+
```javascript
|
|
404
|
+
const { test, expect } = require('@playwright/test');
|
|
405
|
+
|
|
406
|
+
test('completes password reset flow', async ({ page }) => {
|
|
407
|
+
let resetToken = '';
|
|
408
|
+
|
|
409
|
+
await page.route('**/api/auth/forgot-password', async (route) => {
|
|
410
|
+
const response = await route.fetch();
|
|
411
|
+
const body = await response.json();
|
|
412
|
+
resetToken = body.resetToken;
|
|
413
|
+
await route.fulfill({ response });
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
await page.goto('/forgot-password');
|
|
417
|
+
|
|
418
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
419
|
+
await page.getByRole('button', { name: 'Send reset link' }).click();
|
|
420
|
+
|
|
421
|
+
await expect(page.getByText('Reset link sent')).toBeVisible();
|
|
422
|
+
|
|
423
|
+
expect(resetToken).toBeTruthy();
|
|
424
|
+
await page.goto(`/reset-password?token=${resetToken}`);
|
|
425
|
+
|
|
426
|
+
await page.getByLabel('New password', { exact: true }).fill('NewSecurePass456!');
|
|
427
|
+
await page.getByLabel('Confirm new password').fill('NewSecurePass456!');
|
|
428
|
+
await page.getByRole('button', { name: 'Reset password' }).click();
|
|
429
|
+
|
|
430
|
+
await expect(page.getByText('Password reset successfully')).toBeVisible();
|
|
431
|
+
|
|
432
|
+
await page.goto('/login');
|
|
433
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
434
|
+
await page.getByLabel('Password').fill('NewSecurePass456!');
|
|
435
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
436
|
+
|
|
437
|
+
await expect(page).toHaveURL('/dashboard');
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Recipe 5: OAuth Login (Mocked Callback)
|
|
444
|
+
|
|
445
|
+
### Complete Example
|
|
446
|
+
|
|
447
|
+
**TypeScript**
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
import { test, expect } from '@playwright/test';
|
|
451
|
+
|
|
452
|
+
test('logs in via mocked Google OAuth callback', async ({ page }) => {
|
|
453
|
+
// Intercept the OAuth redirect to Google and simulate the callback
|
|
454
|
+
await page.route('**/accounts.google.com/**', async (route) => {
|
|
455
|
+
// Instead of actually going to Google, redirect back to our callback URL
|
|
456
|
+
// with a mock authorization code
|
|
457
|
+
const callbackUrl = new URL('/auth/callback/google', 'http://localhost:3000');
|
|
458
|
+
callbackUrl.searchParams.set('code', 'mock-auth-code-12345');
|
|
459
|
+
callbackUrl.searchParams.set('state', route.request().url().match(/state=([^&]+)/)?.[1] || '');
|
|
460
|
+
|
|
461
|
+
await route.fulfill({
|
|
462
|
+
status: 302,
|
|
463
|
+
headers: { location: callbackUrl.toString() },
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Mock the token exchange on our backend
|
|
468
|
+
await page.route('**/api/auth/callback/google**', async (route) => {
|
|
469
|
+
await route.fulfill({
|
|
470
|
+
status: 200,
|
|
471
|
+
contentType: 'application/json',
|
|
472
|
+
body: JSON.stringify({
|
|
473
|
+
user: {
|
|
474
|
+
id: '1',
|
|
475
|
+
email: 'googleuser@gmail.com',
|
|
476
|
+
name: 'Google User',
|
|
477
|
+
avatar: 'https://example.com/avatar.jpg',
|
|
478
|
+
},
|
|
479
|
+
token: 'mock-jwt-token',
|
|
480
|
+
}),
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
await page.goto('/login');
|
|
485
|
+
|
|
486
|
+
// Click the OAuth button
|
|
487
|
+
await page.getByRole('button', { name: /Sign in with Google/i }).click();
|
|
488
|
+
|
|
489
|
+
// After the mocked OAuth flow, we should land on the dashboard
|
|
490
|
+
await expect(page).toHaveURL('/dashboard');
|
|
491
|
+
await expect(page.getByText('Google User')).toBeVisible();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('handles OAuth login failure gracefully', async ({ page }) => {
|
|
495
|
+
// Simulate OAuth provider returning an error
|
|
496
|
+
await page.route('**/accounts.google.com/**', async (route) => {
|
|
497
|
+
const callbackUrl = new URL('/auth/callback/google', 'http://localhost:3000');
|
|
498
|
+
callbackUrl.searchParams.set('error', 'access_denied');
|
|
499
|
+
callbackUrl.searchParams.set('error_description', 'User denied access');
|
|
500
|
+
|
|
501
|
+
await route.fulfill({
|
|
502
|
+
status: 302,
|
|
503
|
+
headers: { location: callbackUrl.toString() },
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
await page.goto('/login');
|
|
508
|
+
await page.getByRole('button', { name: /Sign in with Google/i }).click();
|
|
509
|
+
|
|
510
|
+
await expect(page.getByRole('alert')).toContainText(/authentication failed|access denied/i);
|
|
511
|
+
await expect(page).toHaveURL(/\/login/);
|
|
512
|
+
});
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
**JavaScript**
|
|
516
|
+
|
|
517
|
+
```javascript
|
|
518
|
+
const { test, expect } = require('@playwright/test');
|
|
519
|
+
|
|
520
|
+
test('logs in via mocked Google OAuth callback', async ({ page }) => {
|
|
521
|
+
await page.route('**/accounts.google.com/**', async (route) => {
|
|
522
|
+
const callbackUrl = new URL('/auth/callback/google', 'http://localhost:3000');
|
|
523
|
+
callbackUrl.searchParams.set('code', 'mock-auth-code-12345');
|
|
524
|
+
callbackUrl.searchParams.set('state', route.request().url().match(/state=([^&]+)/)?.[1] || '');
|
|
525
|
+
|
|
526
|
+
await route.fulfill({
|
|
527
|
+
status: 302,
|
|
528
|
+
headers: { location: callbackUrl.toString() },
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
await page.route('**/api/auth/callback/google**', async (route) => {
|
|
533
|
+
await route.fulfill({
|
|
534
|
+
status: 200,
|
|
535
|
+
contentType: 'application/json',
|
|
536
|
+
body: JSON.stringify({
|
|
537
|
+
user: { id: '1', email: 'googleuser@gmail.com', name: 'Google User' },
|
|
538
|
+
token: 'mock-jwt-token',
|
|
539
|
+
}),
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
await page.goto('/login');
|
|
544
|
+
await page.getByRole('button', { name: /Sign in with Google/i }).click();
|
|
545
|
+
|
|
546
|
+
await expect(page).toHaveURL('/dashboard');
|
|
547
|
+
await expect(page.getByText('Google User')).toBeVisible();
|
|
548
|
+
});
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## Recipe 6: Role-Based Access Testing
|
|
554
|
+
|
|
555
|
+
### Complete Example
|
|
556
|
+
|
|
557
|
+
**TypeScript**
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
import { test, expect, type Page } from '@playwright/test';
|
|
561
|
+
import path from 'path';
|
|
562
|
+
|
|
563
|
+
// Store auth state for each role in separate files
|
|
564
|
+
const authDir = path.resolve(__dirname, '../.auth');
|
|
565
|
+
|
|
566
|
+
// Setup: create auth state files for each role (run in globalSetup or auth.setup)
|
|
567
|
+
// This pattern uses Playwright's storage state for efficient role switching
|
|
568
|
+
const roles = ['admin', 'editor', 'viewer'] as const;
|
|
569
|
+
type Role = typeof roles[number];
|
|
570
|
+
|
|
571
|
+
function authFile(role: Role): string {
|
|
572
|
+
return path.join(authDir, `${role}.json`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Auth setup project — runs before all tests
|
|
576
|
+
test.describe('auth setup', () => {
|
|
577
|
+
const credentials: Record<Role, { email: string; password: string }> = {
|
|
578
|
+
admin: { email: 'admin@example.com', password: 'AdminPass123!' },
|
|
579
|
+
editor: { email: 'editor@example.com', password: 'EditorPass123!' },
|
|
580
|
+
viewer: { email: 'viewer@example.com', password: 'ViewerPass123!' },
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
for (const role of roles) {
|
|
584
|
+
test(`authenticate as ${role}`, async ({ page }) => {
|
|
585
|
+
await page.goto('/login');
|
|
586
|
+
await page.getByLabel('Email').fill(credentials[role].email);
|
|
587
|
+
await page.getByLabel('Password').fill(credentials[role].password);
|
|
588
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
589
|
+
await expect(page).toHaveURL('/dashboard');
|
|
590
|
+
|
|
591
|
+
await page.context().storageState({ path: authFile(role) });
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Admin tests
|
|
597
|
+
test.describe('admin access', () => {
|
|
598
|
+
test.use({ storageState: authFile('admin') });
|
|
599
|
+
|
|
600
|
+
test('admin can access user management', async ({ page }) => {
|
|
601
|
+
await page.goto('/admin/users');
|
|
602
|
+
await expect(page).toHaveURL('/admin/users');
|
|
603
|
+
await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible();
|
|
604
|
+
await expect(page.getByRole('button', { name: 'Add user' })).toBeVisible();
|
|
605
|
+
await expect(page.getByRole('button', { name: 'Delete' }).first()).toBeVisible();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test('admin can access system settings', async ({ page }) => {
|
|
609
|
+
await page.goto('/admin/settings');
|
|
610
|
+
await expect(page).toHaveURL('/admin/settings');
|
|
611
|
+
await expect(page.getByRole('heading', { name: 'System Settings' })).toBeVisible();
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Editor tests
|
|
616
|
+
test.describe('editor access', () => {
|
|
617
|
+
test.use({ storageState: authFile('editor') });
|
|
618
|
+
|
|
619
|
+
test('editor can create and edit content', async ({ page }) => {
|
|
620
|
+
await page.goto('/content');
|
|
621
|
+
await expect(page.getByRole('button', { name: 'New post' })).toBeVisible();
|
|
622
|
+
await expect(page.getByRole('button', { name: 'Edit' }).first()).toBeVisible();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test('editor cannot access admin area', async ({ page }) => {
|
|
626
|
+
await page.goto('/admin/users');
|
|
627
|
+
// Should be redirected or shown forbidden
|
|
628
|
+
await expect(page).toHaveURL(/\/(403|dashboard|login)/);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Viewer tests
|
|
633
|
+
test.describe('viewer access', () => {
|
|
634
|
+
test.use({ storageState: authFile('viewer') });
|
|
635
|
+
|
|
636
|
+
test('viewer can read content', async ({ page }) => {
|
|
637
|
+
await page.goto('/content');
|
|
638
|
+
await expect(page.getByRole('article').first()).toBeVisible();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('viewer cannot create content', async ({ page }) => {
|
|
642
|
+
await page.goto('/content');
|
|
643
|
+
await expect(page.getByRole('button', { name: 'New post' })).not.toBeVisible();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('viewer cannot access admin area', async ({ page }) => {
|
|
647
|
+
await page.goto('/admin/users');
|
|
648
|
+
await expect(page).toHaveURL(/\/(403|dashboard|login)/);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
**JavaScript**
|
|
654
|
+
|
|
655
|
+
```javascript
|
|
656
|
+
const { test, expect } = require('@playwright/test');
|
|
657
|
+
const path = require('path');
|
|
658
|
+
|
|
659
|
+
const authDir = path.resolve(__dirname, '../.auth');
|
|
660
|
+
|
|
661
|
+
function authFile(role) {
|
|
662
|
+
return path.join(authDir, `${role}.json`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Admin tests
|
|
666
|
+
test.describe('admin access', () => {
|
|
667
|
+
test.use({ storageState: authFile('admin') });
|
|
668
|
+
|
|
669
|
+
test('admin can access user management', async ({ page }) => {
|
|
670
|
+
await page.goto('/admin/users');
|
|
671
|
+
await expect(page).toHaveURL('/admin/users');
|
|
672
|
+
await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible();
|
|
673
|
+
await expect(page.getByRole('button', { name: 'Add user' })).toBeVisible();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('admin can access system settings', async ({ page }) => {
|
|
677
|
+
await page.goto('/admin/settings');
|
|
678
|
+
await expect(page).toHaveURL('/admin/settings');
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// Viewer tests
|
|
683
|
+
test.describe('viewer access', () => {
|
|
684
|
+
test.use({ storageState: authFile('viewer') });
|
|
685
|
+
|
|
686
|
+
test('viewer cannot create content', async ({ page }) => {
|
|
687
|
+
await page.goto('/content');
|
|
688
|
+
await expect(page.getByRole('button', { name: 'New post' })).not.toBeVisible();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test('viewer cannot access admin area', async ({ page }) => {
|
|
692
|
+
await page.goto('/admin/users');
|
|
693
|
+
await expect(page).toHaveURL(/\/(403|dashboard|login)/);
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## Recipe 7: Session Timeout Handling
|
|
701
|
+
|
|
702
|
+
### Complete Example
|
|
703
|
+
|
|
704
|
+
**TypeScript**
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
import { test, expect } from '@playwright/test';
|
|
708
|
+
|
|
709
|
+
test('redirects to login after session timeout', async ({ page, context }) => {
|
|
710
|
+
// Log in first
|
|
711
|
+
await page.goto('/login');
|
|
712
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
713
|
+
await page.getByLabel('Password').fill('SecurePass123!');
|
|
714
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
715
|
+
await expect(page).toHaveURL('/dashboard');
|
|
716
|
+
|
|
717
|
+
// Simulate session expiration by clearing the session cookie
|
|
718
|
+
const cookies = await context.cookies();
|
|
719
|
+
const sessionCookie = cookies.find(
|
|
720
|
+
(c) => c.name === 'session' || c.name === 'sid' || c.name === 'connect.sid'
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
if (sessionCookie) {
|
|
724
|
+
await context.clearCookies({ name: sessionCookie.name });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Try to navigate to a protected page
|
|
728
|
+
await page.goto('/settings');
|
|
729
|
+
|
|
730
|
+
// Should redirect to login with a return URL
|
|
731
|
+
await expect(page).toHaveURL(/\/login/);
|
|
732
|
+
await expect(page.getByText(/session.*expired|please.*log in again/i)).toBeVisible();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test('shows session expiry warning before timeout', async ({ page }) => {
|
|
736
|
+
// Mock the session check endpoint to return "expiring soon"
|
|
737
|
+
await page.route('**/api/auth/session', async (route) => {
|
|
738
|
+
await route.fulfill({
|
|
739
|
+
status: 200,
|
|
740
|
+
contentType: 'application/json',
|
|
741
|
+
body: JSON.stringify({
|
|
742
|
+
valid: true,
|
|
743
|
+
expiresIn: 120, // 2 minutes remaining
|
|
744
|
+
}),
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
await page.goto('/dashboard');
|
|
749
|
+
|
|
750
|
+
// The app should show a warning when session is about to expire
|
|
751
|
+
await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 });
|
|
752
|
+
await expect(page.getByRole('button', { name: /extend|stay logged in/i })).toBeVisible();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test('extends session when user clicks extend', async ({ page }) => {
|
|
756
|
+
let sessionExtended = false;
|
|
757
|
+
|
|
758
|
+
await page.route('**/api/auth/session', async (route) => {
|
|
759
|
+
await route.fulfill({
|
|
760
|
+
status: 200,
|
|
761
|
+
contentType: 'application/json',
|
|
762
|
+
body: JSON.stringify({ valid: true, expiresIn: 120 }),
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
await page.route('**/api/auth/refresh', async (route) => {
|
|
767
|
+
sessionExtended = true;
|
|
768
|
+
await route.fulfill({
|
|
769
|
+
status: 200,
|
|
770
|
+
contentType: 'application/json',
|
|
771
|
+
body: JSON.stringify({ valid: true, expiresIn: 3600 }),
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
await page.goto('/dashboard');
|
|
776
|
+
|
|
777
|
+
await expect(page.getByRole('button', { name: /extend|stay logged in/i })).toBeVisible({
|
|
778
|
+
timeout: 10000,
|
|
779
|
+
});
|
|
780
|
+
await page.getByRole('button', { name: /extend|stay logged in/i }).click();
|
|
781
|
+
|
|
782
|
+
expect(sessionExtended).toBe(true);
|
|
783
|
+
await expect(page.getByText(/session.*expir/i)).not.toBeVisible();
|
|
784
|
+
});
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
**JavaScript**
|
|
788
|
+
|
|
789
|
+
```javascript
|
|
790
|
+
const { test, expect } = require('@playwright/test');
|
|
791
|
+
|
|
792
|
+
test('redirects to login after session timeout', async ({ page, context }) => {
|
|
793
|
+
await page.goto('/login');
|
|
794
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
795
|
+
await page.getByLabel('Password').fill('SecurePass123!');
|
|
796
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
797
|
+
await expect(page).toHaveURL('/dashboard');
|
|
798
|
+
|
|
799
|
+
const cookies = await context.cookies();
|
|
800
|
+
const sessionCookie = cookies.find(
|
|
801
|
+
(c) => c.name === 'session' || c.name === 'sid' || c.name === 'connect.sid'
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
if (sessionCookie) {
|
|
805
|
+
await context.clearCookies({ name: sessionCookie.name });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
await page.goto('/settings');
|
|
809
|
+
|
|
810
|
+
await expect(page).toHaveURL(/\/login/);
|
|
811
|
+
await expect(page.getByText(/session.*expired|please.*log in again/i)).toBeVisible();
|
|
812
|
+
});
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
## Recipe 8: Logout
|
|
818
|
+
|
|
819
|
+
### Complete Example
|
|
820
|
+
|
|
821
|
+
**TypeScript**
|
|
822
|
+
|
|
823
|
+
```typescript
|
|
824
|
+
import { test, expect } from '@playwright/test';
|
|
825
|
+
|
|
826
|
+
// Use a logged-in state
|
|
827
|
+
test.use({ storageState: '.auth/user.json' });
|
|
828
|
+
|
|
829
|
+
test('logs out and redirects to login page', async ({ page }) => {
|
|
830
|
+
await page.goto('/dashboard');
|
|
831
|
+
await expect(page.getByText('Welcome')).toBeVisible();
|
|
832
|
+
|
|
833
|
+
// Open user menu and click logout
|
|
834
|
+
await page.getByRole('button', { name: /user menu|account|profile/i }).click();
|
|
835
|
+
await page.getByRole('menuitem', { name: 'Log out' }).click();
|
|
836
|
+
|
|
837
|
+
// Should redirect to login
|
|
838
|
+
await expect(page).toHaveURL('/login');
|
|
839
|
+
|
|
840
|
+
// Verify we can no longer access protected pages
|
|
841
|
+
await page.goto('/dashboard');
|
|
842
|
+
await expect(page).toHaveURL(/\/login/);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test('logout clears all session data', async ({ page, context }) => {
|
|
846
|
+
await page.goto('/dashboard');
|
|
847
|
+
|
|
848
|
+
await page.getByRole('button', { name: /user menu|account|profile/i }).click();
|
|
849
|
+
await page.getByRole('menuitem', { name: 'Log out' }).click();
|
|
850
|
+
|
|
851
|
+
await expect(page).toHaveURL('/login');
|
|
852
|
+
|
|
853
|
+
// Verify cookies are cleared
|
|
854
|
+
const cookies = await context.cookies();
|
|
855
|
+
const sessionCookies = cookies.filter(
|
|
856
|
+
(c) => c.name === 'session' || c.name === 'sid' || c.name === 'token'
|
|
857
|
+
);
|
|
858
|
+
expect(sessionCookies).toHaveLength(0);
|
|
859
|
+
|
|
860
|
+
// Verify localStorage is cleared
|
|
861
|
+
const token = await page.evaluate(() => localStorage.getItem('authToken'));
|
|
862
|
+
expect(token).toBeNull();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test('logout from all devices', async ({ page }) => {
|
|
866
|
+
let logoutAllCalled = false;
|
|
867
|
+
|
|
868
|
+
await page.route('**/api/auth/logout-all', async (route) => {
|
|
869
|
+
logoutAllCalled = true;
|
|
870
|
+
await route.fulfill({
|
|
871
|
+
status: 200,
|
|
872
|
+
contentType: 'application/json',
|
|
873
|
+
body: JSON.stringify({ message: 'Logged out from all devices' }),
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
await page.goto('/settings/security');
|
|
878
|
+
|
|
879
|
+
await page.getByRole('button', { name: 'Log out of all devices' }).click();
|
|
880
|
+
|
|
881
|
+
// Confirm the action in the dialog
|
|
882
|
+
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
|
|
883
|
+
|
|
884
|
+
expect(logoutAllCalled).toBe(true);
|
|
885
|
+
await expect(page).toHaveURL(/\/login/);
|
|
886
|
+
});
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
**JavaScript**
|
|
890
|
+
|
|
891
|
+
```javascript
|
|
892
|
+
const { test, expect } = require('@playwright/test');
|
|
893
|
+
|
|
894
|
+
test.use({ storageState: '.auth/user.json' });
|
|
895
|
+
|
|
896
|
+
test('logs out and redirects to login page', async ({ page }) => {
|
|
897
|
+
await page.goto('/dashboard');
|
|
898
|
+
|
|
899
|
+
await page.getByRole('button', { name: /user menu|account|profile/i }).click();
|
|
900
|
+
await page.getByRole('menuitem', { name: 'Log out' }).click();
|
|
901
|
+
|
|
902
|
+
await expect(page).toHaveURL('/login');
|
|
903
|
+
|
|
904
|
+
await page.goto('/dashboard');
|
|
905
|
+
await expect(page).toHaveURL(/\/login/);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test('logout clears all session data', async ({ page, context }) => {
|
|
909
|
+
await page.goto('/dashboard');
|
|
910
|
+
|
|
911
|
+
await page.getByRole('button', { name: /user menu|account|profile/i }).click();
|
|
912
|
+
await page.getByRole('menuitem', { name: 'Log out' }).click();
|
|
913
|
+
|
|
914
|
+
await expect(page).toHaveURL('/login');
|
|
915
|
+
|
|
916
|
+
const cookies = await context.cookies();
|
|
917
|
+
const sessionCookies = cookies.filter(
|
|
918
|
+
(c) => c.name === 'session' || c.name === 'sid' || c.name === 'token'
|
|
919
|
+
);
|
|
920
|
+
expect(sessionCookies).toHaveLength(0);
|
|
921
|
+
});
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
926
|
+
## Variations
|
|
927
|
+
|
|
928
|
+
### Login via API for Speed
|
|
929
|
+
|
|
930
|
+
Skip the UI for login when testing non-auth features. Use `request` context to get tokens faster.
|
|
931
|
+
|
|
932
|
+
**TypeScript**
|
|
933
|
+
|
|
934
|
+
```typescript
|
|
935
|
+
import { test as setup } from '@playwright/test';
|
|
936
|
+
|
|
937
|
+
setup('authenticate via API', async ({ request }) => {
|
|
938
|
+
const response = await request.post('/api/auth/login', {
|
|
939
|
+
data: {
|
|
940
|
+
email: 'user@example.com',
|
|
941
|
+
password: 'SecurePass123!',
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
expect(response.ok()).toBeTruthy();
|
|
946
|
+
|
|
947
|
+
// Save the auth state
|
|
948
|
+
await request.storageState({ path: '.auth/user.json' });
|
|
949
|
+
});
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
### Multi-Factor Authentication
|
|
953
|
+
|
|
954
|
+
```typescript
|
|
955
|
+
test('logs in with MFA (TOTP)', async ({ page }) => {
|
|
956
|
+
await page.goto('/login');
|
|
957
|
+
await page.getByLabel('Email').fill('mfa-user@example.com');
|
|
958
|
+
await page.getByLabel('Password').fill('SecurePass123!');
|
|
959
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
960
|
+
|
|
961
|
+
// MFA challenge screen appears
|
|
962
|
+
await expect(page.getByText('Enter verification code')).toBeVisible();
|
|
963
|
+
|
|
964
|
+
// In test environments, use a predictable TOTP code or mock the verification
|
|
965
|
+
await page.route('**/api/auth/verify-mfa', async (route) => {
|
|
966
|
+
await route.fulfill({
|
|
967
|
+
status: 200,
|
|
968
|
+
contentType: 'application/json',
|
|
969
|
+
body: JSON.stringify({ success: true, token: 'jwt-token' }),
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
await page.getByLabel('Verification code').fill('123456');
|
|
974
|
+
await page.getByRole('button', { name: 'Verify' }).click();
|
|
975
|
+
|
|
976
|
+
await expect(page).toHaveURL('/dashboard');
|
|
977
|
+
});
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
### Passkeys / WebAuthn (Playwright 1.61+)
|
|
981
|
+
|
|
982
|
+
Playwright 1.61 ships a virtual WebAuthn authenticator scoped to the browser context: `context.credentials`. Tests can register passkeys and answer `navigator.credentials.create()` / `navigator.credentials.get()` ceremonies without a hardware security key. Call `credentials.install()` before any page in the context starts a WebAuthn ceremony.
|
|
983
|
+
|
|
984
|
+
**Pattern A: let the app register a passkey, then sign in with it**
|
|
985
|
+
|
|
986
|
+
```typescript
|
|
987
|
+
test('registers a passkey and signs in with it', async ({ browser }) => {
|
|
988
|
+
const context = await browser.newContext();
|
|
989
|
+
await context.credentials.install(); // override navigator.credentials in this context
|
|
990
|
+
|
|
991
|
+
const page = await context.newPage();
|
|
992
|
+
|
|
993
|
+
// Register: the app's "Create a passkey" flow now talks to the virtual authenticator
|
|
994
|
+
await page.goto('/settings/security');
|
|
995
|
+
await page.getByRole('button', { name: 'Create a passkey' }).click();
|
|
996
|
+
await expect(page.getByText('Passkey added')).toBeVisible();
|
|
997
|
+
|
|
998
|
+
// Sign out, then sign back in with the passkey
|
|
999
|
+
await page.goto('/logout');
|
|
1000
|
+
await page.goto('/login');
|
|
1001
|
+
await page.getByRole('button', { name: 'Sign in with a passkey' }).click();
|
|
1002
|
+
// navigator.credentials.get() is answered with the registered passkey
|
|
1003
|
+
await expect(page).toHaveURL('/dashboard');
|
|
1004
|
+
|
|
1005
|
+
await context.close();
|
|
1006
|
+
});
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
**Pattern B: capture once, seed everywhere (the storageState pattern for passkeys)**
|
|
1010
|
+
|
|
1011
|
+
```typescript
|
|
1012
|
+
// setup project: register a passkey once and save it (it includes the private key)
|
|
1013
|
+
const [credential] = await context.credentials.get({ rpId: 'example.com' });
|
|
1014
|
+
fs.writeFileSync('playwright/.auth/passkey.json', JSON.stringify(credential));
|
|
1015
|
+
|
|
1016
|
+
// later tests: seed the captured passkey so the user is already enrolled
|
|
1017
|
+
const credential = JSON.parse(fs.readFileSync('playwright/.auth/passkey.json', 'utf8'));
|
|
1018
|
+
const context = await browser.newContext();
|
|
1019
|
+
await context.credentials.create(credential.rpId, credential);
|
|
1020
|
+
await context.credentials.install();
|
|
1021
|
+
// navigator.credentials.get() now resolves the seeded passkey — no registration UI needed
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
You can also seed a credential your backend already provisioned for a test user by passing `id`, `userHandle`, `privateKey` (base64url PKCS#8), and `publicKey` (base64url SPKI) to `credentials.create(rpId, …)`, and remove one with `credentials.delete(id)`.
|
|
1025
|
+
|
|
1026
|
+
**Tips:**
|
|
1027
|
+
- Treat saved passkey JSON like a saved `storageState` — it contains a private key, so keep it out of version control (`.gitignore` the `.auth/` directory).
|
|
1028
|
+
- Combine with a setup project (see Recipe 1) so passkey enrollment happens once per run, not per test.
|
|
1029
|
+
- Test the fallback path too: what happens when the user cancels the ceremony or has no passkey.
|
|
1030
|
+
|
|
1031
|
+
### Testing Auth with Different Browser Contexts
|
|
1032
|
+
|
|
1033
|
+
```typescript
|
|
1034
|
+
test('two users interact simultaneously', async ({ browser }) => {
|
|
1035
|
+
const adminContext = await browser.newContext({
|
|
1036
|
+
storageState: '.auth/admin.json',
|
|
1037
|
+
});
|
|
1038
|
+
const userContext = await browser.newContext({
|
|
1039
|
+
storageState: '.auth/viewer.json',
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
const adminPage = await adminContext.newPage();
|
|
1043
|
+
const userPage = await userContext.newPage();
|
|
1044
|
+
|
|
1045
|
+
// Admin bans a user
|
|
1046
|
+
await adminPage.goto('/admin/users');
|
|
1047
|
+
await adminPage.getByRole('row', { name: 'viewer@example.com' })
|
|
1048
|
+
.getByRole('button', { name: 'Ban' }).click();
|
|
1049
|
+
await adminPage.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
|
|
1050
|
+
|
|
1051
|
+
// User tries to navigate and should be blocked
|
|
1052
|
+
await userPage.goto('/dashboard');
|
|
1053
|
+
await expect(userPage.getByText(/account.*banned|access.*revoked/i)).toBeVisible();
|
|
1054
|
+
|
|
1055
|
+
await adminContext.close();
|
|
1056
|
+
await userContext.close();
|
|
1057
|
+
});
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
---
|
|
1061
|
+
|
|
1062
|
+
## Tips
|
|
1063
|
+
|
|
1064
|
+
1. **Use `storageState` for reusable auth**. Run login once in a setup project, save the state to a JSON file, and reuse it across all tests. This dramatically speeds up your test suite since you only log in once per role.
|
|
1065
|
+
|
|
1066
|
+
2. **Never hard-code credentials in test files**. Use environment variables or a `.env` file loaded via `dotenv`. In `playwright.config.ts`, set `process.env` values or use the `env` option in `use`.
|
|
1067
|
+
|
|
1068
|
+
3. **Prefer API-based login for non-auth tests**. Only test the login UI in your auth-specific tests. For everything else, authenticate via the API or by restoring `storageState`. This makes tests faster and less brittle.
|
|
1069
|
+
|
|
1070
|
+
4. **Test the negative paths too**. Invalid credentials, expired tokens, locked accounts, rate limiting. These are the scenarios users actually encounter in production and are frequently undertested.
|
|
1071
|
+
|
|
1072
|
+
5. **Set a shorter session timeout in your test environment**. If your app has a 30-minute session timeout, configure it to 30 seconds in the test environment so you can test timeout behavior without slow tests.
|
|
1073
|
+
|
|
1074
|
+
---
|
|
1075
|
+
|
|
1076
|
+
## Related
|
|
1077
|
+
|
|
1078
|
+
- [Playwright Authentication Docs](https://playwright.dev/docs/auth)
|
|
1079
|
+
- `patterns/page-objects.md` -- Encapsulate login flows in page objects
|
|
1080
|
+
- `foundations/assertions.md` -- Assertion patterns for auth states
|
|
1081
|
+
- `recipes/crud-testing.md` -- Test CRUD operations once authenticated
|