@icarusmx/creta 1.5.12 → 1.5.13
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/bin/creta.js +30 -1
- package/lib/data/command-help/aws-ec2.js +34 -0
- package/lib/data/command-help/grep.js +72 -0
- package/lib/data/command-help/index.js +9 -1
- package/lib/executors/CommandHelpExecutor.js +6 -1
- package/lib/exercises/.claude/settings.local.json +12 -0
- package/lib/exercises/01-developing-muscle-for-nvim.md +528 -0
- package/lib/exercises/{iterm2-pane-navigation.md → 02-iterm2-pane-navigation.md} +1 -1
- package/lib/exercises/05-svelte-first-steps.md +1340 -0
- package/lib/exercises/{curl-and-pipes.md → 06-curl-and-pipes.md} +187 -72
- package/lib/exercises/07-claude-api-first-steps.md +855 -0
- package/lib/exercises/08-playwright-svelte-guide.md +1384 -0
- package/lib/exercises/09-docker-first-steps.md +1475 -0
- package/lib/exercises/{railway-deployment.md → 10-railway-deployment.md} +1 -0
- package/lib/exercises/{aws-billing-detective.md → 11-aws-billing-detective.md} +215 -35
- package/lib/exercises/12-install-skills.md +755 -0
- package/lib/exercises/README.md +180 -0
- package/lib/exercises/utils/booklet-2up.js +133 -0
- package/lib/exercises/utils/booklet-manual-duplex.js +159 -0
- package/lib/exercises/utils/booklet-simple.js +136 -0
- package/lib/exercises/utils/create-booklet.js +116 -0
- package/lib/scripts/aws-ec2-all.sh +58 -0
- package/package.json +3 -2
- /package/lib/exercises/{git-stash-workflow.md → 03-git-stash-workflow.md} +0 -0
- /package/lib/exercises/{array-object-manipulation.md → 04-array-object-manipulation.md} +0 -0
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
# Learn Playwright with Svelte: The Gradual Guide
|
|
2
|
+
|
|
3
|
+
<!-- vim: set foldmethod=marker foldlevel=0: -->
|
|
4
|
+
|
|
5
|
+
## 📖 LazyVim Reading Guide {{{
|
|
6
|
+
|
|
7
|
+
**Start with:** `zM` (close all folds) → Navigate with `za` (toggle fold under cursor)
|
|
8
|
+
|
|
9
|
+
This document uses fold markers `{{{` and `}}}` for organized reading.
|
|
10
|
+
|
|
11
|
+
}}}
|
|
12
|
+
|
|
13
|
+
> **Target audience**: Developers who know Svelte and Tailwind but are new to E2E testing
|
|
14
|
+
> **The confusion we'll solve**: "Wait, `input[name="email"]` looks like CSS... but I use Tailwind classes?"
|
|
15
|
+
> **Time to complete**: 2-3 hours (with breaks)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Table of Contents {{{
|
|
20
|
+
|
|
21
|
+
1. [The Core Confusion (Let's Fix This First)](#the-core-confusion)
|
|
22
|
+
2. [Exercise 0: HTML Attributes vs CSS Classes](#exercise-0-html-attributes-vs-css-classes)
|
|
23
|
+
3. [Exercise 1: Install Playwright](#exercise-1-install-playwright)
|
|
24
|
+
4. [Exercise 2: Your First Test (No Code Changes Needed)](#exercise-2-your-first-test)
|
|
25
|
+
5. [Exercise 3: Understanding Selectors](#exercise-3-understanding-selectors)
|
|
26
|
+
6. [Exercise 4: Testing a Svelte Component](#exercise-4-testing-a-svelte-component)
|
|
27
|
+
7. [Exercise 5: Testing Svelte Runes ($state, $derived)](#exercise-5-testing-svelte-runes)
|
|
28
|
+
8. [Exercise 6: Real-World Login Flow](#exercise-6-real-world-login-flow)
|
|
29
|
+
9. [Exercise 7: Testing User Interactions](#exercise-7-testing-user-interactions)
|
|
30
|
+
10. [Common Pitfalls & Solutions](#common-pitfalls)
|
|
31
|
+
11. [Quick Reference Cheat Sheet](#cheat-sheet)
|
|
32
|
+
|
|
33
|
+
}}}
|
|
34
|
+
|
|
35
|
+
## The Core Confusion {{{
|
|
36
|
+
|
|
37
|
+
### What You're Probably Thinking
|
|
38
|
+
|
|
39
|
+
> "I saw `input[name="email"]` in the test and got anxious. I use Tailwind 4 for CSS. How does this work? Are we selecting by class names? Won't this break when I change my Tailwind classes?"
|
|
40
|
+
|
|
41
|
+
**Good news**: `input[name="email"]` has **NOTHING to do with CSS or Tailwind**.
|
|
42
|
+
|
|
43
|
+
Let me show you exactly what's happening.
|
|
44
|
+
|
|
45
|
+
}}}
|
|
46
|
+
|
|
47
|
+
## Exercise 0: HTML Attributes vs CSS Classes {{{
|
|
48
|
+
|
|
49
|
+
**Goal**: Understand the difference once and for all.
|
|
50
|
+
|
|
51
|
+
### Step 1: Look at a Real HTML Element
|
|
52
|
+
|
|
53
|
+
Open ANY HTML file with an input. Here's an example:
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<input
|
|
57
|
+
name="email"
|
|
58
|
+
id="email-input"
|
|
59
|
+
type="email"
|
|
60
|
+
placeholder="Enter your email"
|
|
61
|
+
class="rounded-lg px-4 py-2 border border-gray-300 focus:ring-2"
|
|
62
|
+
/>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Step 2: Identify the Parts
|
|
66
|
+
|
|
67
|
+
Let me color-code this for you:
|
|
68
|
+
|
|
69
|
+
```html
|
|
70
|
+
<input
|
|
71
|
+
name="email" 🟦 HTML Attribute (semantic identity)
|
|
72
|
+
id="email-input" 🟦 HTML Attribute (unique identifier)
|
|
73
|
+
type="email" 🟦 HTML Attribute (input behavior)
|
|
74
|
+
placeholder="..." 🟦 HTML Attribute (hint text)
|
|
75
|
+
|
|
76
|
+
class="rounded-lg px-4 py-2 border..." 🟨 CSS Classes (visual styling ONLY)
|
|
77
|
+
/>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Step 3: Understand What Each Does
|
|
81
|
+
|
|
82
|
+
| Part | What It Is | Purpose | Can Change? |
|
|
83
|
+
|------|------------|---------|-------------|
|
|
84
|
+
| **`name="email"`** | HTML attribute | Form submission, identifies this field | ❌ Rarely changes |
|
|
85
|
+
| **`id="email-input"`** | HTML attribute | Unique page identifier, label association | ❌ Rarely changes |
|
|
86
|
+
| **`type="email"`** | HTML attribute | Browser validation behavior | ❌ Rarely changes |
|
|
87
|
+
| **`class="..."`** | CSS classes | Visual appearance (Tailwind lives here) | ✅ Changes all the time |
|
|
88
|
+
|
|
89
|
+
### Step 4: The Key Insight
|
|
90
|
+
|
|
91
|
+
**HTML attributes** are like a person's name and ID number.
|
|
92
|
+
**CSS classes** are like their clothes.
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
Person: John Smith (ID: 12345)
|
|
96
|
+
Clothes today: blue shirt, jeans
|
|
97
|
+
Clothes tomorrow: red shirt, shorts
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
John is still John even when clothes change.
|
|
101
|
+
|
|
102
|
+
**Same with HTML:**
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
<!-- Monday (casual styling) -->
|
|
106
|
+
<input name="email" class="rounded-lg px-4 py-2" />
|
|
107
|
+
|
|
108
|
+
<!-- Tuesday (redesign to minimal) -->
|
|
109
|
+
<input name="email" class="rounded-xl p-2" />
|
|
110
|
+
|
|
111
|
+
<!-- Wednesday (complete rebrand) -->
|
|
112
|
+
<input name="email" class="glass-accent border-gold" />
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The `name="email"` **never changed**. Only the clothes (classes) changed.
|
|
116
|
+
|
|
117
|
+
### Step 5: Why This Matters for Testing
|
|
118
|
+
|
|
119
|
+
When you write a Playwright test:
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
await page.fill('input[name="email"]', 'test@example.com');
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
You're saying: **"Find the input element whose NAME is 'email'"**
|
|
126
|
+
|
|
127
|
+
You're NOT saying: **"Find the input with these specific Tailwind classes"**
|
|
128
|
+
|
|
129
|
+
This means:
|
|
130
|
+
- ✅ Tests don't break when you change Tailwind classes
|
|
131
|
+
- ✅ Tests don't break when you redesign your UI
|
|
132
|
+
- ✅ Tests only break when you fundamentally change the HTML structure
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### Quick Self-Check
|
|
137
|
+
|
|
138
|
+
**Question**: If I change this line in my Svelte component:
|
|
139
|
+
|
|
140
|
+
```html
|
|
141
|
+
<!-- Before -->
|
|
142
|
+
<input name="username" class="rounded-lg px-4 py-2" />
|
|
143
|
+
|
|
144
|
+
<!-- After -->
|
|
145
|
+
<input name="username" class="glass-accent p-8 border-2" />
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Will this Playwright test still work?
|
|
149
|
+
|
|
150
|
+
```javascript
|
|
151
|
+
await page.fill('input[name="username"]', 'john');
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Answer**: <details><summary>Click to reveal</summary>YES! The `name="username"` attribute didn't change. Only the CSS classes changed. Playwright doesn't care about CSS classes when you use attribute selectors.</details>
|
|
155
|
+
|
|
156
|
+
}}}
|
|
157
|
+
|
|
158
|
+
## Exercise 1: Install Playwright {{{
|
|
159
|
+
|
|
160
|
+
**Goal**: Get Playwright running in your SvelteKit project.
|
|
161
|
+
|
|
162
|
+
### Step 1: Install Dependencies
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
cd your-sveltekit-project
|
|
166
|
+
npm install -D @playwright/test
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Step 2: Install Browser
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# For fast CI/CD (just Chromium)
|
|
173
|
+
npx playwright install chromium
|
|
174
|
+
|
|
175
|
+
# For thorough testing (all browsers)
|
|
176
|
+
npx playwright install
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Step 3: Create Configuration
|
|
180
|
+
|
|
181
|
+
Create `playwright.config.js` in your project root:
|
|
182
|
+
|
|
183
|
+
```javascript
|
|
184
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
185
|
+
|
|
186
|
+
export default defineConfig({
|
|
187
|
+
// Where test files live
|
|
188
|
+
testDir: './tests/e2e',
|
|
189
|
+
|
|
190
|
+
// Run tests in parallel
|
|
191
|
+
fullyParallel: true,
|
|
192
|
+
|
|
193
|
+
// Retry failed tests (useful for flaky network issues)
|
|
194
|
+
retries: process.env.CI ? 2 : 0,
|
|
195
|
+
|
|
196
|
+
// Reporter (HTML report for debugging)
|
|
197
|
+
reporter: 'html',
|
|
198
|
+
|
|
199
|
+
use: {
|
|
200
|
+
// Base URL for page.goto('/path')
|
|
201
|
+
baseURL: 'http://localhost:5173',
|
|
202
|
+
|
|
203
|
+
// Capture trace on first retry (helps debug failures)
|
|
204
|
+
trace: 'on-first-retry',
|
|
205
|
+
|
|
206
|
+
// Screenshots and videos on failure
|
|
207
|
+
screenshot: 'only-on-failure',
|
|
208
|
+
video: 'retain-on-failure'
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
// Test on desktop Chrome
|
|
212
|
+
projects: [
|
|
213
|
+
{
|
|
214
|
+
name: 'chromium',
|
|
215
|
+
use: { ...devices['Desktop Chrome'] }
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
|
|
219
|
+
// Start dev server before tests
|
|
220
|
+
webServer: {
|
|
221
|
+
command: 'npm run dev',
|
|
222
|
+
url: 'http://localhost:5173',
|
|
223
|
+
reuseExistingServer: !process.env.CI,
|
|
224
|
+
timeout: 120000
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Step 4: Add Test Scripts to package.json
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{
|
|
233
|
+
"scripts": {
|
|
234
|
+
"test:e2e": "playwright test",
|
|
235
|
+
"test:e2e:ui": "playwright test --ui",
|
|
236
|
+
"test:e2e:headed": "playwright test --headed"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Step 5: Create Test Directory
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
mkdir -p tests/e2e
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Verify Installation
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
npx playwright --version
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
You should see something like: `Version 1.48.0`
|
|
254
|
+
|
|
255
|
+
}}}
|
|
256
|
+
|
|
257
|
+
## Exercise 2: Your First Test (No Code Changes Needed) {{{
|
|
258
|
+
|
|
259
|
+
**Goal**: Write a test that visits your homepage without modifying any code.
|
|
260
|
+
|
|
261
|
+
### Step 1: Create Your First Test File
|
|
262
|
+
|
|
263
|
+
Create `tests/e2e/homepage.e2e.js`:
|
|
264
|
+
|
|
265
|
+
```javascript
|
|
266
|
+
import { test, expect } from '@playwright/test';
|
|
267
|
+
|
|
268
|
+
test('homepage should load', async ({ page }) => {
|
|
269
|
+
// Visit the homepage
|
|
270
|
+
await page.goto('/');
|
|
271
|
+
|
|
272
|
+
// Check that SOMETHING loaded (page title exists)
|
|
273
|
+
await expect(page).toHaveTitle(/.+/);
|
|
274
|
+
|
|
275
|
+
console.log('✅ Homepage loaded successfully!');
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Step 2: Run the Test
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
npm run test:e2e
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**What happens:**
|
|
286
|
+
1. Playwright starts your dev server (`npm run dev`)
|
|
287
|
+
2. Opens a browser in the background
|
|
288
|
+
3. Visits `http://localhost:5173/`
|
|
289
|
+
4. Checks if the page has a title
|
|
290
|
+
5. Closes the browser
|
|
291
|
+
6. Shows results
|
|
292
|
+
|
|
293
|
+
### Step 3: Run in Visual Mode (Watch the Browser)
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
npm run test:e2e:headed
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Now you'll SEE the browser open, visit your page, and close. Magic! 🪄
|
|
300
|
+
|
|
301
|
+
### Step 4: Understanding What Just Happened
|
|
302
|
+
|
|
303
|
+
Let's break down the test line by line:
|
|
304
|
+
|
|
305
|
+
```javascript
|
|
306
|
+
import { test, expect } from '@playwright/test';
|
|
307
|
+
// Import testing utilities from Playwright
|
|
308
|
+
|
|
309
|
+
test('homepage should load', async ({ page }) => {
|
|
310
|
+
// test() creates a test case
|
|
311
|
+
// 'homepage should load' = test name (descriptive!)
|
|
312
|
+
// async = test has asynchronous operations
|
|
313
|
+
// { page } = Playwright gives you a browser page object
|
|
314
|
+
|
|
315
|
+
await page.goto('/');
|
|
316
|
+
// goto() navigates to a URL
|
|
317
|
+
// '/' = homepage (uses baseURL from config)
|
|
318
|
+
// await = wait for navigation to complete
|
|
319
|
+
|
|
320
|
+
await expect(page).toHaveTitle(/.+/);
|
|
321
|
+
// expect() = assertion (checking if something is true)
|
|
322
|
+
// toHaveTitle() = checks the <title> tag
|
|
323
|
+
// /.+/ = regex meaning "at least one character"
|
|
324
|
+
// This passes as long as page has SOME title
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Your Turn
|
|
329
|
+
|
|
330
|
+
**Challenge**: Modify the test to check for a specific element on your homepage.
|
|
331
|
+
|
|
332
|
+
Example:
|
|
333
|
+
```javascript
|
|
334
|
+
test('homepage has navigation', async ({ page }) => {
|
|
335
|
+
await page.goto('/');
|
|
336
|
+
|
|
337
|
+
// Check if there's a <nav> element
|
|
338
|
+
await expect(page.locator('nav')).toBeVisible();
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
}}}
|
|
343
|
+
|
|
344
|
+
## Exercise 3: Understanding Selectors {{{
|
|
345
|
+
|
|
346
|
+
**Goal**: Master the different ways to find elements.
|
|
347
|
+
|
|
348
|
+
### The Selector Hierarchy (Best → Worst)
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
1. 🥇 Accessible role + name (most stable, best for a11y)
|
|
352
|
+
2. 🥈 Test IDs (data-testid, designed for testing)
|
|
353
|
+
3. 🥉 Semantic HTML attributes (name, type, id)
|
|
354
|
+
4. 🏅 Text content (good for unique text)
|
|
355
|
+
5. ⚠️ CSS classes (fragile, avoid)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Method 1: Accessible Roles (BEST)
|
|
359
|
+
|
|
360
|
+
Playwright can find elements the same way screen readers do.
|
|
361
|
+
|
|
362
|
+
**Your Svelte component:**
|
|
363
|
+
```svelte
|
|
364
|
+
<button>Login</button>
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Playwright test:**
|
|
368
|
+
```javascript
|
|
369
|
+
await page.getByRole('button', { name: 'Login' }).click();
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
**Why this is great:**
|
|
373
|
+
- ✅ Doesn't rely on classes
|
|
374
|
+
- ✅ Tests accessibility at the same time
|
|
375
|
+
- ✅ Works if you change styling completely
|
|
376
|
+
|
|
377
|
+
### Method 2: Test IDs (STABLE)
|
|
378
|
+
|
|
379
|
+
Add a special attribute ONLY for testing.
|
|
380
|
+
|
|
381
|
+
**Your Svelte component:**
|
|
382
|
+
```svelte
|
|
383
|
+
<input
|
|
384
|
+
name="email"
|
|
385
|
+
type="email"
|
|
386
|
+
data-testid="email-input"
|
|
387
|
+
class="rounded-lg px-4 py-2"
|
|
388
|
+
/>
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Playwright test:**
|
|
392
|
+
```javascript
|
|
393
|
+
await page.getByTestId('email-input').fill('test@example.com');
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
**Why this is good:**
|
|
397
|
+
- ✅ Super stable (only changes if you delete the attribute)
|
|
398
|
+
- ✅ Clear intent (this element is meant to be tested)
|
|
399
|
+
- ✅ Doesn't depend on text content
|
|
400
|
+
|
|
401
|
+
### Method 3: HTML Attributes (GOOD)
|
|
402
|
+
|
|
403
|
+
Use semantic HTML attributes like `name`, `type`, `id`.
|
|
404
|
+
|
|
405
|
+
**Your Svelte component:**
|
|
406
|
+
```svelte
|
|
407
|
+
<input name="username" type="text" />
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**Playwright test:**
|
|
411
|
+
```javascript
|
|
412
|
+
// CSS attribute selector syntax
|
|
413
|
+
await page.locator('input[name="username"]').fill('john');
|
|
414
|
+
await page.locator('input[type="password"]').fill('secret123');
|
|
415
|
+
await page.locator('#username').fill('john'); // if it has id="username"
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Breaking down the syntax:**
|
|
419
|
+
|
|
420
|
+
```
|
|
421
|
+
'input[name="username"]'
|
|
422
|
+
↑ ↑ ↑
|
|
423
|
+
| | └─ Value to match
|
|
424
|
+
| └─────── Attribute name
|
|
425
|
+
└───────────── Element type (optional)
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
More examples:
|
|
429
|
+
```javascript
|
|
430
|
+
'[name="username"]' // Any element with name="username"
|
|
431
|
+
'input[type="email"]' // Input elements with type="email"
|
|
432
|
+
'button[type="submit"]' // Submit buttons
|
|
433
|
+
'[data-testid="search"]' // Any element with data-testid="search"
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Method 4: Text Content (OK for Unique Text)
|
|
437
|
+
|
|
438
|
+
**Your Svelte component:**
|
|
439
|
+
```svelte
|
|
440
|
+
<button>Delete Account</button>
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
**Playwright test:**
|
|
444
|
+
```javascript
|
|
445
|
+
await page.getByText('Delete Account').click();
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**Warning**: Breaks if you change the text (e.g., translation, rewording).
|
|
449
|
+
|
|
450
|
+
### Method 5: CSS Classes (AVOID)
|
|
451
|
+
|
|
452
|
+
**❌ Don't do this:**
|
|
453
|
+
```javascript
|
|
454
|
+
await page.locator('.rounded-lg.px-4.py-2.border').fill('test');
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
**Why it's bad:**
|
|
458
|
+
- Breaks when you change Tailwind classes
|
|
459
|
+
- Breaks during UI redesigns
|
|
460
|
+
- Not semantic (doesn't describe what the element IS)
|
|
461
|
+
|
|
462
|
+
### Visual Comparison
|
|
463
|
+
|
|
464
|
+
Let's say you have this input:
|
|
465
|
+
|
|
466
|
+
```svelte
|
|
467
|
+
<input
|
|
468
|
+
id="email"
|
|
469
|
+
name="email"
|
|
470
|
+
type="email"
|
|
471
|
+
data-testid="email-input"
|
|
472
|
+
class="rounded-lg px-4 py-2 border border-gray-300"
|
|
473
|
+
placeholder="Enter email"
|
|
474
|
+
/>
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**All these selectors find the SAME element:**
|
|
478
|
+
|
|
479
|
+
```javascript
|
|
480
|
+
// 🥇 Best: Accessible label (if you have <label for="email">)
|
|
481
|
+
await page.getByLabel('Email').fill('test@example.com');
|
|
482
|
+
|
|
483
|
+
// 🥈 Great: Test ID
|
|
484
|
+
await page.getByTestId('email-input').fill('test@example.com');
|
|
485
|
+
|
|
486
|
+
// 🥉 Good: Attribute selectors
|
|
487
|
+
await page.locator('input[name="email"]').fill('test@example.com');
|
|
488
|
+
await page.locator('input[type="email"]').fill('test@example.com');
|
|
489
|
+
await page.locator('#email').fill('test@example.com');
|
|
490
|
+
|
|
491
|
+
// 🏅 OK: Placeholder text
|
|
492
|
+
await page.getByPlaceholder('Enter email').fill('test@example.com');
|
|
493
|
+
|
|
494
|
+
// ⚠️ Fragile: CSS classes (don't do this!)
|
|
495
|
+
await page.locator('.rounded-lg.px-4').fill('test@example.com');
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### Your Turn
|
|
499
|
+
|
|
500
|
+
Given this Svelte component:
|
|
501
|
+
|
|
502
|
+
```svelte
|
|
503
|
+
<form>
|
|
504
|
+
<label for="username">Username</label>
|
|
505
|
+
<input
|
|
506
|
+
id="username"
|
|
507
|
+
name="username"
|
|
508
|
+
type="text"
|
|
509
|
+
class="glass-accent rounded-xl p-4"
|
|
510
|
+
/>
|
|
511
|
+
|
|
512
|
+
<button type="submit">Submit</button>
|
|
513
|
+
</form>
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
**Challenge**: Write three different selectors to find the username input (use Methods 1, 2, and 3).
|
|
517
|
+
|
|
518
|
+
<details>
|
|
519
|
+
<summary>Solution</summary>
|
|
520
|
+
|
|
521
|
+
```javascript
|
|
522
|
+
// Method 1: Accessible label
|
|
523
|
+
await page.getByLabel('Username').fill('john');
|
|
524
|
+
|
|
525
|
+
// Method 2: Would need to add data-testid first
|
|
526
|
+
// <input data-testid="username-input" ... />
|
|
527
|
+
await page.getByTestId('username-input').fill('john');
|
|
528
|
+
|
|
529
|
+
// Method 3: HTML attributes
|
|
530
|
+
await page.locator('input[name="username"]').fill('john');
|
|
531
|
+
await page.locator('#username').fill('john');
|
|
532
|
+
await page.locator('input[type="text"]').fill('john');
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
</details>
|
|
536
|
+
|
|
537
|
+
}}}
|
|
538
|
+
|
|
539
|
+
## Exercise 4: Testing a Svelte Component {{{
|
|
540
|
+
|
|
541
|
+
**Goal**: Write a complete test for a real Svelte component with Tailwind styling.
|
|
542
|
+
|
|
543
|
+
### Step 1: Create a Simple Counter Component
|
|
544
|
+
|
|
545
|
+
`src/lib/components/Counter.svelte`:
|
|
546
|
+
|
|
547
|
+
```svelte
|
|
548
|
+
<script>
|
|
549
|
+
let count = $state(0);
|
|
550
|
+
|
|
551
|
+
function increment() {
|
|
552
|
+
count += 1;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function decrement() {
|
|
556
|
+
count -= 1;
|
|
557
|
+
}
|
|
558
|
+
</script>
|
|
559
|
+
|
|
560
|
+
<div class="flex flex-col items-center gap-4 rounded-lg bg-white p-8 shadow-lg">
|
|
561
|
+
<h2 class="text-2xl font-bold text-gray-800">Counter</h2>
|
|
562
|
+
|
|
563
|
+
<div class="flex items-center gap-4">
|
|
564
|
+
<button
|
|
565
|
+
onclick={decrement}
|
|
566
|
+
class="rounded-lg bg-red-500 px-4 py-2 text-white hover:bg-red-600"
|
|
567
|
+
data-testid="decrement"
|
|
568
|
+
>
|
|
569
|
+
-
|
|
570
|
+
</button>
|
|
571
|
+
|
|
572
|
+
<span
|
|
573
|
+
class="text-4xl font-bold text-gray-900"
|
|
574
|
+
data-testid="count"
|
|
575
|
+
>
|
|
576
|
+
{count}
|
|
577
|
+
</span>
|
|
578
|
+
|
|
579
|
+
<button
|
|
580
|
+
onclick={increment}
|
|
581
|
+
class="rounded-lg bg-green-500 px-4 py-2 text-white hover:bg-green-600"
|
|
582
|
+
data-testid="increment"
|
|
583
|
+
>
|
|
584
|
+
+
|
|
585
|
+
</button>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Step 2: Create a Page That Uses It
|
|
591
|
+
|
|
592
|
+
`src/routes/counter/+page.svelte`:
|
|
593
|
+
|
|
594
|
+
```svelte
|
|
595
|
+
<script>
|
|
596
|
+
import Counter from '$lib/components/Counter.svelte';
|
|
597
|
+
</script>
|
|
598
|
+
|
|
599
|
+
<div class="flex min-h-screen items-center justify-center bg-gray-100">
|
|
600
|
+
<Counter />
|
|
601
|
+
</div>
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### Step 3: Write the E2E Test
|
|
605
|
+
|
|
606
|
+
`tests/e2e/counter.e2e.js`:
|
|
607
|
+
|
|
608
|
+
```javascript
|
|
609
|
+
import { test, expect } from '@playwright/test';
|
|
610
|
+
|
|
611
|
+
test.describe('Counter Component', () => {
|
|
612
|
+
test('should start at 0', async ({ page }) => {
|
|
613
|
+
await page.goto('/counter');
|
|
614
|
+
|
|
615
|
+
// Find the count display
|
|
616
|
+
const count = page.getByTestId('count');
|
|
617
|
+
|
|
618
|
+
// Check initial value
|
|
619
|
+
await expect(count).toHaveText('0');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('should increment when + button is clicked', async ({ page }) => {
|
|
623
|
+
await page.goto('/counter');
|
|
624
|
+
|
|
625
|
+
// Click the increment button
|
|
626
|
+
await page.getByTestId('increment').click();
|
|
627
|
+
|
|
628
|
+
// Check count increased
|
|
629
|
+
await expect(page.getByTestId('count')).toHaveText('1');
|
|
630
|
+
|
|
631
|
+
// Click again
|
|
632
|
+
await page.getByTestId('increment').click();
|
|
633
|
+
|
|
634
|
+
// Should be 2 now
|
|
635
|
+
await expect(page.getByTestId('count')).toHaveText('2');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test('should decrement when - button is clicked', async ({ page }) => {
|
|
639
|
+
await page.goto('/counter');
|
|
640
|
+
|
|
641
|
+
// Start by incrementing to 3
|
|
642
|
+
await page.getByTestId('increment').click();
|
|
643
|
+
await page.getByTestId('increment').click();
|
|
644
|
+
await page.getByTestId('increment').click();
|
|
645
|
+
await expect(page.getByTestId('count')).toHaveText('3');
|
|
646
|
+
|
|
647
|
+
// Now decrement
|
|
648
|
+
await page.getByTestId('decrement').click();
|
|
649
|
+
await expect(page.getByTestId('count')).toHaveText('2');
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
### Step 4: Run the Test
|
|
655
|
+
|
|
656
|
+
```bash
|
|
657
|
+
npm run test:e2e:headed
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Watch the browser:
|
|
661
|
+
1. Navigate to /counter
|
|
662
|
+
2. Click the + button
|
|
663
|
+
3. Check the count
|
|
664
|
+
4. Click the - button
|
|
665
|
+
5. All tests pass! ✅
|
|
666
|
+
|
|
667
|
+
### Step 5: Understanding What Just Happened
|
|
668
|
+
|
|
669
|
+
**Key insights:**
|
|
670
|
+
|
|
671
|
+
1. **data-testid is your friend** - Notice we added `data-testid="count"` to the span. This makes testing super stable.
|
|
672
|
+
|
|
673
|
+
2. **Tailwind classes are invisible** - We have `class="text-4xl font-bold text-gray-900"` on the count, but the test doesn't care. It only looks for `data-testid="count"`.
|
|
674
|
+
|
|
675
|
+
3. **Tests survive redesigns** - If you change the Tailwind classes to `class="text-6xl text-blue-500"`, the test still works!
|
|
676
|
+
|
|
677
|
+
### Your Turn
|
|
678
|
+
|
|
679
|
+
**Challenge**: Modify the Counter component to:
|
|
680
|
+
1. Prevent going below 0
|
|
681
|
+
2. Add a "Reset" button
|
|
682
|
+
3. Write tests for both new features
|
|
683
|
+
|
|
684
|
+
<details>
|
|
685
|
+
<summary>Solution</summary>
|
|
686
|
+
|
|
687
|
+
**Updated Counter.svelte:**
|
|
688
|
+
```svelte
|
|
689
|
+
<script>
|
|
690
|
+
let count = $state(0);
|
|
691
|
+
|
|
692
|
+
function increment() {
|
|
693
|
+
count += 1;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function decrement() {
|
|
697
|
+
if (count > 0) {
|
|
698
|
+
count -= 1;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function reset() {
|
|
703
|
+
count = 0;
|
|
704
|
+
}
|
|
705
|
+
</script>
|
|
706
|
+
|
|
707
|
+
<div class="flex flex-col items-center gap-4 rounded-lg bg-white p-8 shadow-lg">
|
|
708
|
+
<h2 class="text-2xl font-bold text-gray-800">Counter</h2>
|
|
709
|
+
|
|
710
|
+
<div class="flex items-center gap-4">
|
|
711
|
+
<button
|
|
712
|
+
onclick={decrement}
|
|
713
|
+
disabled={count === 0}
|
|
714
|
+
class="rounded-lg bg-red-500 px-4 py-2 text-white hover:bg-red-600 disabled:opacity-50"
|
|
715
|
+
data-testid="decrement"
|
|
716
|
+
>
|
|
717
|
+
-
|
|
718
|
+
</button>
|
|
719
|
+
|
|
720
|
+
<span class="text-4xl font-bold text-gray-900" data-testid="count">
|
|
721
|
+
{count}
|
|
722
|
+
</span>
|
|
723
|
+
|
|
724
|
+
<button
|
|
725
|
+
onclick={increment}
|
|
726
|
+
class="rounded-lg bg-green-500 px-4 py-2 text-white hover:bg-green-600"
|
|
727
|
+
data-testid="increment"
|
|
728
|
+
>
|
|
729
|
+
+
|
|
730
|
+
</button>
|
|
731
|
+
</div>
|
|
732
|
+
|
|
733
|
+
<button
|
|
734
|
+
onclick={reset}
|
|
735
|
+
class="rounded bg-gray-500 px-4 py-2 text-white hover:bg-gray-600"
|
|
736
|
+
data-testid="reset"
|
|
737
|
+
>
|
|
738
|
+
Reset
|
|
739
|
+
</button>
|
|
740
|
+
</div>
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**New tests:**
|
|
744
|
+
```javascript
|
|
745
|
+
test('should not go below 0', async ({ page }) => {
|
|
746
|
+
await page.goto('/counter');
|
|
747
|
+
|
|
748
|
+
// Try to decrement from 0
|
|
749
|
+
await page.getByTestId('decrement').click();
|
|
750
|
+
|
|
751
|
+
// Should still be 0
|
|
752
|
+
await expect(page.getByTestId('count')).toHaveText('0');
|
|
753
|
+
|
|
754
|
+
// Decrement button should be disabled
|
|
755
|
+
await expect(page.getByTestId('decrement')).toBeDisabled();
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('should reset to 0', async ({ page }) => {
|
|
759
|
+
await page.goto('/counter');
|
|
760
|
+
|
|
761
|
+
// Increment to 5
|
|
762
|
+
for (let i = 0; i < 5; i++) {
|
|
763
|
+
await page.getByTestId('increment').click();
|
|
764
|
+
}
|
|
765
|
+
await expect(page.getByTestId('count')).toHaveText('5');
|
|
766
|
+
|
|
767
|
+
// Reset
|
|
768
|
+
await page.getByTestId('reset').click();
|
|
769
|
+
|
|
770
|
+
// Should be 0
|
|
771
|
+
await expect(page.getByTestId('count')).toHaveText('0');
|
|
772
|
+
});
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
</details>
|
|
776
|
+
|
|
777
|
+
}}}
|
|
778
|
+
|
|
779
|
+
## Exercise 5: Testing Svelte Runes ($state, $derived) {{{
|
|
780
|
+
|
|
781
|
+
**Goal**: Understand how Playwright tests Svelte 5's reactivity.
|
|
782
|
+
|
|
783
|
+
### The Key Insight
|
|
784
|
+
|
|
785
|
+
**Playwright doesn't "see" JavaScript state. It sees the DOM.**
|
|
786
|
+
|
|
787
|
+
```svelte
|
|
788
|
+
<script>
|
|
789
|
+
let count = $state(0); // ← Playwright can't see this
|
|
790
|
+
</script>
|
|
791
|
+
|
|
792
|
+
<span data-testid="count">{count}</span> // ← Playwright sees this!
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
When `count` changes, Svelte updates the DOM. Playwright waits for the DOM update.
|
|
796
|
+
|
|
797
|
+
### Example: Testing $derived
|
|
798
|
+
|
|
799
|
+
`src/lib/components/TemperatureConverter.svelte`:
|
|
800
|
+
|
|
801
|
+
```svelte
|
|
802
|
+
<script>
|
|
803
|
+
let celsius = $state(0);
|
|
804
|
+
|
|
805
|
+
// $derived auto-calculates fahrenheit
|
|
806
|
+
let fahrenheit = $derived(celsius * 9/5 + 32);
|
|
807
|
+
let status = $derived(
|
|
808
|
+
celsius < 0 ? 'Freezing' :
|
|
809
|
+
celsius < 20 ? 'Cold' :
|
|
810
|
+
celsius < 30 ? 'Warm' :
|
|
811
|
+
'Hot'
|
|
812
|
+
);
|
|
813
|
+
</script>
|
|
814
|
+
|
|
815
|
+
<div class="rounded-lg bg-white p-6 shadow-lg">
|
|
816
|
+
<h2 class="mb-4 text-xl font-bold">Temperature Converter</h2>
|
|
817
|
+
|
|
818
|
+
<div class="mb-4">
|
|
819
|
+
<label for="celsius" class="block text-sm font-medium">
|
|
820
|
+
Celsius
|
|
821
|
+
</label>
|
|
822
|
+
<input
|
|
823
|
+
id="celsius"
|
|
824
|
+
type="number"
|
|
825
|
+
bind:value={celsius}
|
|
826
|
+
data-testid="celsius-input"
|
|
827
|
+
class="w-full rounded border px-3 py-2"
|
|
828
|
+
/>
|
|
829
|
+
</div>
|
|
830
|
+
|
|
831
|
+
<div class="space-y-2">
|
|
832
|
+
<p data-testid="fahrenheit">
|
|
833
|
+
Fahrenheit: <strong>{fahrenheit.toFixed(1)}°F</strong>
|
|
834
|
+
</p>
|
|
835
|
+
<p data-testid="status" class="text-lg font-semibold">
|
|
836
|
+
Status: {status}
|
|
837
|
+
</p>
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
**The E2E Test:**
|
|
843
|
+
|
|
844
|
+
```javascript
|
|
845
|
+
import { test, expect } from '@playwright/test';
|
|
846
|
+
|
|
847
|
+
test.describe('Temperature Converter', () => {
|
|
848
|
+
test('should convert celsius to fahrenheit', async ({ page }) => {
|
|
849
|
+
await page.goto('/temperature');
|
|
850
|
+
|
|
851
|
+
// Type 0°C
|
|
852
|
+
await page.getByTestId('celsius-input').fill('0');
|
|
853
|
+
|
|
854
|
+
// Should show 32°F (freezing point of water)
|
|
855
|
+
await expect(page.getByTestId('fahrenheit')).toContainText('32.0°F');
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test('should update derived status', async ({ page }) => {
|
|
859
|
+
await page.goto('/temperature');
|
|
860
|
+
|
|
861
|
+
// Test freezing
|
|
862
|
+
await page.getByTestId('celsius-input').fill('-10');
|
|
863
|
+
await expect(page.getByTestId('status')).toContainText('Freezing');
|
|
864
|
+
|
|
865
|
+
// Test cold
|
|
866
|
+
await page.getByTestId('celsius-input').fill('15');
|
|
867
|
+
await expect(page.getByTestId('status')).toContainText('Cold');
|
|
868
|
+
|
|
869
|
+
// Test warm
|
|
870
|
+
await page.getByTestId('celsius-input').fill('25');
|
|
871
|
+
await expect(page.getByTestId('status')).toContainText('Warm');
|
|
872
|
+
|
|
873
|
+
// Test hot
|
|
874
|
+
await page.getByTestId('celsius-input').fill('35');
|
|
875
|
+
await expect(page.getByTestId('status')).toContainText('Hot');
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test('should handle decimal inputs', async ({ page }) => {
|
|
879
|
+
await page.goto('/temperature');
|
|
880
|
+
|
|
881
|
+
// Type 37.5°C (normal body temperature)
|
|
882
|
+
await page.getByTestId('celsius-input').fill('37.5');
|
|
883
|
+
|
|
884
|
+
// Should show 99.5°F
|
|
885
|
+
await expect(page.getByTestId('fahrenheit')).toContainText('99.5°F');
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
**What's happening:**
|
|
891
|
+
1. We type into the input (changes `celsius`)
|
|
892
|
+
2. Svelte's `$derived` automatically updates `fahrenheit` and `status`
|
|
893
|
+
3. Svelte updates the DOM
|
|
894
|
+
4. Playwright sees the DOM change and assertions pass
|
|
895
|
+
|
|
896
|
+
**We're testing Svelte's reactivity without thinking about it!**
|
|
897
|
+
|
|
898
|
+
}}}
|
|
899
|
+
|
|
900
|
+
## Exercise 6: Real-World Login Flow {{{
|
|
901
|
+
|
|
902
|
+
**Goal**: Test a complete authentication flow with error handling.
|
|
903
|
+
|
|
904
|
+
### Your Login Component
|
|
905
|
+
|
|
906
|
+
Let's use a realistic login form with Tailwind:
|
|
907
|
+
|
|
908
|
+
`src/lib/components/LoginForm.svelte`:
|
|
909
|
+
|
|
910
|
+
```svelte
|
|
911
|
+
<script>
|
|
912
|
+
let email = $state('');
|
|
913
|
+
let password = $state('');
|
|
914
|
+
let loading = $state(false);
|
|
915
|
+
let error = $state('');
|
|
916
|
+
|
|
917
|
+
async function handleLogin(event) {
|
|
918
|
+
event.preventDefault();
|
|
919
|
+
loading = true;
|
|
920
|
+
error = '';
|
|
921
|
+
|
|
922
|
+
// Simulate API call
|
|
923
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
924
|
+
|
|
925
|
+
if (email === 'admin@test.com' && password === 'password123') {
|
|
926
|
+
window.location.href = '/dashboard';
|
|
927
|
+
} else {
|
|
928
|
+
error = 'Invalid email or password';
|
|
929
|
+
loading = false;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
</script>
|
|
933
|
+
|
|
934
|
+
<form
|
|
935
|
+
onsubmit={handleLogin}
|
|
936
|
+
class="glass-accent w-full max-w-md rounded-xl p-8"
|
|
937
|
+
>
|
|
938
|
+
<h2 class="mb-6 text-2xl font-bold text-white">Login</h2>
|
|
939
|
+
|
|
940
|
+
<div class="mb-4">
|
|
941
|
+
<label for="email" class="sr-only">Email</label>
|
|
942
|
+
<input
|
|
943
|
+
id="email"
|
|
944
|
+
name="email"
|
|
945
|
+
type="email"
|
|
946
|
+
bind:value={email}
|
|
947
|
+
data-testid="email-input"
|
|
948
|
+
placeholder="Email"
|
|
949
|
+
required
|
|
950
|
+
class="w-full rounded-lg bg-white/20 px-4 py-3 text-white placeholder-white/60"
|
|
951
|
+
/>
|
|
952
|
+
</div>
|
|
953
|
+
|
|
954
|
+
<div class="mb-4">
|
|
955
|
+
<label for="password" class="sr-only">Password</label>
|
|
956
|
+
<input
|
|
957
|
+
id="password"
|
|
958
|
+
name="password"
|
|
959
|
+
type="password"
|
|
960
|
+
bind:value={password}
|
|
961
|
+
data-testid="password-input"
|
|
962
|
+
placeholder="Password"
|
|
963
|
+
required
|
|
964
|
+
class="w-full rounded-lg bg-white/20 px-4 py-3 text-white placeholder-white/60"
|
|
965
|
+
/>
|
|
966
|
+
</div>
|
|
967
|
+
|
|
968
|
+
{#if error}
|
|
969
|
+
<div
|
|
970
|
+
class="mb-4 rounded-lg bg-red-500/20 p-3"
|
|
971
|
+
data-testid="error-message"
|
|
972
|
+
>
|
|
973
|
+
<p class="text-sm text-red-200">{error}</p>
|
|
974
|
+
</div>
|
|
975
|
+
{/if}
|
|
976
|
+
|
|
977
|
+
<button
|
|
978
|
+
type="submit"
|
|
979
|
+
disabled={loading}
|
|
980
|
+
data-testid="login-button"
|
|
981
|
+
class="w-full rounded-lg bg-white px-4 py-3 font-semibold text-gray-900 hover:bg-gray-100 disabled:opacity-50"
|
|
982
|
+
>
|
|
983
|
+
{loading ? 'Logging in...' : 'Login'}
|
|
984
|
+
</button>
|
|
985
|
+
</form>
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
### The Complete E2E Test
|
|
989
|
+
|
|
990
|
+
`tests/e2e/login.e2e.js`:
|
|
991
|
+
|
|
992
|
+
```javascript
|
|
993
|
+
import { test, expect } from '@playwright/test';
|
|
994
|
+
|
|
995
|
+
test.describe('Login Flow', () => {
|
|
996
|
+
test('should show login form', async ({ page }) => {
|
|
997
|
+
await page.goto('/login');
|
|
998
|
+
|
|
999
|
+
// Check all form elements are visible
|
|
1000
|
+
await expect(page.getByTestId('email-input')).toBeVisible();
|
|
1001
|
+
await expect(page.getByTestId('password-input')).toBeVisible();
|
|
1002
|
+
await expect(page.getByTestId('login-button')).toBeVisible();
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test('should login with valid credentials', async ({ page }) => {
|
|
1006
|
+
await page.goto('/login');
|
|
1007
|
+
|
|
1008
|
+
// Fill form using data-testid (stable!)
|
|
1009
|
+
await page.getByTestId('email-input').fill('admin@test.com');
|
|
1010
|
+
await page.getByTestId('password-input').fill('password123');
|
|
1011
|
+
|
|
1012
|
+
// Click login
|
|
1013
|
+
await page.getByTestId('login-button').click();
|
|
1014
|
+
|
|
1015
|
+
// Should redirect to dashboard
|
|
1016
|
+
await expect(page).toHaveURL('/dashboard');
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
test('should show error with invalid credentials', async ({ page }) => {
|
|
1020
|
+
await page.goto('/login');
|
|
1021
|
+
|
|
1022
|
+
// Fill with wrong credentials
|
|
1023
|
+
await page.getByTestId('email-input').fill('wrong@test.com');
|
|
1024
|
+
await page.getByTestId('password-input').fill('wrongpassword');
|
|
1025
|
+
|
|
1026
|
+
// Submit
|
|
1027
|
+
await page.getByTestId('login-button').click();
|
|
1028
|
+
|
|
1029
|
+
// Should show error message
|
|
1030
|
+
await expect(page.getByTestId('error-message')).toBeVisible();
|
|
1031
|
+
await expect(page.getByTestId('error-message')).toContainText('Invalid email or password');
|
|
1032
|
+
|
|
1033
|
+
// Should stay on login page
|
|
1034
|
+
await expect(page).toHaveURL('/login');
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test('should show loading state during login', async ({ page }) => {
|
|
1038
|
+
await page.goto('/login');
|
|
1039
|
+
|
|
1040
|
+
await page.getByTestId('email-input').fill('admin@test.com');
|
|
1041
|
+
await page.getByTestId('password-input').fill('password123');
|
|
1042
|
+
|
|
1043
|
+
// Click submit
|
|
1044
|
+
await page.getByTestId('login-button').click();
|
|
1045
|
+
|
|
1046
|
+
// Button should show "Logging in..." immediately
|
|
1047
|
+
await expect(page.getByTestId('login-button')).toContainText('Logging in...');
|
|
1048
|
+
|
|
1049
|
+
// Button should be disabled
|
|
1050
|
+
await expect(page.getByTestId('login-button')).toBeDisabled();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
test('should validate required fields', async ({ page }) => {
|
|
1054
|
+
await page.goto('/login');
|
|
1055
|
+
|
|
1056
|
+
// Try to submit empty form
|
|
1057
|
+
await page.getByTestId('login-button').click();
|
|
1058
|
+
|
|
1059
|
+
// Browser's built-in validation should prevent submission
|
|
1060
|
+
// (won't navigate away)
|
|
1061
|
+
await expect(page).toHaveURL('/login');
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
### Key Observations
|
|
1067
|
+
|
|
1068
|
+
**Notice what we're NOT doing:**
|
|
1069
|
+
- ❌ Not testing Tailwind classes
|
|
1070
|
+
- ❌ Not checking specific CSS values
|
|
1071
|
+
- ❌ Not testing colors or font sizes
|
|
1072
|
+
|
|
1073
|
+
**What we ARE testing:**
|
|
1074
|
+
- ✅ Functional behavior (does login work?)
|
|
1075
|
+
- ✅ Error handling (does error show?)
|
|
1076
|
+
- ✅ Loading states (does button disable?)
|
|
1077
|
+
- ✅ Navigation (does redirect happen?)
|
|
1078
|
+
|
|
1079
|
+
**The Tailwind classes could completely change** and these tests would still pass!
|
|
1080
|
+
|
|
1081
|
+
}}}
|
|
1082
|
+
|
|
1083
|
+
## Exercise 7: Testing User Interactions {{{
|
|
1084
|
+
|
|
1085
|
+
**Goal**: Test complex interactions like keyboard navigation, form validation, and multi-step flows.
|
|
1086
|
+
|
|
1087
|
+
### Example: Search with Debounce
|
|
1088
|
+
|
|
1089
|
+
`src/lib/components/SearchBar.svelte`:
|
|
1090
|
+
|
|
1091
|
+
```svelte
|
|
1092
|
+
<script>
|
|
1093
|
+
import { debounce } from '$lib/utils/debounce.js';
|
|
1094
|
+
|
|
1095
|
+
let query = $state('');
|
|
1096
|
+
let results = $state([]);
|
|
1097
|
+
let loading = $state(false);
|
|
1098
|
+
|
|
1099
|
+
const search = debounce(async (searchQuery) => {
|
|
1100
|
+
if (!searchQuery.trim()) {
|
|
1101
|
+
results = [];
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
loading = true;
|
|
1106
|
+
|
|
1107
|
+
// Simulate API call
|
|
1108
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1109
|
+
|
|
1110
|
+
// Mock results
|
|
1111
|
+
results = [
|
|
1112
|
+
`Result 1 for "${searchQuery}"`,
|
|
1113
|
+
`Result 2 for "${searchQuery}"`,
|
|
1114
|
+
`Result 3 for "${searchQuery}"`
|
|
1115
|
+
];
|
|
1116
|
+
|
|
1117
|
+
loading = false;
|
|
1118
|
+
}, 300);
|
|
1119
|
+
|
|
1120
|
+
$effect(() => {
|
|
1121
|
+
search(query);
|
|
1122
|
+
});
|
|
1123
|
+
</script>
|
|
1124
|
+
|
|
1125
|
+
<div class="w-full max-w-md">
|
|
1126
|
+
<input
|
|
1127
|
+
type="search"
|
|
1128
|
+
bind:value={query}
|
|
1129
|
+
placeholder="Search..."
|
|
1130
|
+
data-testid="search-input"
|
|
1131
|
+
class="w-full rounded-lg border px-4 py-2"
|
|
1132
|
+
/>
|
|
1133
|
+
|
|
1134
|
+
{#if loading}
|
|
1135
|
+
<div data-testid="loading" class="mt-2 text-gray-500">
|
|
1136
|
+
Searching...
|
|
1137
|
+
</div>
|
|
1138
|
+
{/if}
|
|
1139
|
+
|
|
1140
|
+
{#if results.length > 0}
|
|
1141
|
+
<ul data-testid="results-list" class="mt-2 space-y-2">
|
|
1142
|
+
{#each results as result}
|
|
1143
|
+
<li class="rounded bg-gray-100 p-2" data-testid="result-item">
|
|
1144
|
+
{result}
|
|
1145
|
+
</li>
|
|
1146
|
+
{/each}
|
|
1147
|
+
</ul>
|
|
1148
|
+
{/if}
|
|
1149
|
+
</div>
|
|
1150
|
+
```
|
|
1151
|
+
|
|
1152
|
+
### The Test
|
|
1153
|
+
|
|
1154
|
+
```javascript
|
|
1155
|
+
import { test, expect } from '@playwright/test';
|
|
1156
|
+
|
|
1157
|
+
test.describe('Search Bar', () => {
|
|
1158
|
+
test('should show results after typing', async ({ page }) => {
|
|
1159
|
+
await page.goto('/search');
|
|
1160
|
+
|
|
1161
|
+
// Type into search
|
|
1162
|
+
await page.getByTestId('search-input').fill('svelte');
|
|
1163
|
+
|
|
1164
|
+
// Wait for debounce + API call
|
|
1165
|
+
await page.waitForTimeout(1000);
|
|
1166
|
+
|
|
1167
|
+
// Should show results
|
|
1168
|
+
await expect(page.getByTestId('results-list')).toBeVisible();
|
|
1169
|
+
|
|
1170
|
+
// Should have 3 results
|
|
1171
|
+
const results = page.getByTestId('result-item');
|
|
1172
|
+
await expect(results).toHaveCount(3);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
test('should show loading state', async ({ page }) => {
|
|
1176
|
+
await page.goto('/search');
|
|
1177
|
+
|
|
1178
|
+
await page.getByTestId('search-input').fill('test');
|
|
1179
|
+
|
|
1180
|
+
// Loading should appear
|
|
1181
|
+
await expect(page.getByTestId('loading')).toBeVisible();
|
|
1182
|
+
|
|
1183
|
+
// Wait for results to load
|
|
1184
|
+
await page.waitForTimeout(1000);
|
|
1185
|
+
|
|
1186
|
+
// Loading should disappear
|
|
1187
|
+
await expect(page.getByTestId('loading')).not.toBeVisible();
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
test('should clear results when input is cleared', async ({ page }) => {
|
|
1191
|
+
await page.goto('/search');
|
|
1192
|
+
|
|
1193
|
+
// Type and get results
|
|
1194
|
+
await page.getByTestId('search-input').fill('svelte');
|
|
1195
|
+
await page.waitForTimeout(1000);
|
|
1196
|
+
await expect(page.getByTestId('results-list')).toBeVisible();
|
|
1197
|
+
|
|
1198
|
+
// Clear input
|
|
1199
|
+
await page.getByTestId('search-input').clear();
|
|
1200
|
+
await page.waitForTimeout(500);
|
|
1201
|
+
|
|
1202
|
+
// Results should be gone
|
|
1203
|
+
await expect(page.getByTestId('results-list')).not.toBeVisible();
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
}}}
|
|
1209
|
+
|
|
1210
|
+
## Common Pitfalls {{{
|
|
1211
|
+
|
|
1212
|
+
### Pitfall 1: Forgetting to Wait
|
|
1213
|
+
|
|
1214
|
+
**❌ Wrong:**
|
|
1215
|
+
```javascript
|
|
1216
|
+
await page.getByTestId('button').click();
|
|
1217
|
+
expect(page.getByTestId('result')).toBeVisible(); // Missing await!
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
**✅ Correct:**
|
|
1221
|
+
```javascript
|
|
1222
|
+
await page.getByTestId('button').click();
|
|
1223
|
+
await expect(page.getByTestId('result')).toBeVisible();
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
### Pitfall 2: Testing Implementation Details
|
|
1227
|
+
|
|
1228
|
+
**❌ Wrong:**
|
|
1229
|
+
```javascript
|
|
1230
|
+
// Testing that component has a specific variable
|
|
1231
|
+
const component = await page.evaluate(() => {
|
|
1232
|
+
return window.myComponent.count;
|
|
1233
|
+
});
|
|
1234
|
+
expect(component).toBe(5);
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
**✅ Correct:**
|
|
1238
|
+
```javascript
|
|
1239
|
+
// Test what the USER sees
|
|
1240
|
+
await expect(page.getByTestId('count')).toHaveText('5');
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
### Pitfall 3: Brittle Selectors
|
|
1244
|
+
|
|
1245
|
+
**❌ Wrong:**
|
|
1246
|
+
```javascript
|
|
1247
|
+
await page.locator('.rounded-lg.px-4.py-2.bg-blue-500').click();
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
**✅ Correct:**
|
|
1251
|
+
```javascript
|
|
1252
|
+
await page.getByTestId('submit-button').click();
|
|
1253
|
+
// or
|
|
1254
|
+
await page.getByRole('button', { name: 'Submit' }).click();
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
### Pitfall 4: Not Handling Async Operations
|
|
1258
|
+
|
|
1259
|
+
**❌ Wrong:**
|
|
1260
|
+
```javascript
|
|
1261
|
+
await page.getByTestId('search').fill('test');
|
|
1262
|
+
// Immediately check for results (but API hasn't finished!)
|
|
1263
|
+
await expect(page.getByTestId('results')).toBeVisible(); // Fails!
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
**✅ Correct:**
|
|
1267
|
+
```javascript
|
|
1268
|
+
await page.getByTestId('search').fill('test');
|
|
1269
|
+
// Wait for the loading indicator to disappear
|
|
1270
|
+
await expect(page.getByTestId('loading')).not.toBeVisible();
|
|
1271
|
+
// Now check for results
|
|
1272
|
+
await expect(page.getByTestId('results')).toBeVisible();
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
}}}
|
|
1276
|
+
|
|
1277
|
+
## Cheat Sheet {{{
|
|
1278
|
+
|
|
1279
|
+
### Essential Commands
|
|
1280
|
+
|
|
1281
|
+
```javascript
|
|
1282
|
+
// Navigation
|
|
1283
|
+
await page.goto('/path');
|
|
1284
|
+
await page.goBack();
|
|
1285
|
+
await page.reload();
|
|
1286
|
+
|
|
1287
|
+
// Finding elements (best to worst)
|
|
1288
|
+
page.getByRole('button', { name: 'Submit' }) // 🥇 Best
|
|
1289
|
+
page.getByTestId('submit-btn') // 🥈 Great
|
|
1290
|
+
page.getByLabel('Email') // 🥉 Good
|
|
1291
|
+
page.locator('input[name="email"]') // 🏅 OK
|
|
1292
|
+
page.locator('.btn-primary') // ⚠️ Avoid
|
|
1293
|
+
|
|
1294
|
+
// Interactions
|
|
1295
|
+
await element.click();
|
|
1296
|
+
await element.fill('text');
|
|
1297
|
+
await element.clear();
|
|
1298
|
+
await element.selectOption('value');
|
|
1299
|
+
await element.check(); // checkbox
|
|
1300
|
+
await element.uncheck();
|
|
1301
|
+
|
|
1302
|
+
// Assertions
|
|
1303
|
+
await expect(element).toBeVisible();
|
|
1304
|
+
await expect(element).toBeHidden();
|
|
1305
|
+
await expect(element).toBeEnabled();
|
|
1306
|
+
await expect(element).toBeDisabled();
|
|
1307
|
+
await expect(element).toHaveText('exact text');
|
|
1308
|
+
await expect(element).toContainText('partial');
|
|
1309
|
+
await expect(element).toHaveValue('input value');
|
|
1310
|
+
await expect(page).toHaveURL('/expected-path');
|
|
1311
|
+
await expect(page).toHaveTitle('Page Title');
|
|
1312
|
+
|
|
1313
|
+
// Waiting
|
|
1314
|
+
await page.waitForTimeout(1000); // Wait 1 second
|
|
1315
|
+
await page.waitForLoadState('networkidle'); // Wait for network
|
|
1316
|
+
await element.waitFor({ state: 'visible' }); // Wait for element
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
### HTML Attribute Selector Syntax
|
|
1320
|
+
|
|
1321
|
+
```javascript
|
|
1322
|
+
// Pattern: element[attribute="value"]
|
|
1323
|
+
|
|
1324
|
+
'input[name="email"]' // Input with name="email"
|
|
1325
|
+
'button[type="submit"]' // Button with type="submit"
|
|
1326
|
+
'[data-testid="search"]' // Any element with data-testid="search"
|
|
1327
|
+
'input[type="password"]' // Password input
|
|
1328
|
+
'a[href="/about"]' // Link to /about
|
|
1329
|
+
'img[alt="Logo"]' // Image with alt="Logo"
|
|
1330
|
+
|
|
1331
|
+
// You can omit the element type
|
|
1332
|
+
'[name="email"]' // Any element with name="email"
|
|
1333
|
+
|
|
1334
|
+
// You can combine multiple attributes
|
|
1335
|
+
'input[name="email"][type="email"]' // Input with both attributes
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
### When to Use Which Selector
|
|
1339
|
+
|
|
1340
|
+
| Situation | Use This | Example |
|
|
1341
|
+
|-----------|----------|---------|
|
|
1342
|
+
| Button with text | `getByRole` | `page.getByRole('button', { name: 'Submit' })` |
|
|
1343
|
+
| Input with label | `getByLabel` | `page.getByLabel('Email')` |
|
|
1344
|
+
| Element with test ID | `getByTestId` | `page.getByTestId('search-input')` |
|
|
1345
|
+
| Form input | `locator` with name | `page.locator('input[name="email"]')` |
|
|
1346
|
+
| Unique text | `getByText` | `page.getByText('Welcome back')` |
|
|
1347
|
+
| CSS classes | **DON'T** | Use one of the above instead |
|
|
1348
|
+
|
|
1349
|
+
}}}
|
|
1350
|
+
|
|
1351
|
+
## Summary {{{
|
|
1352
|
+
|
|
1353
|
+
**What you learned:**
|
|
1354
|
+
|
|
1355
|
+
1. ✅ **HTML attributes ≠ CSS classes** - Playwright selectors use HTML attributes, not Tailwind classes
|
|
1356
|
+
2. ✅ **data-testid is your friend** - Add it to components for stable, semantic selectors
|
|
1357
|
+
3. ✅ **Tests survive redesigns** - Change Tailwind classes all you want, tests still pass
|
|
1358
|
+
4. ✅ **Test user behavior** - Click buttons, fill forms, check what users see
|
|
1359
|
+
5. ✅ **Svelte Runes work seamlessly** - $state and $derived updates show in the DOM automatically
|
|
1360
|
+
|
|
1361
|
+
**Your anxiety about `input[name="email"]` is now gone** because you understand:
|
|
1362
|
+
- It's an HTML attribute selector
|
|
1363
|
+
- Has nothing to do with CSS or Tailwind
|
|
1364
|
+
- Looks for the semantic identity of the element
|
|
1365
|
+
- Is stable and won't break when you restyle
|
|
1366
|
+
|
|
1367
|
+
**Next steps:**
|
|
1368
|
+
1. Add data-testid to your critical UI elements
|
|
1369
|
+
2. Write your first E2E test for your app
|
|
1370
|
+
3. Run it in headed mode to see the magic happen
|
|
1371
|
+
4. Gradually add more tests for critical user flows
|
|
1372
|
+
|
|
1373
|
+
}}}
|
|
1374
|
+
|
|
1375
|
+
## Resources {{{
|
|
1376
|
+
|
|
1377
|
+
- **Playwright Docs**: https://playwright.dev
|
|
1378
|
+
- **Playwright Best Practices**: https://playwright.dev/docs/best-practices
|
|
1379
|
+
- **Playwright vs Selenium**: Playwright is faster, more reliable, better API
|
|
1380
|
+
- **This guide**: Keep it handy for reference!
|
|
1381
|
+
|
|
1382
|
+
}}}
|
|
1383
|
+
|
|
1384
|
+
**You've got this.** 🚀
|