@icarusmx/creta 1.5.11 → 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.
Files changed (26) hide show
  1. package/bin/creta.js +37 -1
  2. package/lib/data/command-help/aws-ec2.js +34 -0
  3. package/lib/data/command-help/grep.js +72 -0
  4. package/lib/data/command-help/index.js +9 -1
  5. package/lib/executors/CommandHelpExecutor.js +6 -1
  6. package/lib/executors/ExercisesExecutor.js +8 -0
  7. package/lib/exercises/.claude/settings.local.json +12 -0
  8. package/lib/exercises/01-developing-muscle-for-nvim.md +528 -0
  9. package/lib/exercises/{iterm2-pane-navigation.md → 02-iterm2-pane-navigation.md} +1 -1
  10. package/lib/exercises/05-svelte-first-steps.md +1340 -0
  11. package/lib/exercises/{curl-and-pipes.md → 06-curl-and-pipes.md} +187 -72
  12. package/lib/exercises/07-claude-api-first-steps.md +855 -0
  13. package/lib/exercises/08-playwright-svelte-guide.md +1384 -0
  14. package/lib/exercises/09-docker-first-steps.md +1475 -0
  15. package/lib/exercises/{railway-deployment.md → 10-railway-deployment.md} +1 -0
  16. package/lib/exercises/{aws-billing-detective.md → 11-aws-billing-detective.md} +215 -35
  17. package/lib/exercises/12-install-skills.md +755 -0
  18. package/lib/exercises/README.md +180 -0
  19. package/lib/exercises/utils/booklet-2up.js +133 -0
  20. package/lib/exercises/utils/booklet-manual-duplex.js +159 -0
  21. package/lib/exercises/utils/booklet-simple.js +136 -0
  22. package/lib/exercises/utils/create-booklet.js +116 -0
  23. package/lib/scripts/aws-ec2-all.sh +58 -0
  24. package/package.json +3 -2
  25. /package/lib/exercises/{git-stash-workflow.md → 03-git-stash-workflow.md} +0 -0
  26. /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.** 🚀