@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,1013 @@
1
+ # Testing Next.js Apps with Playwright
2
+
3
+ > **When to use**: Testing Next.js applications -- App Router, Pages Router, API routes, middleware, SSR pages, dynamic routes, and server components. This guide covers E2E testing patterns specific to Next.js behavior.
4
+ > **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md)
5
+
6
+ ## Quick Reference
7
+
8
+ ```bash
9
+ # Install Playwright in a Next.js project
10
+ npm init playwright@latest
11
+
12
+ # Run with Next.js dev server managed by Playwright
13
+ npx playwright test
14
+
15
+ # Run against a production build (recommended for CI)
16
+ npx playwright test --project=chromium
17
+
18
+ # Debug a single test with headed browser
19
+ npx playwright test tests/home.spec.ts --headed --debug
20
+ ```
21
+
22
+ ```
23
+ # .env.test — loaded by Next.js automatically when NODE_ENV=test
24
+ NEXT_PUBLIC_API_URL=http://localhost:3000/api
25
+ DATABASE_URL=postgresql://localhost:5432/test_db
26
+ NEXTAUTH_SECRET=test-secret-do-not-use-in-production
27
+ NEXTAUTH_URL=http://localhost:3000
28
+ ```
29
+
30
+ ## Setup
31
+
32
+ ### Playwright Config for Next.js
33
+
34
+ The single most important configuration detail: use `webServer` to let Playwright start and manage your Next.js server.
35
+
36
+ **TypeScript**
37
+ ```typescript
38
+ // playwright.config.ts
39
+ import { defineConfig, devices } from '@playwright/test';
40
+ import path from 'path';
41
+
42
+ export default defineConfig({
43
+ testDir: './tests',
44
+ testMatch: '**/*.spec.ts',
45
+ fullyParallel: true,
46
+ forbidOnly: !!process.env.CI,
47
+ retries: process.env.CI ? 2 : 0,
48
+ workers: process.env.CI ? '50%' : undefined,
49
+
50
+ use: {
51
+ baseURL: 'http://localhost:3000',
52
+ trace: 'on-first-retry',
53
+ screenshot: 'only-on-failure',
54
+ },
55
+
56
+ projects: [
57
+ {
58
+ name: 'chromium',
59
+ use: { ...devices['Desktop Chrome'] },
60
+ },
61
+ {
62
+ name: 'mobile',
63
+ use: { ...devices['iPhone 14'] },
64
+ },
65
+ ],
66
+
67
+ webServer: {
68
+ command: process.env.CI
69
+ ? 'npm run build && npm run start' // production build in CI
70
+ : 'npm run dev', // dev server locally
71
+ url: 'http://localhost:3000',
72
+ reuseExistingServer: !process.env.CI,
73
+ timeout: 120_000,
74
+ env: {
75
+ NODE_ENV: process.env.CI ? 'production' : 'test',
76
+ },
77
+ },
78
+ });
79
+ ```
80
+
81
+ **JavaScript**
82
+ ```javascript
83
+ // playwright.config.js
84
+ const { defineConfig, devices } = require('@playwright/test');
85
+
86
+ module.exports = defineConfig({
87
+ testDir: './tests',
88
+ testMatch: '**/*.spec.js',
89
+ fullyParallel: true,
90
+ forbidOnly: !!process.env.CI,
91
+ retries: process.env.CI ? 2 : 0,
92
+ workers: process.env.CI ? '50%' : undefined,
93
+
94
+ use: {
95
+ baseURL: 'http://localhost:3000',
96
+ trace: 'on-first-retry',
97
+ screenshot: 'only-on-failure',
98
+ },
99
+
100
+ projects: [
101
+ {
102
+ name: 'chromium',
103
+ use: { ...devices['Desktop Chrome'] },
104
+ },
105
+ {
106
+ name: 'mobile',
107
+ use: { ...devices['iPhone 14'] },
108
+ },
109
+ ],
110
+
111
+ webServer: {
112
+ command: process.env.CI
113
+ ? 'npm run build && npm run start'
114
+ : 'npm run dev',
115
+ url: 'http://localhost:3000',
116
+ reuseExistingServer: !process.env.CI,
117
+ timeout: 120_000,
118
+ env: {
119
+ NODE_ENV: process.env.CI ? 'production' : 'test',
120
+ },
121
+ },
122
+ });
123
+ ```
124
+
125
+ ### Environment Variables with `.env.test`
126
+
127
+ Next.js loads `.env.test` automatically when `NODE_ENV=test`. Use this for test-specific overrides.
128
+
129
+ ```bash
130
+ # .env.test (commit this -- no real secrets)
131
+ NEXT_PUBLIC_API_URL=http://localhost:3000/api
132
+ NEXT_PUBLIC_FEATURE_FLAG_NEW_CHECKOUT=true
133
+ DATABASE_URL=postgresql://localhost:5432/test_db
134
+
135
+ # .env.test.local (gitignored -- real test secrets)
136
+ NEXTAUTH_SECRET=test-secret-local
137
+ STRIPE_TEST_KEY=sk_test_xxx
138
+ ```
139
+
140
+ ```bash
141
+ # .gitignore
142
+ .env*.local
143
+ playwright-report/
144
+ playwright/.auth/
145
+ test-results/
146
+ ```
147
+
148
+ ## Patterns
149
+
150
+ ### Testing App Router Pages
151
+
152
+ **Use when**: Testing pages built with the Next.js App Router (`app/` directory). App Router pages are server components by default and may include streaming, suspense boundaries, and loading states.
153
+ **Avoid when**: You need to test isolated server component logic -- use unit tests for that. E2E tests verify the rendered result.
154
+
155
+ **TypeScript**
156
+ ```typescript
157
+ import { test, expect } from '@playwright/test';
158
+
159
+ test.describe('App Router pages', () => {
160
+ test('home page renders server component content', async ({ page }) => {
161
+ await page.goto('/');
162
+
163
+ // Server components render on the server -- by the time Playwright
164
+ // sees the page, SSR content is already in the HTML
165
+ await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
166
+ await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
167
+ });
168
+
169
+ test('loading state shows while data streams in', async ({ page }) => {
170
+ // Slow down the API to expose the loading state
171
+ await page.route('**/api/dashboard/stats', async (route) => {
172
+ await new Promise((resolve) => setTimeout(resolve, 2000));
173
+ await route.continue();
174
+ });
175
+
176
+ await page.goto('/dashboard');
177
+
178
+ // Verify the loading skeleton appears during streaming
179
+ await expect(page.getByRole('progressbar')).toBeVisible();
180
+
181
+ // Then verify the real content replaces it
182
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
183
+ await expect(page.getByRole('progressbar')).toBeHidden();
184
+ });
185
+
186
+ test('suspense boundary shows fallback then resolves', async ({ page }) => {
187
+ await page.goto('/products');
188
+
189
+ // The product list may be inside a Suspense boundary
190
+ // Playwright auto-waits, so just assert the final state
191
+ await expect(page.getByRole('listitem')).toHaveCount(12);
192
+ });
193
+
194
+ test('nested layouts persist across navigation', async ({ page }) => {
195
+ await page.goto('/dashboard/analytics');
196
+
197
+ // Verify the dashboard layout sidebar is visible
198
+ const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
199
+ await expect(sidebar).toBeVisible();
200
+
201
+ // Navigate to a sibling route -- layout should persist (no full reload)
202
+ await sidebar.getByRole('link', { name: 'Settings' }).click();
203
+ await page.waitForURL('/dashboard/settings');
204
+
205
+ // Sidebar is still there -- layout was not re-mounted
206
+ await expect(sidebar).toBeVisible();
207
+ await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
208
+ });
209
+ });
210
+ ```
211
+
212
+ **JavaScript**
213
+ ```javascript
214
+ const { test, expect } = require('@playwright/test');
215
+
216
+ test.describe('App Router pages', () => {
217
+ test('home page renders server component content', async ({ page }) => {
218
+ await page.goto('/');
219
+
220
+ await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
221
+ await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
222
+ });
223
+
224
+ test('loading state shows while data streams in', async ({ page }) => {
225
+ await page.route('**/api/dashboard/stats', async (route) => {
226
+ await new Promise((resolve) => setTimeout(resolve, 2000));
227
+ await route.continue();
228
+ });
229
+
230
+ await page.goto('/dashboard');
231
+
232
+ await expect(page.getByRole('progressbar')).toBeVisible();
233
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
234
+ await expect(page.getByRole('progressbar')).toBeHidden();
235
+ });
236
+
237
+ test('suspense boundary shows fallback then resolves', async ({ page }) => {
238
+ await page.goto('/products');
239
+
240
+ await expect(page.getByRole('listitem')).toHaveCount(12);
241
+ });
242
+
243
+ test('nested layouts persist across navigation', async ({ page }) => {
244
+ await page.goto('/dashboard/analytics');
245
+
246
+ const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
247
+ await expect(sidebar).toBeVisible();
248
+
249
+ await sidebar.getByRole('link', { name: 'Settings' }).click();
250
+ await page.waitForURL('/dashboard/settings');
251
+
252
+ await expect(sidebar).toBeVisible();
253
+ await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
254
+ });
255
+ });
256
+ ```
257
+
258
+ ### Testing Pages Router (getServerSideProps / getStaticProps)
259
+
260
+ **Use when**: Testing pages built with the Pages Router (`pages/` directory) that use `getServerSideProps` or `getStaticProps` for data fetching.
261
+ **Avoid when**: Testing the data fetching functions directly -- that is a unit test concern. E2E tests verify what the user sees.
262
+
263
+ **TypeScript**
264
+ ```typescript
265
+ import { test, expect } from '@playwright/test';
266
+
267
+ test.describe('Pages Router with SSR', () => {
268
+ test('page with getServerSideProps renders fetched data', async ({ page }) => {
269
+ await page.goto('/blog');
270
+
271
+ // getServerSideProps fetches posts on the server -- verify they render
272
+ await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
273
+ await expect(page.getByRole('article')).toHaveCount(10);
274
+
275
+ // Verify server-fetched data appears (not a loading skeleton)
276
+ await expect(page.getByRole('article').first()).toContainText(/\w+/);
277
+ });
278
+
279
+ test('page with getStaticProps shows pre-rendered content', async ({ page }) => {
280
+ await page.goto('/about');
281
+
282
+ // Static pages are pre-rendered at build time -- content is immediate
283
+ await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
284
+ await expect(page.getByText('Founded in 2020')).toBeVisible();
285
+ });
286
+
287
+ test('client-side navigation with next/link preserves SPA behavior', async ({ page }) => {
288
+ await page.goto('/blog');
289
+
290
+ // Click a next/link -- this should be a client-side transition, not a full reload
291
+ const navigationPromise = page.waitForURL('/blog/my-first-post');
292
+ await page.getByRole('link', { name: 'My First Post' }).click();
293
+ await navigationPromise;
294
+
295
+ await expect(page.getByRole('heading', { name: 'My First Post', level: 1 })).toBeVisible();
296
+ });
297
+ });
298
+ ```
299
+
300
+ **JavaScript**
301
+ ```javascript
302
+ const { test, expect } = require('@playwright/test');
303
+
304
+ test.describe('Pages Router with SSR', () => {
305
+ test('page with getServerSideProps renders fetched data', async ({ page }) => {
306
+ await page.goto('/blog');
307
+
308
+ await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
309
+ await expect(page.getByRole('article')).toHaveCount(10);
310
+ await expect(page.getByRole('article').first()).toContainText(/\w+/);
311
+ });
312
+
313
+ test('page with getStaticProps shows pre-rendered content', async ({ page }) => {
314
+ await page.goto('/about');
315
+
316
+ await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
317
+ await expect(page.getByText('Founded in 2020')).toBeVisible();
318
+ });
319
+
320
+ test('client-side navigation with next/link preserves SPA behavior', async ({ page }) => {
321
+ await page.goto('/blog');
322
+
323
+ const navigationPromise = page.waitForURL('/blog/my-first-post');
324
+ await page.getByRole('link', { name: 'My First Post' }).click();
325
+ await navigationPromise;
326
+
327
+ await expect(page.getByRole('heading', { name: 'My First Post', level: 1 })).toBeVisible();
328
+ });
329
+ });
330
+ ```
331
+
332
+ ### Testing Dynamic Routes (`[slug]`, `[...catchAll]`)
333
+
334
+ **Use when**: Testing pages with dynamic segments like `/blog/[slug]`, `/products/[id]`, or catch-all routes like `/docs/[...path]`.
335
+ **Avoid when**: The route is static -- no dynamic segments involved.
336
+
337
+ **TypeScript**
338
+ ```typescript
339
+ import { test, expect } from '@playwright/test';
340
+
341
+ test.describe('dynamic routes', () => {
342
+ test('dynamic [slug] page renders correct content', async ({ page }) => {
343
+ await page.goto('/blog/nextjs-testing-guide');
344
+
345
+ await expect(page.getByRole('heading', { level: 1 })).toContainText('Next.js Testing Guide');
346
+ // Verify the slug maps to the correct content, not a 404
347
+ await expect(page.getByText('Page not found')).toBeHidden();
348
+ });
349
+
350
+ test('non-existent slug shows 404 page', async ({ page }) => {
351
+ const response = await page.goto('/blog/this-post-does-not-exist');
352
+
353
+ // Next.js returns 404 for pages that call notFound() or return { notFound: true }
354
+ expect(response?.status()).toBe(404);
355
+ await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
356
+ });
357
+
358
+ test('catch-all route handles nested paths', async ({ page }) => {
359
+ await page.goto('/docs/getting-started/installation');
360
+
361
+ await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
362
+
363
+ // Navigate to a different docs path
364
+ await page.goto('/docs/api/configuration');
365
+ await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
366
+ });
367
+
368
+ test('dynamic route with query parameters', async ({ page }) => {
369
+ await page.goto('/products?category=electronics&sort=price-asc');
370
+
371
+ await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
372
+ // Verify sort order is applied
373
+ const prices = await page.getByTestId('product-price').allTextContents();
374
+ const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
375
+ expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
376
+ });
377
+ });
378
+ ```
379
+
380
+ **JavaScript**
381
+ ```javascript
382
+ const { test, expect } = require('@playwright/test');
383
+
384
+ test.describe('dynamic routes', () => {
385
+ test('dynamic [slug] page renders correct content', async ({ page }) => {
386
+ await page.goto('/blog/nextjs-testing-guide');
387
+
388
+ await expect(page.getByRole('heading', { level: 1 })).toContainText('Next.js Testing Guide');
389
+ await expect(page.getByText('Page not found')).toBeHidden();
390
+ });
391
+
392
+ test('non-existent slug shows 404 page', async ({ page }) => {
393
+ const response = await page.goto('/blog/this-post-does-not-exist');
394
+
395
+ expect(response?.status()).toBe(404);
396
+ await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
397
+ });
398
+
399
+ test('catch-all route handles nested paths', async ({ page }) => {
400
+ await page.goto('/docs/getting-started/installation');
401
+
402
+ await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
403
+
404
+ await page.goto('/docs/api/configuration');
405
+ await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
406
+ });
407
+
408
+ test('dynamic route with query parameters', async ({ page }) => {
409
+ await page.goto('/products?category=electronics&sort=price-asc');
410
+
411
+ await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
412
+ const prices = await page.getByTestId('product-price').allTextContents();
413
+ const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
414
+ expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
415
+ });
416
+ });
417
+ ```
418
+
419
+ ### Testing API Routes
420
+
421
+ **Use when**: Testing Next.js API routes (`app/api/` or `pages/api/`) directly with Playwright's `request` context, or indirectly through UI interactions that call them.
422
+ **Avoid when**: Unit testing API handler logic in isolation -- use a unit testing framework for that.
423
+
424
+ **TypeScript**
425
+ ```typescript
426
+ import { test, expect } from '@playwright/test';
427
+
428
+ test.describe('API routes -- direct testing', () => {
429
+ test('GET /api/products returns product list', async ({ request }) => {
430
+ const response = await request.get('/api/products');
431
+
432
+ expect(response.ok()).toBeTruthy();
433
+ const body = await response.json();
434
+ expect(body.products).toBeInstanceOf(Array);
435
+ expect(body.products.length).toBeGreaterThan(0);
436
+ expect(body.products[0]).toHaveProperty('id');
437
+ expect(body.products[0]).toHaveProperty('name');
438
+ expect(body.products[0]).toHaveProperty('price');
439
+ });
440
+
441
+ test('POST /api/products creates a new product', async ({ request }) => {
442
+ const response = await request.post('/api/products', {
443
+ data: {
444
+ name: 'Test Product',
445
+ price: 29.99,
446
+ description: 'Created by Playwright',
447
+ },
448
+ });
449
+
450
+ expect(response.status()).toBe(201);
451
+ const body = await response.json();
452
+ expect(body.product.name).toBe('Test Product');
453
+ });
454
+
455
+ test('POST /api/products validates required fields', async ({ request }) => {
456
+ const response = await request.post('/api/products', {
457
+ data: { name: '' }, // missing required fields
458
+ });
459
+
460
+ expect(response.status()).toBe(400);
461
+ const body = await response.json();
462
+ expect(body.error).toContainEqual(
463
+ expect.objectContaining({ field: 'price' })
464
+ );
465
+ });
466
+ });
467
+
468
+ test.describe('API routes -- indirect through UI', () => {
469
+ test('form submission calls API and shows result', async ({ page }) => {
470
+ await page.goto('/products/new');
471
+
472
+ await page.getByLabel('Product name').fill('Widget');
473
+ await page.getByLabel('Price').fill('19.99');
474
+ await page.getByRole('button', { name: 'Create product' }).click();
475
+
476
+ // The UI calls POST /api/products internally
477
+ await expect(page.getByText('Product created successfully')).toBeVisible();
478
+ await page.waitForURL('/products/**');
479
+ });
480
+ });
481
+ ```
482
+
483
+ **JavaScript**
484
+ ```javascript
485
+ const { test, expect } = require('@playwright/test');
486
+
487
+ test.describe('API routes -- direct testing', () => {
488
+ test('GET /api/products returns product list', async ({ request }) => {
489
+ const response = await request.get('/api/products');
490
+
491
+ expect(response.ok()).toBeTruthy();
492
+ const body = await response.json();
493
+ expect(body.products).toBeInstanceOf(Array);
494
+ expect(body.products.length).toBeGreaterThan(0);
495
+ expect(body.products[0]).toHaveProperty('id');
496
+ expect(body.products[0]).toHaveProperty('name');
497
+ expect(body.products[0]).toHaveProperty('price');
498
+ });
499
+
500
+ test('POST /api/products creates a new product', async ({ request }) => {
501
+ const response = await request.post('/api/products', {
502
+ data: {
503
+ name: 'Test Product',
504
+ price: 29.99,
505
+ description: 'Created by Playwright',
506
+ },
507
+ });
508
+
509
+ expect(response.status()).toBe(201);
510
+ const body = await response.json();
511
+ expect(body.product.name).toBe('Test Product');
512
+ });
513
+
514
+ test('POST /api/products validates required fields', async ({ request }) => {
515
+ const response = await request.post('/api/products', {
516
+ data: { name: '' },
517
+ });
518
+
519
+ expect(response.status()).toBe(400);
520
+ const body = await response.json();
521
+ expect(body.error).toContainEqual(
522
+ expect.objectContaining({ field: 'price' })
523
+ );
524
+ });
525
+ });
526
+
527
+ test.describe('API routes -- indirect through UI', () => {
528
+ test('form submission calls API and shows result', async ({ page }) => {
529
+ await page.goto('/products/new');
530
+
531
+ await page.getByLabel('Product name').fill('Widget');
532
+ await page.getByLabel('Price').fill('19.99');
533
+ await page.getByRole('button', { name: 'Create product' }).click();
534
+
535
+ await expect(page.getByText('Product created successfully')).toBeVisible();
536
+ await page.waitForURL('/products/**');
537
+ });
538
+ });
539
+ ```
540
+
541
+ ### Testing Middleware
542
+
543
+ **Use when**: Testing Next.js middleware that handles redirects, rewrites, authentication guards, geolocation-based routing, or header manipulation.
544
+ **Avoid when**: The middleware logic is trivial -- a redirect from `/old` to `/new` can be verified with a simple navigation test.
545
+
546
+ **TypeScript**
547
+ ```typescript
548
+ import { test, expect } from '@playwright/test';
549
+
550
+ test.describe('middleware', () => {
551
+ test('unauthenticated user is redirected to login', async ({ page }) => {
552
+ // Visit a protected page without auth cookies
553
+ const response = await page.goto('/dashboard');
554
+
555
+ // Middleware should redirect to /login
556
+ expect(page.url()).toContain('/login');
557
+ await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
558
+ });
559
+
560
+ test('middleware redirect preserves the return URL', async ({ page }) => {
561
+ await page.goto('/dashboard/settings');
562
+
563
+ // Should redirect to login with a callbackUrl or returnTo parameter
564
+ const url = new URL(page.url());
565
+ expect(url.pathname).toBe('/login');
566
+ expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
567
+ .toContain('/dashboard/settings');
568
+ });
569
+
570
+ test('middleware sets security headers', async ({ page }) => {
571
+ const response = await page.goto('/');
572
+
573
+ const headers = response!.headers();
574
+ expect(headers['x-frame-options']).toBe('DENY');
575
+ expect(headers['x-content-type-options']).toBe('nosniff');
576
+ expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
577
+ });
578
+
579
+ test('middleware rewrites based on locale', async ({ page, context }) => {
580
+ // Set Accept-Language header to simulate a French user
581
+ await context.setExtraHTTPHeaders({
582
+ 'Accept-Language': 'fr-FR,fr;q=0.9',
583
+ });
584
+
585
+ await page.goto('/');
586
+
587
+ // Middleware should rewrite to the French locale
588
+ await expect(page.getByText('Bienvenue')).toBeVisible();
589
+ });
590
+
591
+ test('middleware blocks unauthorized API access', async ({ request }) => {
592
+ // Call a protected API route without authentication
593
+ const response = await request.get('/api/admin/users');
594
+
595
+ expect(response.status()).toBe(401);
596
+ });
597
+ });
598
+ ```
599
+
600
+ **JavaScript**
601
+ ```javascript
602
+ const { test, expect } = require('@playwright/test');
603
+
604
+ test.describe('middleware', () => {
605
+ test('unauthenticated user is redirected to login', async ({ page }) => {
606
+ const response = await page.goto('/dashboard');
607
+
608
+ expect(page.url()).toContain('/login');
609
+ await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
610
+ });
611
+
612
+ test('middleware redirect preserves the return URL', async ({ page }) => {
613
+ await page.goto('/dashboard/settings');
614
+
615
+ const url = new URL(page.url());
616
+ expect(url.pathname).toBe('/login');
617
+ expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
618
+ .toContain('/dashboard/settings');
619
+ });
620
+
621
+ test('middleware sets security headers', async ({ page }) => {
622
+ const response = await page.goto('/');
623
+
624
+ const headers = response.headers();
625
+ expect(headers['x-frame-options']).toBe('DENY');
626
+ expect(headers['x-content-type-options']).toBe('nosniff');
627
+ expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
628
+ });
629
+
630
+ test('middleware rewrites based on locale', async ({ page, context }) => {
631
+ await context.setExtraHTTPHeaders({
632
+ 'Accept-Language': 'fr-FR,fr;q=0.9',
633
+ });
634
+
635
+ await page.goto('/');
636
+
637
+ await expect(page.getByText('Bienvenue')).toBeVisible();
638
+ });
639
+
640
+ test('middleware blocks unauthorized API access', async ({ request }) => {
641
+ const response = await request.get('/api/admin/users');
642
+
643
+ expect(response.status()).toBe(401);
644
+ });
645
+ });
646
+ ```
647
+
648
+ ### Testing Hydration and SSR/CSR Consistency
649
+
650
+ **Use when**: Verifying that server-rendered HTML matches the client-side hydrated output. Hydration mismatches cause visual flicker, broken interactivity, or React errors in the console.
651
+ **Avoid when**: The page has no interactive client components -- pure server components do not hydrate.
652
+
653
+ **TypeScript**
654
+ ```typescript
655
+ import { test, expect } from '@playwright/test';
656
+
657
+ test.describe('hydration', () => {
658
+ test('no hydration errors in console', async ({ page }) => {
659
+ const consoleErrors: string[] = [];
660
+ page.on('console', (msg) => {
661
+ if (msg.type() === 'error') {
662
+ consoleErrors.push(msg.text());
663
+ }
664
+ });
665
+
666
+ await page.goto('/');
667
+ // Wait for hydration to complete -- interactive elements become clickable
668
+ await page.getByRole('button', { name: 'Get started' }).click();
669
+
670
+ // Filter for hydration-specific errors
671
+ const hydrationErrors = consoleErrors.filter(
672
+ (e) =>
673
+ e.includes('Hydration') ||
674
+ e.includes('hydration') ||
675
+ e.includes('server-rendered') ||
676
+ e.includes('did not match')
677
+ );
678
+ expect(hydrationErrors).toEqual([]);
679
+ });
680
+
681
+ test('interactive elements work after hydration', async ({ page }) => {
682
+ await page.goto('/');
683
+
684
+ // This button relies on a client component event handler
685
+ // If hydration fails, the click will do nothing
686
+ const counter = page.getByTestId('counter-value');
687
+ await expect(counter).toHaveText('0');
688
+
689
+ await page.getByRole('button', { name: 'Increment' }).click();
690
+ await expect(counter).toHaveText('1');
691
+ });
692
+
693
+ test('date/time renders without hydration mismatch', async ({ page }) => {
694
+ // Dates are a common source of hydration mismatch because server
695
+ // and client may be in different timezones
696
+ await page.goto('/dashboard');
697
+
698
+ // Verify the date displays without flicker
699
+ const dateElement = page.getByTestId('current-date');
700
+ await expect(dateElement).toBeVisible();
701
+ // Verify it contains a plausible date format, not "undefined" or garbled text
702
+ await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/);
703
+ });
704
+ });
705
+ ```
706
+
707
+ **JavaScript**
708
+ ```javascript
709
+ const { test, expect } = require('@playwright/test');
710
+
711
+ test.describe('hydration', () => {
712
+ test('no hydration errors in console', async ({ page }) => {
713
+ const consoleErrors = [];
714
+ page.on('console', (msg) => {
715
+ if (msg.type() === 'error') {
716
+ consoleErrors.push(msg.text());
717
+ }
718
+ });
719
+
720
+ await page.goto('/');
721
+ await page.getByRole('button', { name: 'Get started' }).click();
722
+
723
+ const hydrationErrors = consoleErrors.filter(
724
+ (e) =>
725
+ e.includes('Hydration') ||
726
+ e.includes('hydration') ||
727
+ e.includes('server-rendered') ||
728
+ e.includes('did not match')
729
+ );
730
+ expect(hydrationErrors).toEqual([]);
731
+ });
732
+
733
+ test('interactive elements work after hydration', async ({ page }) => {
734
+ await page.goto('/');
735
+
736
+ const counter = page.getByTestId('counter-value');
737
+ await expect(counter).toHaveText('0');
738
+
739
+ await page.getByRole('button', { name: 'Increment' }).click();
740
+ await expect(counter).toHaveText('1');
741
+ });
742
+
743
+ test('date/time renders without hydration mismatch', async ({ page }) => {
744
+ await page.goto('/dashboard');
745
+
746
+ const dateElement = page.getByTestId('current-date');
747
+ await expect(dateElement).toBeVisible();
748
+ await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/);
749
+ });
750
+ });
751
+ ```
752
+
753
+ ### Testing next/image Optimization
754
+
755
+ **Use when**: Verifying that `next/image` components render correctly, lazy load offscreen images, and serve optimized formats.
756
+ **Avoid when**: You do not use `next/image` or image optimization is not a concern for your test.
757
+
758
+ **TypeScript**
759
+ ```typescript
760
+ import { test, expect } from '@playwright/test';
761
+
762
+ test.describe('next/image', () => {
763
+ test('hero image loads with correct attributes', async ({ page }) => {
764
+ await page.goto('/');
765
+
766
+ const heroImage = page.getByRole('img', { name: 'Hero banner' });
767
+ await expect(heroImage).toBeVisible();
768
+
769
+ // Verify next/image sets srcset for responsive loading
770
+ const srcset = await heroImage.getAttribute('srcset');
771
+ expect(srcset).toBeTruthy();
772
+ expect(srcset).toContain('w='); // next/image adds width descriptors
773
+
774
+ // Verify priority images are not lazy-loaded
775
+ const loading = await heroImage.getAttribute('loading');
776
+ expect(loading).not.toBe('lazy'); // priority images use eager loading
777
+ });
778
+
779
+ test('offscreen images lazy load on scroll', async ({ page }) => {
780
+ await page.goto('/gallery');
781
+
782
+ // Get an image that is below the fold
783
+ const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
784
+
785
+ // Before scroll: image should not have loaded its src yet
786
+ const initialSrc = await offscreenImage.getAttribute('src');
787
+ // next/image uses a blur placeholder or empty src for lazy images
788
+
789
+ // Scroll the image into view
790
+ await offscreenImage.scrollIntoViewIfNeeded();
791
+ await expect(offscreenImage).toBeVisible();
792
+
793
+ // Verify the image has loaded (naturalWidth > 0 means the image loaded)
794
+ const naturalWidth = await offscreenImage.evaluate(
795
+ (img: HTMLImageElement) => img.naturalWidth
796
+ );
797
+ expect(naturalWidth).toBeGreaterThan(0);
798
+ });
799
+ });
800
+ ```
801
+
802
+ **JavaScript**
803
+ ```javascript
804
+ const { test, expect } = require('@playwright/test');
805
+
806
+ test.describe('next/image', () => {
807
+ test('hero image loads with correct attributes', async ({ page }) => {
808
+ await page.goto('/');
809
+
810
+ const heroImage = page.getByRole('img', { name: 'Hero banner' });
811
+ await expect(heroImage).toBeVisible();
812
+
813
+ const srcset = await heroImage.getAttribute('srcset');
814
+ expect(srcset).toBeTruthy();
815
+ expect(srcset).toContain('w=');
816
+
817
+ const loading = await heroImage.getAttribute('loading');
818
+ expect(loading).not.toBe('lazy');
819
+ });
820
+
821
+ test('offscreen images lazy load on scroll', async ({ page }) => {
822
+ await page.goto('/gallery');
823
+
824
+ const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
825
+
826
+ await offscreenImage.scrollIntoViewIfNeeded();
827
+ await expect(offscreenImage).toBeVisible();
828
+
829
+ const naturalWidth = await offscreenImage.evaluate(
830
+ (img) => img.naturalWidth
831
+ );
832
+ expect(naturalWidth).toBeGreaterThan(0);
833
+ });
834
+ });
835
+ ```
836
+
837
+ ### Authentication with NextAuth.js / Auth.js
838
+
839
+ **Use when**: Testing login flows in Next.js apps using NextAuth.js or Auth.js. Use a setup project to authenticate once, then reuse `storageState` across tests.
840
+ **Avoid when**: Your app does not use session-based authentication.
841
+
842
+ **TypeScript**
843
+ ```typescript
844
+ // playwright.config.ts (auth-specific excerpt)
845
+ import { defineConfig } from '@playwright/test';
846
+
847
+ export default defineConfig({
848
+ projects: [
849
+ {
850
+ name: 'setup',
851
+ testMatch: /auth\.setup\.ts/,
852
+ },
853
+ {
854
+ name: 'authenticated',
855
+ use: { storageState: 'playwright/.auth/user.json' },
856
+ dependencies: ['setup'],
857
+ },
858
+ {
859
+ name: 'unauthenticated',
860
+ // No storageState -- tests run as logged-out user
861
+ testMatch: '**/*.unauth.spec.ts',
862
+ },
863
+ ],
864
+ });
865
+ ```
866
+
867
+ ```typescript
868
+ // tests/auth.setup.ts
869
+ import { test as setup, expect } from '@playwright/test';
870
+
871
+ const authFile = 'playwright/.auth/user.json';
872
+
873
+ setup('authenticate via credentials', async ({ page }) => {
874
+ await page.goto('/login');
875
+ await page.getByLabel('Email').fill('test@example.com');
876
+ await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
877
+ await page.getByRole('button', { name: 'Sign in' }).click();
878
+
879
+ // Wait for the redirect after successful login
880
+ await page.waitForURL('/dashboard');
881
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
882
+
883
+ // Save authentication state (cookies + localStorage)
884
+ await page.context().storageState({ path: authFile });
885
+ });
886
+ ```
887
+
888
+ ```typescript
889
+ // tests/dashboard.spec.ts
890
+ import { test, expect } from '@playwright/test';
891
+
892
+ // This test runs with the authenticated storageState from the setup project
893
+ test('authenticated user sees dashboard', async ({ page }) => {
894
+ await page.goto('/dashboard');
895
+
896
+ // No login redirect -- auth cookies are already set
897
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
898
+ await expect(page.getByText('test@example.com')).toBeVisible();
899
+ });
900
+ ```
901
+
902
+ **JavaScript**
903
+ ```javascript
904
+ // tests/auth.setup.js
905
+ const { test: setup, expect } = require('@playwright/test');
906
+
907
+ const authFile = 'playwright/.auth/user.json';
908
+
909
+ setup('authenticate via credentials', async ({ page }) => {
910
+ await page.goto('/login');
911
+ await page.getByLabel('Email').fill('test@example.com');
912
+ await page.getByLabel('Password').fill(process.env.TEST_PASSWORD);
913
+ await page.getByRole('button', { name: 'Sign in' }).click();
914
+
915
+ await page.waitForURL('/dashboard');
916
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
917
+
918
+ await page.context().storageState({ path: authFile });
919
+ });
920
+ ```
921
+
922
+ ```javascript
923
+ // tests/dashboard.spec.js
924
+ const { test, expect } = require('@playwright/test');
925
+
926
+ test('authenticated user sees dashboard', async ({ page }) => {
927
+ await page.goto('/dashboard');
928
+
929
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
930
+ await expect(page.getByText('test@example.com')).toBeVisible();
931
+ });
932
+ ```
933
+
934
+ ## Framework-Specific Tips
935
+
936
+ ### Dev Server vs Production Build
937
+
938
+ | Scenario | Command | Trade-off |
939
+ |---|---|---|
940
+ | Local development | `npm run dev` | Hot reload, fast iteration, but does not test production behavior (minification, optimization, middleware edge runtime) |
941
+ | CI pipeline | `npm run build && npm run start` | Tests the real production bundle; catches build errors, middleware edge cases |
942
+ | Quick smoke test | `npm run dev` in CI with `reuseExistingServer: false` | Faster CI but misses production-only bugs |
943
+
944
+ **Recommendation**: Use `npm run dev` locally for fast feedback. Use `npm run build && npm run start` in CI to test the real production artifact.
945
+
946
+ ### Server Components Cannot Be Tested in Isolation
947
+
948
+ Next.js server components run on the server and produce HTML. Playwright tests the rendered output. You cannot import and render a server component in a Playwright test. Instead:
949
+
950
+ 1. Test the final rendered HTML through navigation (`page.goto`)
951
+ 2. Verify that server-fetched data appears on the page
952
+ 3. Use API route tests to validate the data layer separately
953
+
954
+ ### Handling Next.js Redirects
955
+
956
+ Next.js redirects (configured in `next.config.js`, middleware, or `redirect()` in server actions) are transparent to Playwright. After `page.goto()`, check `page.url()` to verify the final destination.
957
+
958
+ ### Turbopack Compatibility
959
+
960
+ If using Turbopack (`next dev --turbopack`), update your `webServer.command`:
961
+
962
+ ```typescript
963
+ webServer: {
964
+ command: process.env.CI
965
+ ? 'npm run build && npm run start'
966
+ : 'npx next dev --turbopack',
967
+ url: 'http://localhost:3000',
968
+ reuseExistingServer: !process.env.CI,
969
+ },
970
+ ```
971
+
972
+ ### Multiple webServer Entries (Next.js + API Backend)
973
+
974
+ If your Next.js app consumes a separate backend API:
975
+
976
+ ```typescript
977
+ webServer: [
978
+ {
979
+ command: 'npm run dev:api',
980
+ url: 'http://localhost:4000/health',
981
+ reuseExistingServer: !process.env.CI,
982
+ },
983
+ {
984
+ command: 'npm run dev',
985
+ url: 'http://localhost:3000',
986
+ reuseExistingServer: !process.env.CI,
987
+ },
988
+ ],
989
+ ```
990
+
991
+ ## Anti-Patterns
992
+
993
+ | Don't Do This | Problem | Do This Instead |
994
+ |---|---|---|
995
+ | `await page.waitForTimeout(3000)` after navigation | Next.js client-side transitions are fast; arbitrary waits are wasteful and fragile | `await page.waitForURL('/expected-path')` or `await expect(locator).toBeVisible()` |
996
+ | Test `getServerSideProps` by importing and calling it directly | It depends on `context` (req/res) that Playwright cannot provide; it is a unit test concern | Navigate to the page and verify the rendered output |
997
+ | Mock your own API routes with `page.route()` | You are testing a fiction; your API handler may have bugs the mock hides | Let the real API route handle requests; mock only external services |
998
+ | Use `page.goto('http://localhost:3000/path')` with full URL | Breaks when port or host changes; ignores `baseURL` | Use `page.goto('/path')` and configure `baseURL` in config |
999
+ | Run `npm run build && npm run start` locally for every test run | Extremely slow feedback loop during development | Use `npm run dev` locally with `reuseExistingServer: true`; reserve production builds for CI |
1000
+ | Test `next/image` by checking exact URL paths | `next/image` rewrites image URLs through `/_next/image`; paths change between dev and prod | Assert on `alt` text, visibility, `naturalWidth > 0`, and `srcset` existence |
1001
+ | Skip `.env.test` and hardcode test values in config | Values scatter across config and test files; hard to maintain | Use `.env.test` for shared test values; `.env.test.local` for secrets |
1002
+ | Test server actions by calling them as functions | Server actions are bound to the Next.js runtime; calling them outside a request context fails | Trigger server actions through their UI (form submissions, button clicks) |
1003
+ | Ignore console errors during SSR tests | Hydration mismatches and server errors appear in the console and indicate real bugs | Listen for `page.on('console')` errors and fail the test if hydration warnings appear |
1004
+
1005
+ ## Related
1006
+
1007
+ - [core/configuration.md](configuration.md) -- base Playwright configuration patterns including `webServer`
1008
+ - [core/authentication.md](authentication.md) -- authentication setup projects and `storageState` reuse
1009
+ - [core/api-testing.md](api-testing.md) -- testing API routes directly with `request` context
1010
+ - [core/network-mocking.md](network-mocking.md) -- mocking external APIs that Next.js API routes call
1011
+ - [core/when-to-mock.md](when-to-mock.md) -- when to mock vs hit real services
1012
+ - [core/react.md](react.md) -- React-specific patterns that apply to Next.js client components
1013
+ - [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI setup with `npm run build` caching for Next.js