@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,1162 @@
|
|
|
1
|
+
# Testing Vue Apps with Playwright
|
|
2
|
+
|
|
3
|
+
> **When to use**: Testing Vue 3 applications, including composition API components, Pinia stores, Vue Router navigation, Nuxt.js apps, Teleport portals, and transitions. Covers E2E testing and experimental component testing with `@playwright/experimental-ct-vue`.
|
|
4
|
+
> **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md)
|
|
5
|
+
|
|
6
|
+
## Quick Reference
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# Install Playwright in a Vue project
|
|
10
|
+
npm init playwright@latest
|
|
11
|
+
|
|
12
|
+
# Install component testing (experimental)
|
|
13
|
+
npm install -D @playwright/experimental-ct-vue
|
|
14
|
+
|
|
15
|
+
# Run E2E tests
|
|
16
|
+
npx playwright test
|
|
17
|
+
|
|
18
|
+
# Run component tests
|
|
19
|
+
npx playwright test -c playwright-ct.config.ts
|
|
20
|
+
|
|
21
|
+
# Debug a test with headed browser and inspector
|
|
22
|
+
npx playwright test tests/home.spec.ts --headed --debug
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
### Playwright Config for Vue (Vite)
|
|
28
|
+
|
|
29
|
+
**TypeScript**
|
|
30
|
+
```typescript
|
|
31
|
+
// playwright.config.ts
|
|
32
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
33
|
+
|
|
34
|
+
export default defineConfig({
|
|
35
|
+
testDir: './tests/e2e',
|
|
36
|
+
testMatch: '**/*.spec.ts',
|
|
37
|
+
fullyParallel: true,
|
|
38
|
+
forbidOnly: !!process.env.CI,
|
|
39
|
+
retries: process.env.CI ? 2 : 0,
|
|
40
|
+
workers: process.env.CI ? '50%' : undefined,
|
|
41
|
+
|
|
42
|
+
use: {
|
|
43
|
+
baseURL: 'http://localhost:5173',
|
|
44
|
+
trace: 'on-first-retry',
|
|
45
|
+
screenshot: 'only-on-failure',
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
projects: [
|
|
49
|
+
{
|
|
50
|
+
name: 'chromium',
|
|
51
|
+
use: { ...devices['Desktop Chrome'] },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'firefox',
|
|
55
|
+
use: { ...devices['Desktop Firefox'] },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'mobile',
|
|
59
|
+
use: { ...devices['iPhone 14'] },
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
|
|
63
|
+
webServer: {
|
|
64
|
+
command: process.env.CI
|
|
65
|
+
? 'npm run build && npx vite preview --port 5173'
|
|
66
|
+
: 'npm run dev',
|
|
67
|
+
url: 'http://localhost:5173',
|
|
68
|
+
reuseExistingServer: !process.env.CI,
|
|
69
|
+
timeout: 120_000,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**JavaScript**
|
|
75
|
+
```javascript
|
|
76
|
+
// playwright.config.js
|
|
77
|
+
const { defineConfig, devices } = require('@playwright/test');
|
|
78
|
+
|
|
79
|
+
module.exports = defineConfig({
|
|
80
|
+
testDir: './tests/e2e',
|
|
81
|
+
testMatch: '**/*.spec.js',
|
|
82
|
+
fullyParallel: true,
|
|
83
|
+
forbidOnly: !!process.env.CI,
|
|
84
|
+
retries: process.env.CI ? 2 : 0,
|
|
85
|
+
workers: process.env.CI ? '50%' : undefined,
|
|
86
|
+
|
|
87
|
+
use: {
|
|
88
|
+
baseURL: 'http://localhost:5173',
|
|
89
|
+
trace: 'on-first-retry',
|
|
90
|
+
screenshot: 'only-on-failure',
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
projects: [
|
|
94
|
+
{
|
|
95
|
+
name: 'chromium',
|
|
96
|
+
use: { ...devices['Desktop Chrome'] },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'firefox',
|
|
100
|
+
use: { ...devices['Desktop Firefox'] },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'mobile',
|
|
104
|
+
use: { ...devices['iPhone 14'] },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
|
|
108
|
+
webServer: {
|
|
109
|
+
command: process.env.CI
|
|
110
|
+
? 'npm run build && npx vite preview --port 5173'
|
|
111
|
+
: 'npm run dev',
|
|
112
|
+
url: 'http://localhost:5173',
|
|
113
|
+
reuseExistingServer: !process.env.CI,
|
|
114
|
+
timeout: 120_000,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Component Testing Config
|
|
120
|
+
|
|
121
|
+
**TypeScript**
|
|
122
|
+
```typescript
|
|
123
|
+
// playwright-ct.config.ts
|
|
124
|
+
import { defineConfig, devices } from '@playwright/experimental-ct-vue';
|
|
125
|
+
|
|
126
|
+
export default defineConfig({
|
|
127
|
+
testDir: './tests/components',
|
|
128
|
+
testMatch: '**/*.ct.ts',
|
|
129
|
+
|
|
130
|
+
use: {
|
|
131
|
+
trace: 'on-first-retry',
|
|
132
|
+
ctPort: 3100,
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
projects: [
|
|
136
|
+
{
|
|
137
|
+
name: 'chromium',
|
|
138
|
+
use: { ...devices['Desktop Chrome'] },
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**JavaScript**
|
|
145
|
+
```javascript
|
|
146
|
+
// playwright-ct.config.js
|
|
147
|
+
const { defineConfig, devices } = require('@playwright/experimental-ct-vue');
|
|
148
|
+
|
|
149
|
+
module.exports = defineConfig({
|
|
150
|
+
testDir: './tests/components',
|
|
151
|
+
testMatch: '**/*.ct.js',
|
|
152
|
+
|
|
153
|
+
use: {
|
|
154
|
+
trace: 'on-first-retry',
|
|
155
|
+
ctPort: 3100,
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
projects: [
|
|
159
|
+
{
|
|
160
|
+
name: 'chromium',
|
|
161
|
+
use: { ...devices['Desktop Chrome'] },
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Nuxt.js Setup
|
|
168
|
+
|
|
169
|
+
Nuxt requires a build step before testing and uses port 3000 by default.
|
|
170
|
+
|
|
171
|
+
**TypeScript**
|
|
172
|
+
```typescript
|
|
173
|
+
// playwright.config.ts
|
|
174
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
175
|
+
|
|
176
|
+
export default defineConfig({
|
|
177
|
+
testDir: './tests/e2e',
|
|
178
|
+
testMatch: '**/*.spec.ts',
|
|
179
|
+
fullyParallel: true,
|
|
180
|
+
forbidOnly: !!process.env.CI,
|
|
181
|
+
retries: process.env.CI ? 2 : 0,
|
|
182
|
+
|
|
183
|
+
use: {
|
|
184
|
+
baseURL: 'http://localhost:3000',
|
|
185
|
+
trace: 'on-first-retry',
|
|
186
|
+
screenshot: 'only-on-failure',
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
projects: [
|
|
190
|
+
{
|
|
191
|
+
name: 'chromium',
|
|
192
|
+
use: { ...devices['Desktop Chrome'] },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
|
|
196
|
+
webServer: {
|
|
197
|
+
command: process.env.CI
|
|
198
|
+
? 'npx nuxi build && npx nuxi preview'
|
|
199
|
+
: 'npx nuxi dev',
|
|
200
|
+
url: 'http://localhost:3000',
|
|
201
|
+
reuseExistingServer: !process.env.CI,
|
|
202
|
+
timeout: 120_000,
|
|
203
|
+
env: {
|
|
204
|
+
NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**JavaScript**
|
|
211
|
+
```javascript
|
|
212
|
+
// playwright.config.js
|
|
213
|
+
const { defineConfig, devices } = require('@playwright/test');
|
|
214
|
+
|
|
215
|
+
module.exports = defineConfig({
|
|
216
|
+
testDir: './tests/e2e',
|
|
217
|
+
testMatch: '**/*.spec.js',
|
|
218
|
+
fullyParallel: true,
|
|
219
|
+
forbidOnly: !!process.env.CI,
|
|
220
|
+
retries: process.env.CI ? 2 : 0,
|
|
221
|
+
|
|
222
|
+
use: {
|
|
223
|
+
baseURL: 'http://localhost:3000',
|
|
224
|
+
trace: 'on-first-retry',
|
|
225
|
+
screenshot: 'only-on-failure',
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
projects: [
|
|
229
|
+
{
|
|
230
|
+
name: 'chromium',
|
|
231
|
+
use: { ...devices['Desktop Chrome'] },
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
|
|
235
|
+
webServer: {
|
|
236
|
+
command: process.env.CI
|
|
237
|
+
? 'npx nuxi build && npx nuxi preview'
|
|
238
|
+
: 'npx nuxi dev',
|
|
239
|
+
url: 'http://localhost:3000',
|
|
240
|
+
reuseExistingServer: !process.env.CI,
|
|
241
|
+
timeout: 120_000,
|
|
242
|
+
env: {
|
|
243
|
+
NUXT_PUBLIC_API_BASE: 'http://localhost:3000/api',
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Patterns
|
|
250
|
+
|
|
251
|
+
### Component Testing with `@playwright/experimental-ct-vue`
|
|
252
|
+
|
|
253
|
+
**Use when**: Testing complex interactive Vue components in isolation -- data tables, form components, custom select dropdowns, rich editors. The component needs a real browser but not a full application.
|
|
254
|
+
**Avoid when**: The component depends heavily on Pinia stores, Vue Router, or backend data. Use E2E tests instead, or provide the dependencies in your component test setup.
|
|
255
|
+
|
|
256
|
+
**TypeScript**
|
|
257
|
+
```typescript
|
|
258
|
+
// tests/components/Counter.ct.ts
|
|
259
|
+
import { test, expect } from '@playwright/experimental-ct-vue';
|
|
260
|
+
import Counter from '../../src/components/Counter.vue';
|
|
261
|
+
|
|
262
|
+
test('renders with initial count and increments', async ({ mount }) => {
|
|
263
|
+
const component = await mount(Counter, {
|
|
264
|
+
props: {
|
|
265
|
+
initialCount: 0,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await expect(component.getByText('Count: 0')).toBeVisible();
|
|
270
|
+
await component.getByRole('button', { name: 'Increment' }).click();
|
|
271
|
+
await expect(component.getByText('Count: 1')).toBeVisible();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('emits update event when count changes', async ({ mount }) => {
|
|
275
|
+
const emittedValues: number[] = [];
|
|
276
|
+
const component = await mount(Counter, {
|
|
277
|
+
props: {
|
|
278
|
+
initialCount: 5,
|
|
279
|
+
},
|
|
280
|
+
on: {
|
|
281
|
+
update: (value: number) => emittedValues.push(value),
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await component.getByRole('button', { name: 'Increment' }).click();
|
|
286
|
+
await component.getByRole('button', { name: 'Increment' }).click();
|
|
287
|
+
|
|
288
|
+
expect(emittedValues).toEqual([6, 7]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('respects max prop', async ({ mount }) => {
|
|
292
|
+
const component = await mount(Counter, {
|
|
293
|
+
props: {
|
|
294
|
+
initialCount: 9,
|
|
295
|
+
max: 10,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await component.getByRole('button', { name: 'Increment' }).click();
|
|
300
|
+
await expect(component.getByText('Count: 10')).toBeVisible();
|
|
301
|
+
|
|
302
|
+
// At max -- button should be disabled
|
|
303
|
+
await expect(component.getByRole('button', { name: 'Increment' })).toBeDisabled();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('renders with slot content', async ({ mount }) => {
|
|
307
|
+
const component = await mount(Counter, {
|
|
308
|
+
props: { initialCount: 0 },
|
|
309
|
+
slots: {
|
|
310
|
+
default: '<span class="custom-label">Items in cart</span>',
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await expect(component.getByText('Items in cart')).toBeVisible();
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**JavaScript**
|
|
319
|
+
```javascript
|
|
320
|
+
// tests/components/Counter.ct.js
|
|
321
|
+
const { test, expect } = require('@playwright/experimental-ct-vue');
|
|
322
|
+
const Counter = require('../../src/components/Counter.vue');
|
|
323
|
+
|
|
324
|
+
test('renders with initial count and increments', async ({ mount }) => {
|
|
325
|
+
const component = await mount(Counter, {
|
|
326
|
+
props: {
|
|
327
|
+
initialCount: 0,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await expect(component.getByText('Count: 0')).toBeVisible();
|
|
332
|
+
await component.getByRole('button', { name: 'Increment' }).click();
|
|
333
|
+
await expect(component.getByText('Count: 1')).toBeVisible();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('emits update event when count changes', async ({ mount }) => {
|
|
337
|
+
const emittedValues = [];
|
|
338
|
+
const component = await mount(Counter, {
|
|
339
|
+
props: {
|
|
340
|
+
initialCount: 5,
|
|
341
|
+
},
|
|
342
|
+
on: {
|
|
343
|
+
update: (value) => emittedValues.push(value),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await component.getByRole('button', { name: 'Increment' }).click();
|
|
348
|
+
await component.getByRole('button', { name: 'Increment' }).click();
|
|
349
|
+
|
|
350
|
+
expect(emittedValues).toEqual([6, 7]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('renders with slot content', async ({ mount }) => {
|
|
354
|
+
const component = await mount(Counter, {
|
|
355
|
+
props: { initialCount: 0 },
|
|
356
|
+
slots: {
|
|
357
|
+
default: '<span class="custom-label">Items in cart</span>',
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await expect(component.getByText('Items in cart')).toBeVisible();
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Testing Pinia Stores Through the UI
|
|
366
|
+
|
|
367
|
+
**Use when**: Verifying that Pinia stores produce the correct UI behavior. Playwright tests the rendered output, not the store directly. If the UI is correct, the store is correct.
|
|
368
|
+
**Avoid when**: Testing pure store logic (getters, actions with no UI side effect) -- use unit tests with Vitest for that.
|
|
369
|
+
|
|
370
|
+
**TypeScript**
|
|
371
|
+
```typescript
|
|
372
|
+
import { test, expect } from '@playwright/test';
|
|
373
|
+
|
|
374
|
+
test.describe('Pinia cart store (tested through UI)', () => {
|
|
375
|
+
test('adding items updates the cart across the app', async ({ page }) => {
|
|
376
|
+
await page.goto('/products');
|
|
377
|
+
|
|
378
|
+
const cartBadge = page.getByTestId('cart-count');
|
|
379
|
+
|
|
380
|
+
// Initially empty
|
|
381
|
+
await expect(cartBadge).toHaveText('0');
|
|
382
|
+
|
|
383
|
+
// Add first item -- Pinia store action is triggered
|
|
384
|
+
await page.getByRole('listitem')
|
|
385
|
+
.filter({ hasText: 'Vue T-Shirt' })
|
|
386
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
387
|
+
.click();
|
|
388
|
+
await expect(cartBadge).toHaveText('1');
|
|
389
|
+
|
|
390
|
+
// Add second item
|
|
391
|
+
await page.getByRole('listitem')
|
|
392
|
+
.filter({ hasText: 'Pinia Sticker Pack' })
|
|
393
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
394
|
+
.click();
|
|
395
|
+
await expect(cartBadge).toHaveText('2');
|
|
396
|
+
|
|
397
|
+
// Navigate to cart page -- store state persists
|
|
398
|
+
await page.getByRole('link', { name: 'Cart' }).click();
|
|
399
|
+
await page.waitForURL('/cart');
|
|
400
|
+
|
|
401
|
+
await expect(page.getByText('Vue T-Shirt')).toBeVisible();
|
|
402
|
+
await expect(page.getByText('Pinia Sticker Pack')).toBeVisible();
|
|
403
|
+
await expect(page.getByText('Total: $29.98')).toBeVisible();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('removing items from cart updates total', async ({ page }) => {
|
|
407
|
+
// Navigate to a pre-populated cart (seed via API or navigate through adding items)
|
|
408
|
+
await page.goto('/products');
|
|
409
|
+
await page.getByRole('listitem')
|
|
410
|
+
.filter({ hasText: 'Vue T-Shirt' })
|
|
411
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
412
|
+
.click();
|
|
413
|
+
|
|
414
|
+
await page.getByRole('link', { name: 'Cart' }).click();
|
|
415
|
+
await page.waitForURL('/cart');
|
|
416
|
+
|
|
417
|
+
await page.getByRole('button', { name: 'Remove Vue T-Shirt' }).click();
|
|
418
|
+
|
|
419
|
+
await expect(page.getByText('Your cart is empty')).toBeVisible();
|
|
420
|
+
await expect(page.getByTestId('cart-count')).toHaveText('0');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('store persists after page reload when using pinia-plugin-persistedstate', async ({ page }) => {
|
|
424
|
+
await page.goto('/products');
|
|
425
|
+
|
|
426
|
+
await page.getByRole('listitem')
|
|
427
|
+
.filter({ hasText: 'Vue T-Shirt' })
|
|
428
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
429
|
+
.click();
|
|
430
|
+
|
|
431
|
+
// Reload the page -- persisted store should restore state
|
|
432
|
+
await page.reload();
|
|
433
|
+
|
|
434
|
+
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test.describe('Pinia auth store (tested through UI)', () => {
|
|
439
|
+
test('login updates auth state across all components', async ({ page }) => {
|
|
440
|
+
await page.goto('/');
|
|
441
|
+
|
|
442
|
+
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
|
443
|
+
|
|
444
|
+
await page.getByRole('link', { name: 'Sign in' }).click();
|
|
445
|
+
await page.getByLabel('Email').fill('user@example.com');
|
|
446
|
+
await page.getByLabel('Password').fill('password123');
|
|
447
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
448
|
+
|
|
449
|
+
await page.waitForURL('/dashboard');
|
|
450
|
+
|
|
451
|
+
// Header updates (uses auth store)
|
|
452
|
+
await expect(page.getByText('user@example.com')).toBeVisible();
|
|
453
|
+
// Navigation updates (uses auth store for conditional routes)
|
|
454
|
+
await expect(page.getByRole('link', { name: 'Admin' })).toBeVisible();
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**JavaScript**
|
|
460
|
+
```javascript
|
|
461
|
+
const { test, expect } = require('@playwright/test');
|
|
462
|
+
|
|
463
|
+
test.describe('Pinia cart store (tested through UI)', () => {
|
|
464
|
+
test('adding items updates the cart across the app', async ({ page }) => {
|
|
465
|
+
await page.goto('/products');
|
|
466
|
+
|
|
467
|
+
const cartBadge = page.getByTestId('cart-count');
|
|
468
|
+
await expect(cartBadge).toHaveText('0');
|
|
469
|
+
|
|
470
|
+
await page.getByRole('listitem')
|
|
471
|
+
.filter({ hasText: 'Vue T-Shirt' })
|
|
472
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
473
|
+
.click();
|
|
474
|
+
await expect(cartBadge).toHaveText('1');
|
|
475
|
+
|
|
476
|
+
await page.getByRole('listitem')
|
|
477
|
+
.filter({ hasText: 'Pinia Sticker Pack' })
|
|
478
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
479
|
+
.click();
|
|
480
|
+
await expect(cartBadge).toHaveText('2');
|
|
481
|
+
|
|
482
|
+
await page.getByRole('link', { name: 'Cart' }).click();
|
|
483
|
+
await page.waitForURL('/cart');
|
|
484
|
+
|
|
485
|
+
await expect(page.getByText('Vue T-Shirt')).toBeVisible();
|
|
486
|
+
await expect(page.getByText('Pinia Sticker Pack')).toBeVisible();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('store persists after page reload when using pinia-plugin-persistedstate', async ({ page }) => {
|
|
490
|
+
await page.goto('/products');
|
|
491
|
+
|
|
492
|
+
await page.getByRole('listitem')
|
|
493
|
+
.filter({ hasText: 'Vue T-Shirt' })
|
|
494
|
+
.getByRole('button', { name: 'Add to cart' })
|
|
495
|
+
.click();
|
|
496
|
+
|
|
497
|
+
await page.reload();
|
|
498
|
+
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### Testing Vue Router Navigation
|
|
504
|
+
|
|
505
|
+
**Use when**: Testing client-side routing with Vue Router. Verify route transitions, navigation guards, URL parameters, and browser history behavior.
|
|
506
|
+
**Avoid when**: Testing Nuxt.js file-based routing -- the patterns are similar but Nuxt has additional server-side concerns.
|
|
507
|
+
|
|
508
|
+
**TypeScript**
|
|
509
|
+
```typescript
|
|
510
|
+
import { test, expect } from '@playwright/test';
|
|
511
|
+
|
|
512
|
+
test.describe('Vue Router navigation', () => {
|
|
513
|
+
test('client-side navigation does not cause full page reload', async ({ page }) => {
|
|
514
|
+
await page.goto('/');
|
|
515
|
+
|
|
516
|
+
await page.evaluate(() => {
|
|
517
|
+
(window as any).__testMarker = 'no-reload';
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
await page.getByRole('link', { name: 'Products' }).click();
|
|
521
|
+
await page.waitForURL('/products');
|
|
522
|
+
|
|
523
|
+
const marker = await page.evaluate(() => (window as any).__testMarker);
|
|
524
|
+
expect(marker).toBe('no-reload');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('dynamic route params render correct content', async ({ page }) => {
|
|
528
|
+
await page.goto('/products/42');
|
|
529
|
+
|
|
530
|
+
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
531
|
+
await expect(page.getByText('Product #42')).toBeVisible();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('navigation guard redirects unauthenticated users', async ({ page }) => {
|
|
535
|
+
// Visit a route guarded by router.beforeEach
|
|
536
|
+
await page.goto('/admin/users');
|
|
537
|
+
|
|
538
|
+
// Guard should redirect to login
|
|
539
|
+
await expect(page).toHaveURL(/\/login/);
|
|
540
|
+
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test('named routes and nested views render correctly', async ({ page }) => {
|
|
544
|
+
await page.goto('/settings');
|
|
545
|
+
|
|
546
|
+
// Parent layout
|
|
547
|
+
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
|
548
|
+
await expect(page.getByRole('navigation', { name: 'Settings' })).toBeVisible();
|
|
549
|
+
|
|
550
|
+
// Default child route
|
|
551
|
+
await expect(page.getByRole('heading', { name: 'General', level: 2 })).toBeVisible();
|
|
552
|
+
|
|
553
|
+
// Navigate to sibling child route
|
|
554
|
+
await page.getByRole('link', { name: 'Notifications' }).click();
|
|
555
|
+
await page.waitForURL('/settings/notifications');
|
|
556
|
+
|
|
557
|
+
// Parent layout persists, child content changes
|
|
558
|
+
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
|
559
|
+
await expect(page.getByRole('heading', { name: 'Notifications', level: 2 })).toBeVisible();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('browser back/forward works with Vue Router', async ({ page }) => {
|
|
563
|
+
await page.goto('/');
|
|
564
|
+
await page.getByRole('link', { name: 'Products' }).click();
|
|
565
|
+
await page.waitForURL('/products');
|
|
566
|
+
await page.getByRole('link', { name: 'About' }).click();
|
|
567
|
+
await page.waitForURL('/about');
|
|
568
|
+
|
|
569
|
+
await page.goBack();
|
|
570
|
+
await expect(page).toHaveURL(/\/products/);
|
|
571
|
+
|
|
572
|
+
await page.goBack();
|
|
573
|
+
await expect(page).toHaveURL(/\/$/);
|
|
574
|
+
|
|
575
|
+
await page.goForward();
|
|
576
|
+
await expect(page).toHaveURL(/\/products/);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('query parameters update reactive computed properties', async ({ page }) => {
|
|
580
|
+
await page.goto('/products?sort=price-asc&category=electronics');
|
|
581
|
+
|
|
582
|
+
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
|
|
583
|
+
|
|
584
|
+
// Change sort via UI (updates route query params)
|
|
585
|
+
await page.getByRole('combobox', { name: 'Sort by' }).selectOption('name-asc');
|
|
586
|
+
await expect(page).toHaveURL(/sort=name-asc/);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('404 catch-all route displays not found page', async ({ page }) => {
|
|
590
|
+
await page.goto('/this-page-does-not-exist');
|
|
591
|
+
|
|
592
|
+
await expect(page.getByRole('heading', { name: 'Page Not Found' })).toBeVisible();
|
|
593
|
+
await expect(page.getByRole('link', { name: 'Go home' })).toBeVisible();
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
**JavaScript**
|
|
599
|
+
```javascript
|
|
600
|
+
const { test, expect } = require('@playwright/test');
|
|
601
|
+
|
|
602
|
+
test.describe('Vue Router navigation', () => {
|
|
603
|
+
test('client-side navigation does not cause full page reload', async ({ page }) => {
|
|
604
|
+
await page.goto('/');
|
|
605
|
+
|
|
606
|
+
await page.evaluate(() => {
|
|
607
|
+
window.__testMarker = 'no-reload';
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
await page.getByRole('link', { name: 'Products' }).click();
|
|
611
|
+
await page.waitForURL('/products');
|
|
612
|
+
|
|
613
|
+
const marker = await page.evaluate(() => window.__testMarker);
|
|
614
|
+
expect(marker).toBe('no-reload');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test('dynamic route params render correct content', async ({ page }) => {
|
|
618
|
+
await page.goto('/products/42');
|
|
619
|
+
|
|
620
|
+
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
621
|
+
await expect(page.getByText('Product #42')).toBeVisible();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('navigation guard redirects unauthenticated users', async ({ page }) => {
|
|
625
|
+
await page.goto('/admin/users');
|
|
626
|
+
|
|
627
|
+
await expect(page).toHaveURL(/\/login/);
|
|
628
|
+
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test('browser back/forward works with Vue Router', async ({ page }) => {
|
|
632
|
+
await page.goto('/');
|
|
633
|
+
await page.getByRole('link', { name: 'Products' }).click();
|
|
634
|
+
await page.waitForURL('/products');
|
|
635
|
+
await page.getByRole('link', { name: 'About' }).click();
|
|
636
|
+
await page.waitForURL('/about');
|
|
637
|
+
|
|
638
|
+
await page.goBack();
|
|
639
|
+
await expect(page).toHaveURL(/\/products/);
|
|
640
|
+
|
|
641
|
+
await page.goBack();
|
|
642
|
+
await expect(page).toHaveURL(/\/$/);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### Testing Teleport (Portals)
|
|
648
|
+
|
|
649
|
+
**Use when**: Testing components rendered via Vue's `<Teleport>` -- modals, notifications, overlay menus. Teleport moves DOM elements to a different location (often `body`), but Playwright sees the entire document.
|
|
650
|
+
**Avoid when**: The component is not teleported -- it renders inline in its parent.
|
|
651
|
+
|
|
652
|
+
**TypeScript**
|
|
653
|
+
```typescript
|
|
654
|
+
import { test, expect } from '@playwright/test';
|
|
655
|
+
|
|
656
|
+
test.describe('Teleport components', () => {
|
|
657
|
+
test('modal teleported to body is visible and interactive', async ({ page }) => {
|
|
658
|
+
await page.goto('/products');
|
|
659
|
+
|
|
660
|
+
await page.getByRole('button', { name: 'Delete' }).first().click();
|
|
661
|
+
|
|
662
|
+
// Modal is teleported to <body> with <Teleport to="body">
|
|
663
|
+
// Playwright sees it like any other element
|
|
664
|
+
const modal = page.getByRole('dialog', { name: 'Confirm deletion' });
|
|
665
|
+
await expect(modal).toBeVisible();
|
|
666
|
+
|
|
667
|
+
await modal.getByRole('button', { name: 'Cancel' }).click();
|
|
668
|
+
await expect(modal).toBeHidden();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test('teleported notification appears above all content', async ({ page }) => {
|
|
672
|
+
await page.goto('/settings');
|
|
673
|
+
|
|
674
|
+
await page.getByRole('button', { name: 'Save' }).click();
|
|
675
|
+
|
|
676
|
+
// Notification teleported to #notifications container
|
|
677
|
+
const notification = page.getByRole('alert');
|
|
678
|
+
await expect(notification).toBeVisible();
|
|
679
|
+
await expect(notification).toContainText('Settings saved');
|
|
680
|
+
|
|
681
|
+
// Auto-dismiss
|
|
682
|
+
await expect(notification).toBeHidden({ timeout: 10_000 });
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test('teleported dropdown closes on outside click', async ({ page }) => {
|
|
686
|
+
await page.goto('/dashboard');
|
|
687
|
+
|
|
688
|
+
await page.getByRole('button', { name: 'User menu' }).click();
|
|
689
|
+
|
|
690
|
+
const menu = page.getByRole('menu');
|
|
691
|
+
await expect(menu).toBeVisible();
|
|
692
|
+
|
|
693
|
+
// Click outside the menu
|
|
694
|
+
await page.locator('body').click({ position: { x: 10, y: 10 } });
|
|
695
|
+
await expect(menu).toBeHidden();
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
**JavaScript**
|
|
701
|
+
```javascript
|
|
702
|
+
const { test, expect } = require('@playwright/test');
|
|
703
|
+
|
|
704
|
+
test.describe('Teleport components', () => {
|
|
705
|
+
test('modal teleported to body is visible and interactive', async ({ page }) => {
|
|
706
|
+
await page.goto('/products');
|
|
707
|
+
|
|
708
|
+
await page.getByRole('button', { name: 'Delete' }).first().click();
|
|
709
|
+
|
|
710
|
+
const modal = page.getByRole('dialog', { name: 'Confirm deletion' });
|
|
711
|
+
await expect(modal).toBeVisible();
|
|
712
|
+
|
|
713
|
+
await modal.getByRole('button', { name: 'Cancel' }).click();
|
|
714
|
+
await expect(modal).toBeHidden();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('teleported notification appears above all content', async ({ page }) => {
|
|
718
|
+
await page.goto('/settings');
|
|
719
|
+
|
|
720
|
+
await page.getByRole('button', { name: 'Save' }).click();
|
|
721
|
+
|
|
722
|
+
const notification = page.getByRole('alert');
|
|
723
|
+
await expect(notification).toBeVisible();
|
|
724
|
+
await expect(notification).toContainText('Settings saved');
|
|
725
|
+
|
|
726
|
+
await expect(notification).toBeHidden({ timeout: 10_000 });
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('teleported dropdown closes on outside click', async ({ page }) => {
|
|
730
|
+
await page.goto('/dashboard');
|
|
731
|
+
|
|
732
|
+
await page.getByRole('button', { name: 'User menu' }).click();
|
|
733
|
+
|
|
734
|
+
const menu = page.getByRole('menu');
|
|
735
|
+
await expect(menu).toBeVisible();
|
|
736
|
+
|
|
737
|
+
await page.locator('body').click({ position: { x: 10, y: 10 } });
|
|
738
|
+
await expect(menu).toBeHidden();
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### Testing Transitions and Animations
|
|
744
|
+
|
|
745
|
+
**Use when**: Verifying that Vue `<Transition>` and `<TransitionGroup>` components work correctly -- elements appear, disappear, and reorder with expected behavior. Focus on the end state, not the animation itself.
|
|
746
|
+
**Avoid when**: Testing the exact CSS animation keyframes -- visual regression testing is better for pixel-level validation.
|
|
747
|
+
|
|
748
|
+
**TypeScript**
|
|
749
|
+
```typescript
|
|
750
|
+
import { test, expect } from '@playwright/test';
|
|
751
|
+
|
|
752
|
+
test.describe('transitions', () => {
|
|
753
|
+
test('list items animate in when added', async ({ page }) => {
|
|
754
|
+
await page.goto('/todo');
|
|
755
|
+
|
|
756
|
+
await page.getByRole('textbox', { name: 'New task' }).fill('Buy groceries');
|
|
757
|
+
await page.getByRole('button', { name: 'Add' }).click();
|
|
758
|
+
|
|
759
|
+
// The item should be visible after the enter transition completes
|
|
760
|
+
// Playwright auto-waits for visibility, handling the transition automatically
|
|
761
|
+
await expect(page.getByText('Buy groceries')).toBeVisible();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('deleted items animate out and are removed from DOM', async ({ page }) => {
|
|
765
|
+
await page.goto('/todo');
|
|
766
|
+
|
|
767
|
+
// Add an item first
|
|
768
|
+
await page.getByRole('textbox', { name: 'New task' }).fill('Temporary task');
|
|
769
|
+
await page.getByRole('button', { name: 'Add' }).click();
|
|
770
|
+
await expect(page.getByText('Temporary task')).toBeVisible();
|
|
771
|
+
|
|
772
|
+
// Delete it
|
|
773
|
+
await page.getByRole('listitem')
|
|
774
|
+
.filter({ hasText: 'Temporary task' })
|
|
775
|
+
.getByRole('button', { name: 'Delete' })
|
|
776
|
+
.click();
|
|
777
|
+
|
|
778
|
+
// After the leave transition, the element should be gone
|
|
779
|
+
await expect(page.getByText('Temporary task')).toBeHidden();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test('page transition completes between routes', async ({ page }) => {
|
|
783
|
+
await page.goto('/');
|
|
784
|
+
|
|
785
|
+
await page.getByRole('link', { name: 'About' }).click();
|
|
786
|
+
await page.waitForURL('/about');
|
|
787
|
+
|
|
788
|
+
// After the route transition animation, content should be visible
|
|
789
|
+
await expect(page.getByRole('heading', { name: 'About' })).toBeVisible();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('disable animations for faster, more reliable tests', async ({ page }) => {
|
|
793
|
+
// Disable all CSS animations and transitions for deterministic testing
|
|
794
|
+
await page.addStyleTag({
|
|
795
|
+
content: `
|
|
796
|
+
*, *::before, *::after {
|
|
797
|
+
animation-duration: 0s !important;
|
|
798
|
+
animation-delay: 0s !important;
|
|
799
|
+
transition-duration: 0s !important;
|
|
800
|
+
transition-delay: 0s !important;
|
|
801
|
+
}
|
|
802
|
+
`,
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
await page.goto('/todo');
|
|
806
|
+
|
|
807
|
+
await page.getByRole('textbox', { name: 'New task' }).fill('Instant task');
|
|
808
|
+
await page.getByRole('button', { name: 'Add' }).click();
|
|
809
|
+
|
|
810
|
+
// With animations disabled, elements appear/disappear instantly
|
|
811
|
+
await expect(page.getByText('Instant task')).toBeVisible();
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
**JavaScript**
|
|
817
|
+
```javascript
|
|
818
|
+
const { test, expect } = require('@playwright/test');
|
|
819
|
+
|
|
820
|
+
test.describe('transitions', () => {
|
|
821
|
+
test('list items animate in when added', async ({ page }) => {
|
|
822
|
+
await page.goto('/todo');
|
|
823
|
+
|
|
824
|
+
await page.getByRole('textbox', { name: 'New task' }).fill('Buy groceries');
|
|
825
|
+
await page.getByRole('button', { name: 'Add' }).click();
|
|
826
|
+
|
|
827
|
+
await expect(page.getByText('Buy groceries')).toBeVisible();
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test('deleted items animate out and are removed from DOM', async ({ page }) => {
|
|
831
|
+
await page.goto('/todo');
|
|
832
|
+
|
|
833
|
+
await page.getByRole('textbox', { name: 'New task' }).fill('Temporary task');
|
|
834
|
+
await page.getByRole('button', { name: 'Add' }).click();
|
|
835
|
+
await expect(page.getByText('Temporary task')).toBeVisible();
|
|
836
|
+
|
|
837
|
+
await page.getByRole('listitem')
|
|
838
|
+
.filter({ hasText: 'Temporary task' })
|
|
839
|
+
.getByRole('button', { name: 'Delete' })
|
|
840
|
+
.click();
|
|
841
|
+
|
|
842
|
+
await expect(page.getByText('Temporary task')).toBeHidden();
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test('disable animations for faster, more reliable tests', async ({ page }) => {
|
|
846
|
+
await page.addStyleTag({
|
|
847
|
+
content: `
|
|
848
|
+
*, *::before, *::after {
|
|
849
|
+
animation-duration: 0s !important;
|
|
850
|
+
animation-delay: 0s !important;
|
|
851
|
+
transition-duration: 0s !important;
|
|
852
|
+
transition-delay: 0s !important;
|
|
853
|
+
}
|
|
854
|
+
`,
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
await page.goto('/todo');
|
|
858
|
+
|
|
859
|
+
await page.getByRole('textbox', { name: 'New task' }).fill('Instant task');
|
|
860
|
+
await page.getByRole('button', { name: 'Add' }).click();
|
|
861
|
+
|
|
862
|
+
await expect(page.getByText('Instant task')).toBeVisible();
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
### Testing Composition API Components
|
|
868
|
+
|
|
869
|
+
**Use when**: Testing components built with the Vue 3 Composition API (`<script setup>`, `setup()` function). From Playwright's perspective, Composition API and Options API components are identical -- you test the rendered output.
|
|
870
|
+
**Avoid when**: You want to test composable functions in isolation -- use Vitest for that.
|
|
871
|
+
|
|
872
|
+
**TypeScript**
|
|
873
|
+
```typescript
|
|
874
|
+
import { test, expect } from '@playwright/test';
|
|
875
|
+
|
|
876
|
+
test.describe('composition API patterns', () => {
|
|
877
|
+
test('computed properties update the UI reactively', async ({ page }) => {
|
|
878
|
+
await page.goto('/calculator');
|
|
879
|
+
|
|
880
|
+
await page.getByLabel('Price').fill('100');
|
|
881
|
+
await page.getByLabel('Quantity').fill('3');
|
|
882
|
+
|
|
883
|
+
// Computed total should update reactively
|
|
884
|
+
await expect(page.getByTestId('total')).toHaveText('$300.00');
|
|
885
|
+
|
|
886
|
+
// Apply discount
|
|
887
|
+
await page.getByLabel('Discount (%)').fill('10');
|
|
888
|
+
await expect(page.getByTestId('total')).toHaveText('$270.00');
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
test('watcher triggers side effect on value change', async ({ page }) => {
|
|
892
|
+
await page.goto('/settings');
|
|
893
|
+
|
|
894
|
+
// Changing the language triggers a watcher that reloads translations
|
|
895
|
+
await page.getByRole('combobox', { name: 'Language' }).selectOption('fr');
|
|
896
|
+
|
|
897
|
+
// Watcher fetches French translations -- UI updates
|
|
898
|
+
await expect(page.getByRole('heading', { name: 'Parametres' })).toBeVisible();
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test('composable hook (useSearch) provides debounced search', async ({ page }) => {
|
|
902
|
+
await page.goto('/products');
|
|
903
|
+
|
|
904
|
+
const searchInput = page.getByRole('textbox', { name: 'Search products' });
|
|
905
|
+
await searchInput.pressSequentially('vue stickers', { delay: 50 });
|
|
906
|
+
|
|
907
|
+
// useSearch composable debounces and fetches results
|
|
908
|
+
await expect(page.getByRole('listitem')).toHaveCount(3);
|
|
909
|
+
await expect(page.getByText('Vue Sticker Pack')).toBeVisible();
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
test('provide/inject context works across component tree', async ({ page }) => {
|
|
913
|
+
await page.goto('/dashboard');
|
|
914
|
+
|
|
915
|
+
// Theme is provided at the app root and injected in child components
|
|
916
|
+
await page.getByRole('switch', { name: 'Dark mode' }).click();
|
|
917
|
+
|
|
918
|
+
// All components using inject('theme') should update
|
|
919
|
+
await expect(page.locator('body')).toHaveClass(/dark/);
|
|
920
|
+
await expect(page.getByRole('navigation')).toHaveCSS('background-color', /rgb\(30/);
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
**JavaScript**
|
|
926
|
+
```javascript
|
|
927
|
+
const { test, expect } = require('@playwright/test');
|
|
928
|
+
|
|
929
|
+
test.describe('composition API patterns', () => {
|
|
930
|
+
test('computed properties update the UI reactively', async ({ page }) => {
|
|
931
|
+
await page.goto('/calculator');
|
|
932
|
+
|
|
933
|
+
await page.getByLabel('Price').fill('100');
|
|
934
|
+
await page.getByLabel('Quantity').fill('3');
|
|
935
|
+
|
|
936
|
+
await expect(page.getByTestId('total')).toHaveText('$300.00');
|
|
937
|
+
|
|
938
|
+
await page.getByLabel('Discount (%)').fill('10');
|
|
939
|
+
await expect(page.getByTestId('total')).toHaveText('$270.00');
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test('watcher triggers side effect on value change', async ({ page }) => {
|
|
943
|
+
await page.goto('/settings');
|
|
944
|
+
|
|
945
|
+
await page.getByRole('combobox', { name: 'Language' }).selectOption('fr');
|
|
946
|
+
await expect(page.getByRole('heading', { name: 'Parametres' })).toBeVisible();
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test('composable hook (useSearch) provides debounced search', async ({ page }) => {
|
|
950
|
+
await page.goto('/products');
|
|
951
|
+
|
|
952
|
+
const searchInput = page.getByRole('textbox', { name: 'Search products' });
|
|
953
|
+
await searchInput.pressSequentially('vue stickers', { delay: 50 });
|
|
954
|
+
|
|
955
|
+
await expect(page.getByRole('listitem')).toHaveCount(3);
|
|
956
|
+
await expect(page.getByText('Vue Sticker Pack')).toBeVisible();
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
### Testing Nuxt.js Specific Patterns
|
|
962
|
+
|
|
963
|
+
**Use when**: Testing Nuxt 3 applications with server-side rendering, auto-imports, server routes (`/server/api/`), and middleware.
|
|
964
|
+
**Avoid when**: Testing a plain Vue SPA without Nuxt.
|
|
965
|
+
|
|
966
|
+
**TypeScript**
|
|
967
|
+
```typescript
|
|
968
|
+
import { test, expect } from '@playwright/test';
|
|
969
|
+
|
|
970
|
+
test.describe('Nuxt-specific patterns', () => {
|
|
971
|
+
test('SSR page renders server-fetched data', async ({ page }) => {
|
|
972
|
+
await page.goto('/blog');
|
|
973
|
+
|
|
974
|
+
// useFetch/useAsyncData runs on the server -- content is in the initial HTML
|
|
975
|
+
await expect(page.getByRole('article')).toHaveCount(10);
|
|
976
|
+
await expect(page.getByRole('article').first()).toContainText(/\w+/);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test('Nuxt server route returns correct data', async ({ request }) => {
|
|
980
|
+
const response = await request.get('/api/products');
|
|
981
|
+
|
|
982
|
+
expect(response.ok()).toBeTruthy();
|
|
983
|
+
const body = await response.json();
|
|
984
|
+
expect(body).toBeInstanceOf(Array);
|
|
985
|
+
expect(body[0]).toHaveProperty('id');
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test('Nuxt middleware redirects unauthenticated users', async ({ page }) => {
|
|
989
|
+
await page.goto('/admin');
|
|
990
|
+
|
|
991
|
+
// definePageMeta({ middleware: 'auth' }) in the page triggers redirect
|
|
992
|
+
await expect(page).toHaveURL(/\/login/);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test('NuxtLink enables client-side navigation', async ({ page }) => {
|
|
996
|
+
await page.goto('/');
|
|
997
|
+
|
|
998
|
+
await page.evaluate(() => {
|
|
999
|
+
(window as any).__testMarker = 'no-reload';
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
await page.getByRole('link', { name: 'Blog' }).click();
|
|
1003
|
+
await page.waitForURL('/blog');
|
|
1004
|
+
|
|
1005
|
+
const marker = await page.evaluate(() => (window as any).__testMarker);
|
|
1006
|
+
expect(marker).toBe('no-reload');
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test('useHead sets meta tags for SEO', async ({ page }) => {
|
|
1010
|
+
await page.goto('/blog/my-post');
|
|
1011
|
+
|
|
1012
|
+
const title = await page.title();
|
|
1013
|
+
expect(title).toContain('My Post');
|
|
1014
|
+
|
|
1015
|
+
const description = await page.locator('meta[name="description"]').getAttribute('content');
|
|
1016
|
+
expect(description).toBeTruthy();
|
|
1017
|
+
expect(description!.length).toBeGreaterThan(50);
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
**JavaScript**
|
|
1023
|
+
```javascript
|
|
1024
|
+
const { test, expect } = require('@playwright/test');
|
|
1025
|
+
|
|
1026
|
+
test.describe('Nuxt-specific patterns', () => {
|
|
1027
|
+
test('SSR page renders server-fetched data', async ({ page }) => {
|
|
1028
|
+
await page.goto('/blog');
|
|
1029
|
+
|
|
1030
|
+
await expect(page.getByRole('article')).toHaveCount(10);
|
|
1031
|
+
await expect(page.getByRole('article').first()).toContainText(/\w+/);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
test('Nuxt server route returns correct data', async ({ request }) => {
|
|
1035
|
+
const response = await request.get('/api/products');
|
|
1036
|
+
|
|
1037
|
+
expect(response.ok()).toBeTruthy();
|
|
1038
|
+
const body = await response.json();
|
|
1039
|
+
expect(body).toBeInstanceOf(Array);
|
|
1040
|
+
expect(body[0]).toHaveProperty('id');
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
test('Nuxt middleware redirects unauthenticated users', async ({ page }) => {
|
|
1044
|
+
await page.goto('/admin');
|
|
1045
|
+
|
|
1046
|
+
await expect(page).toHaveURL(/\/login/);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
test('NuxtLink enables client-side navigation', async ({ page }) => {
|
|
1050
|
+
await page.goto('/');
|
|
1051
|
+
|
|
1052
|
+
await page.evaluate(() => {
|
|
1053
|
+
window.__testMarker = 'no-reload';
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
await page.getByRole('link', { name: 'Blog' }).click();
|
|
1057
|
+
await page.waitForURL('/blog');
|
|
1058
|
+
|
|
1059
|
+
const marker = await page.evaluate(() => window.__testMarker);
|
|
1060
|
+
expect(marker).toBe('no-reload');
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
## Framework-Specific Tips
|
|
1066
|
+
|
|
1067
|
+
### Vue DevTools and Playwright
|
|
1068
|
+
|
|
1069
|
+
Vue DevTools is a browser extension. It does not interfere with Playwright tests since Playwright launches its own browser profile without extensions. Do not rely on Vue DevTools for debugging in CI -- use Playwright traces instead.
|
|
1070
|
+
|
|
1071
|
+
### Testing `v-model` Two-Way Binding
|
|
1072
|
+
|
|
1073
|
+
`v-model` on form inputs works through standard HTML events. Playwright's `fill()`, `check()`, `selectOption()` methods trigger the correct events automatically. No special handling is needed.
|
|
1074
|
+
|
|
1075
|
+
```typescript
|
|
1076
|
+
// This works for any v-model input -- no special Vue handling needed
|
|
1077
|
+
await page.getByLabel('Username').fill('janedoe');
|
|
1078
|
+
await page.getByRole('checkbox', { name: 'Remember me' }).check();
|
|
1079
|
+
await page.getByRole('combobox', { name: 'Role' }).selectOption('admin');
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
### Vue vs Nuxt: Configuration Differences
|
|
1083
|
+
|
|
1084
|
+
| Aspect | Vue 3 (Vite) | Nuxt 3 |
|
|
1085
|
+
|---|---|---|
|
|
1086
|
+
| Default port | `5173` | `3000` |
|
|
1087
|
+
| Dev command | `npm run dev` (vite) | `npx nuxi dev` |
|
|
1088
|
+
| Build + preview | `npm run build && npx vite preview` | `npx nuxi build && npx nuxi preview` |
|
|
1089
|
+
| SSR | Optional (vue-server-renderer) | Built-in |
|
|
1090
|
+
| API routes | External backend | `/server/api/` built-in |
|
|
1091
|
+
| Environment variables | `VITE_*` prefix | `NUXT_PUBLIC_*` prefix for client, `NUXT_*` for server |
|
|
1092
|
+
| File-based routing | No (uses vue-router config) | Yes (automatic from `/pages/`) |
|
|
1093
|
+
|
|
1094
|
+
### Component Testing: Providing Pinia and Router
|
|
1095
|
+
|
|
1096
|
+
When using `@playwright/experimental-ct-vue`, components that depend on Pinia or Vue Router need these dependencies provided. Configure the test wrapper:
|
|
1097
|
+
|
|
1098
|
+
```typescript
|
|
1099
|
+
// playwright/index.ts (component testing setup)
|
|
1100
|
+
import { beforeMount } from '@playwright/experimental-ct-vue/hooks';
|
|
1101
|
+
import { createPinia } from 'pinia';
|
|
1102
|
+
import { createMemoryHistory, createRouter } from 'vue-router';
|
|
1103
|
+
|
|
1104
|
+
beforeMount(async ({ app, hooksConfig }) => {
|
|
1105
|
+
// Install Pinia for all component tests
|
|
1106
|
+
const pinia = createPinia();
|
|
1107
|
+
app.use(pinia);
|
|
1108
|
+
|
|
1109
|
+
// Install a minimal router if the component uses <RouterLink> or useRoute()
|
|
1110
|
+
if (hooksConfig?.routes) {
|
|
1111
|
+
const router = createRouter({
|
|
1112
|
+
history: createMemoryHistory(),
|
|
1113
|
+
routes: hooksConfig.routes,
|
|
1114
|
+
});
|
|
1115
|
+
app.use(router);
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
### Handling Vue Warnings in Tests
|
|
1121
|
+
|
|
1122
|
+
Vue emits runtime warnings (prop validation, missing components, etc.) that can indicate real issues:
|
|
1123
|
+
|
|
1124
|
+
```typescript
|
|
1125
|
+
test('no Vue warnings during render', async ({ page }) => {
|
|
1126
|
+
const warnings: string[] = [];
|
|
1127
|
+
page.on('console', (msg) => {
|
|
1128
|
+
if (msg.type() === 'warning' && msg.text().includes('[Vue warn]')) {
|
|
1129
|
+
warnings.push(msg.text());
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
await page.goto('/dashboard');
|
|
1134
|
+
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
1135
|
+
|
|
1136
|
+
expect(warnings).toEqual([]);
|
|
1137
|
+
});
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
## Anti-Patterns
|
|
1141
|
+
|
|
1142
|
+
| Don't Do This | Problem | Do This Instead |
|
|
1143
|
+
|---|---|---|
|
|
1144
|
+
| `page.evaluate(() => app.__vue_app__.config.globalProperties.$store)` | Accesses Vue internals; breaks on upgrades; tests implementation | Assert on the UI that state produces |
|
|
1145
|
+
| `page.locator('[data-v-abc123]')` (scoped style hash) | Vue generates random scoped attribute hashes; changes on every build | Use `getByRole`, `getByText`, `getByTestId` |
|
|
1146
|
+
| Import `.vue` files in E2E tests | E2E tests run in Node.js; `.vue` files need Vite/Webpack compilation | Use `@playwright/experimental-ct-vue` for component tests |
|
|
1147
|
+
| `page.waitForTimeout(300)` to wait for transition to finish | Transition durations vary; arbitrary waits are fragile | `await expect(locator).toBeVisible()` auto-waits through transitions |
|
|
1148
|
+
| Mock Pinia stores by patching `window.__pinia` | Fragile; depends on Pinia internals; may not trigger reactivity | Control state through UI interactions or mock the API responses the store consumes |
|
|
1149
|
+
| Test composables by calling them via `page.evaluate` | Composables rely on Vue's setup context; calling them outside a component fails | Test composables through the components that use them, or unit test with Vitest |
|
|
1150
|
+
| Use `page.locator('.v-btn')` for Vuetify components | Vuetify class names are internal and change between versions | `page.getByRole('button', { name: 'Submit' })` works regardless of the component library |
|
|
1151
|
+
| Skip testing keyboard navigation for custom components | Vue component libraries often have incomplete keyboard support | Test `Tab`, `Enter`, `Escape`, `ArrowDown/Up` on dropdowns, modals, tabs |
|
|
1152
|
+
| Run Nuxt dev server in CI | Dev mode includes hot reload overhead, slower builds, development warnings | Use `npx nuxi build && npx nuxi preview` in CI |
|
|
1153
|
+
|
|
1154
|
+
## Related
|
|
1155
|
+
|
|
1156
|
+
- [core/locators.md](locators.md) -- locator strategies for any Vue component library (Vuetify, PrimeVue, Quasar)
|
|
1157
|
+
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- auto-waiting assertions for Vue reactivity
|
|
1158
|
+
- [core/component-testing.md](component-testing.md) -- in-depth component testing patterns
|
|
1159
|
+
- [core/forms-and-validation.md](forms-and-validation.md) -- form testing patterns for VeeValidate and FormKit
|
|
1160
|
+
- [core/accessibility.md](accessibility.md) -- accessibility testing for Vue component libraries
|
|
1161
|
+
- [core/test-architecture.md](test-architecture.md) -- when to use E2E vs component vs unit tests
|
|
1162
|
+
- [core/nextjs.md](nextjs.md) -- comparison: Nuxt vs Next.js testing patterns
|