diff --git a/.claude/skills/svelte-testing/README.md b/.claude/skills/svelte-testing/README.md new file mode 100644 index 000000000..41372cb33 --- /dev/null +++ b/.claude/skills/svelte-testing/README.md @@ -0,0 +1,58 @@ +# Sveltest Helper + +**Comprehensive testing tools and examples for building robust Svelte +5 applications** + +This skill provides a complete testing framework for SvelteKit +projects using vitest-browser-svelte. It includes patterns, best +practices, and real-world examples for client-side, server-side, and +SSR testing. + +## What You'll Find Here + +- ✅ **Foundation First methodology** - Plan comprehensive test + coverage before coding +- ✅ **Real browser testing** - Using vitest-browser-svelte with + Playwright +- ✅ **Client-Server alignment** - Test with real FormData/Request + objects +- ✅ **Svelte 5 runes patterns** - Proper use of untrack(), $derived, + and $effect +- ✅ **Common pitfalls solved** - Strict mode, form submission, + accessibility +- ✅ **Production-ready examples** - Copy-paste test templates + +## For Developers + +Use this as a quick reference guide when writing tests: + +- Browse `SKILL.md` for quick patterns and reminders +- Check `references/detailed-guide.md` for comprehensive examples +- Follow the "Unbreakable Rules" to avoid common mistakes + +## For AI Assistants + +This skill is automatically invoked by Claude Code when working with +tests in SvelteKit projects. It provides context-aware guidance for +creating and maintaining high-quality tests. + +## Structure + +- **SKILL.md** - Quick reference for common patterns +- **references/detailed-guide.md** - Complete testing guide with + examples + +## Quick Start + +```bash +# Run all tests +pnpm test + +# Run specific test types +pnpm test:client # Browser component tests +pnpm test:server # API and server logic tests +pnpm test:ssr # Server-side rendering tests + +# Generate coverage +pnpm coverage +``` diff --git a/.claude/skills/svelte-testing/SKILL.md b/.claude/skills/svelte-testing/SKILL.md new file mode 100644 index 000000000..b201be38e --- /dev/null +++ b/.claude/skills/svelte-testing/SKILL.md @@ -0,0 +1,58 @@ +--- +name: svelte-testing +# prettier-ignore +description: Fix and create Svelte 5 tests with vitest-browser-svelte and Playwright. Use when fixing broken tests, debugging failures, writing unit/SSR/e2e tests, or working with vitest/Playwright. +--- + +# Svelte Testing + +## Quick Start + +```typescript +// Client-side component test (.svelte.test.ts) +import { render } from 'vitest-browser-svelte'; +import { expect } from 'vitest'; +import Button from './button.svelte'; + +test('button click increments counter', async () => { + const { page } = render(Button); + const button = page.getByRole('button', { name: /click me/i }); + + await button.click(); + await expect.element(button).toHaveTextContent('Clicked: 1'); +}); +``` + +## Core Principles + +- **Always use locators**: `page.getBy*()` methods, never containers +- **Multiple elements**: Use `.first()`, `.nth()`, `.last()` to avoid + strict mode violations +- **Use untrack()**: When accessing `$derived` values in tests +- **Real API objects**: Test with FormData/Request, minimal mocking + +## Reference Files + +- [core-principles](references/core-principles.md) | + [foundation-first](references/foundation-first.md) | + [client-examples](references/client-examples.md) +- [server-ssr-examples](references/server-ssr-examples.md) | + [critical-patterns](references/critical-patterns.md) +- [client-server-alignment](references/client-server-alignment.md) | + [troubleshooting](references/troubleshooting.md) + +## Notes + +- Never click SvelteKit form submit buttons - Always use + `await expect.element()` +- Test files: `.svelte.test.ts` (client), `.ssr.test.ts` (SSR), + `server.test.ts` (API) + + diff --git a/.claude/skills/svelte-testing/references/client-examples.md b/.claude/skills/svelte-testing/references/client-examples.md new file mode 100644 index 000000000..47877fcf5 --- /dev/null +++ b/.claude/skills/svelte-testing/references/client-examples.md @@ -0,0 +1,181 @@ +## Complete Examples + +### Example 1: Client-Side Component Test + +Real browser testing with user interactions: + +```typescript +// button.svelte.test.ts +import { render } from 'vitest-browser-svelte'; +import { test, expect, describe } from 'vitest'; +import { userEvent } from '@vitest/browser/context'; +import Button from './button.svelte'; + +describe('Button Component', () => { + test('increments counter on click', async () => { + const { page } = render(Button, { props: { label: 'Click me' } }); + + const button = page.getByRole('button', { name: /click me/i }); + + await userEvent.click(button); + await expect.element(button).toHaveTextContent('Clicked: 1'); + + await userEvent.click(button); + await expect.element(button).toHaveTextContent('Clicked: 2'); + }); + + test('supports keyboard interaction', async () => { + render(Button, { props: { label: 'Press me' } }); + + const button = page.getByRole('button', { name: /press me/i }); + await button.focus(); + await userEvent.keyboard('{Enter}'); + + await expect.element(button).toHaveTextContent('Clicked: 1'); + }); + + test('handles multiple buttons with .first()', async () => { + render(ButtonGroup); // Renders multiple buttons + + // Handle multiple buttons explicitly + const firstButton = page.getByRole('button').first(); + const secondButton = page.getByRole('button').nth(1); + + await firstButton.click(); + await expect.element(firstButton).toHaveTextContent('Clicked: 1'); + + await secondButton.click(); + await expect + .element(secondButton) + .toHaveTextContent('Clicked: 1'); + }); +}); +``` + +### Example 2: Testing Svelte 5 Runes + +```typescript +// counter.svelte.test.ts +import { render } from 'vitest-browser-svelte'; +import { test, expect } from 'vitest'; +import { untrack, flushSync } from 'svelte'; +import Counter from './counter.svelte'; + +test('$state and $derived reactivity', async () => { + const { component } = render(Counter); + + // Access $state value directly + expect(component.count).toBe(0); + + // Update state + component.increment(); + + // Force synchronous update + flushSync(() => {}); + + // Access $derived value with untrack + const doubled = untrack(() => component.doubled); + expect(doubled).toBe(2); +}); + +test('form validation lifecycle', async () => { + const { component } = render(FormComponent); + + // Initially valid (no validation run yet) + expect(untrack(() => component.isFormValid())).toBe(true); + + // Trigger validation + component.validateAllFields(); + + // Now invalid (empty required fields) + expect(untrack(() => component.isFormValid())).toBe(false); + + // Fix validation errors + component.email.value = 'test@example.com'; + component.validateAllFields(); + + // Valid again + expect(untrack(() => component.isFormValid())).toBe(true); +}); +``` + +### Example 3: Server-Side API Test + +Test with real FormData/Request objects: + +```typescript +// api/users/server.test.ts +import { test, expect, describe, vi } from 'vitest'; +import { POST } from './+server'; +import * as database from '$lib/server/database'; + +vi.mock('$lib/server/database'); + +describe('POST /api/users', () => { + test('creates user with valid data', async () => { + // Mock only external services + vi.mocked(database.createUser).mockResolvedValue({ + id: '123', + email: 'user@example.com', + }); + + // Use real FormData + const formData = new FormData(); + formData.append('email', 'user@example.com'); + formData.append('password', 'securepass123'); + + // Use real Request object + const request = new Request('http://localhost/api/users', { + method: 'POST', + body: formData, + }); + + const response = await POST({ request }); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.email).toBe('user@example.com'); + expect(database.createUser).toHaveBeenCalledWith({ + email: 'user@example.com', + password: 'securepass123', + }); + }); + + test('rejects invalid email format', async () => { + const formData = new FormData(); + formData.append('email', 'invalid-email'); + formData.append('password', 'pass123'); + + const request = new Request('http://localhost/api/users', { + method: 'POST', + body: formData, + }); + + const response = await POST({ request }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.errors.email).toBeDefined(); + expect(database.createUser).not.toHaveBeenCalled(); + }); + + test('handles missing required fields', async () => { + const formData = new FormData(); + // Missing email and password + + const request = new Request('http://localhost/api/users', { + method: 'POST', + body: formData, + }); + + const response = await POST({ request }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.errors.email).toBeDefined(); + expect(data.errors.password).toBeDefined(); + }); +}); +``` + +--- diff --git a/.claude/skills/svelte-testing/references/client-server-alignment.md b/.claude/skills/svelte-testing/references/client-server-alignment.md new file mode 100644 index 000000000..b3f2895be --- /dev/null +++ b/.claude/skills/svelte-testing/references/client-server-alignment.md @@ -0,0 +1,71 @@ +## Client-Server Alignment + +### The Problem + +Heavy mocking in server tests can hide client-server mismatches that +only appear in production. + +### The Solution + +Use real `FormData` and `Request` objects. Only mock external services +(database, APIs). + +```typescript +// ❌ BRITTLE APPROACH +const mockRequest = { + formData: vi.fn().mockResolvedValue({ + get: vi.fn((key) => { + if (key === 'email') return 'test@example.com'; + if (key === 'password') return 'pass123'; + }), + }), +}; +// This passes even if real FormData API differs! + +// ✅ ROBUST APPROACH +const formData = new FormData(); +formData.append('email', 'test@example.com'); +formData.append('password', 'pass123'); + +const request = new Request('http://localhost/register', { + method: 'POST', + body: formData, +}); + +// Only mock external services +vi.mocked(database.createUser).mockResolvedValue({ + id: '123', + email: 'test@example.com', +}); + +const response = await POST({ request }); +``` + +### Shared Validation Logic + +Use the same validation on client and server: + +```typescript +// lib/validation.ts +export function validateEmail(email: string) { + if (!email) return 'Email is required'; + if (!email.includes('@')) return 'Invalid email format'; + return null; +} + +// Component test +import { validateEmail } from '$lib/validation'; +test('validates email', () => { + expect(validateEmail('')).toBe('Email is required'); + expect(validateEmail('invalid')).toBe('Invalid email format'); + expect(validateEmail('test@example.com')).toBe(null); +}); + +// Server test - same validation! +const emailError = validateEmail(formData.get('email')); +if (emailError) { + return json({ errors: { email: emailError } }, { status: 400 }); +} +``` + +--- diff --git a/.claude/skills/svelte-testing/references/core-principles.md b/.claude/skills/svelte-testing/references/core-principles.md new file mode 100644 index 000000000..306453e19 --- /dev/null +++ b/.claude/skills/svelte-testing/references/core-principles.md @@ -0,0 +1,77 @@ +# Core Principles + +## 1. Always Use Locators, Never Containers + +vitest-browser-svelte uses Playwright-style locators with automatic +retry logic. **Never** use the `container` object. + +```typescript +// ❌ NEVER - No retry logic, brittle tests +const { container } = render(MyComponent); +const button = container.querySelector('button'); + +// ✅ ALWAYS - Auto-retry, resilient tests +render(MyComponent); +const button = page.getByRole('button', { name: 'Submit' }); +await button.click(); +``` + +## 2. Handle Strict Mode Violations + +When multiple elements match a locator, use `.first()`, `.nth()`, or +`.last()`: + +```typescript +// ❌ FAILS: "strict mode violation: resolved to 2 elements" +page.getByRole('link', { name: 'Home' }); // Desktop + mobile nav + +// ✅ CORRECT: Handle multiple elements explicitly +page.getByRole('link', { name: 'Home' }).first(); +page.getByRole('link', { name: 'Home' }).nth(1); // Second element +page.getByRole('link', { name: 'Home' }).last(); +``` + +## 3. Use `untrack()` for $derived Values + +Svelte 5 runes require `untrack()` when accessing `$derived` values in +tests: + +```typescript +import { untrack } from 'svelte'; + +// ✅ Access $derived values +const value = untrack(() => component.derivedValue); +expect(value).toBe(42); + +// ✅ For getters: get function first, then untrack +const derivedFn = component.computedValue; +expect(untrack(() => derivedFn())).toBe(expected); +``` + +## 4. Real FormData/Request Objects + +Use real web APIs instead of heavy mocking to catch client-server +mismatches: + +```typescript +// ❌ BRITTLE: Mocks hide API mismatches +const mockRequest = { + formData: vi.fn().mockResolvedValue({ + get: vi.fn().mockReturnValue('test@example.com'), + }), +}; + +// ✅ ROBUST: Real FormData catches mismatches +const formData = new FormData(); +formData.append('email', 'test@example.com'); +const request = new Request('http://localhost/api/users', { + method: 'POST', + body: formData, +}); + +// Only mock external services +vi.mocked(database.createUser).mockResolvedValue({ + id: '123', + email: 'test@example.com', +}); +``` diff --git a/.claude/skills/svelte-testing/references/critical-patterns.md b/.claude/skills/svelte-testing/references/critical-patterns.md new file mode 100644 index 000000000..f1b6ac736 --- /dev/null +++ b/.claude/skills/svelte-testing/references/critical-patterns.md @@ -0,0 +1,95 @@ +## Critical Patterns + +### Form Handling in SvelteKit + +**NEVER click submit buttons** in SvelteKit forms - they trigger full +page navigation: + +```typescript +// ❌ DON'T - Causes navigation/hangs +const submit = page.getByRole('button', { name: /submit/i }); +await submit.click(); // ⚠️ Infinite hang + +// ✅ DO - Test form state directly +render(MyForm, { props: { errors: { email: 'Required' } } }); + +const emailInput = page.getByRole('textbox', { name: /email/i }); +await emailInput.fill('test@example.com'); + +// Verify form state +await expect.element(emailInput).toHaveValue('test@example.com'); + +// Test error display +await expect.element(page.getByText('Required')).toBeInTheDocument(); +``` + +### Semantic Queries (Preferred) + +Use semantic role-based queries for better accessibility and +maintainability: + +```typescript +// ✅ BEST - Semantic queries +page.getByRole('button', { name: 'Submit' }); +page.getByRole('textbox', { name: 'Email' }); +page.getByRole('heading', { name: 'Welcome', level: 1 }); +page.getByLabel('Email address'); +page.getByText('Welcome back'); + +// ⚠️ OK - Use when no role available +page.getByTestId('custom-widget'); +page.getByPlaceholder('Enter your email'); + +// ❌ AVOID - Brittle, implementation-dependent +container.querySelector('.submit-button'); +``` + +### Common Role Mistakes + +```typescript +// ❌ WRONG: "input" is not a role +page.getByRole('input', { name: 'Email' }); + +// ✅ CORRECT: Use "textbox" for input fields +page.getByRole('textbox', { name: 'Email' }); + +// ❌ WRONG: Using link role when element has role="button" +page.getByRole('link', { name: 'Submit' }); // + +// ✅ CORRECT: Use the actual role attribute +page.getByRole('button', { name: 'Submit' }); + +// ✅ Check actual roles in browser DevTools +// Right-click element → Inspect → Accessibility tab +``` + +### Avoid Testing Implementation Details + +Test user-visible behavior, not internal implementation: + +```typescript +// ❌ BRITTLE - Tests exact SVG path +expect(html).toContain( + 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', +); +// Breaks when icon library updates! + +// ✅ ROBUST - Tests semantic structure +expect(html).toContain('text-success'); // CSS class +expect(html).toContain(' { + describe('Initial Rendering', () => { + test.skip('renders with default props', () => {}); + test.skip('renders all form fields', () => {}); + test.skip('has proper ARIA labels', () => {}); + }); + + describe('Form Validation', () => { + test.skip('validates email format', () => {}); + test.skip('requires all fields', () => {}); + test.skip('shows validation errors', () => {}); + test.skip('validates on blur', () => {}); + }); + + describe('User Interactions', () => { + test.skip('handles input changes', () => {}); + test.skip('clears form on reset', () => {}); + test.skip('disables submit when invalid', () => {}); + }); + + describe('Edge Cases', () => { + test.skip('handles empty submission', () => {}); + test.skip('handles server errors', () => {}); + test.skip('shows loading state', () => {}); + }); + + describe('Accessibility', () => { + test.skip('supports keyboard navigation', () => {}); + test.skip('announces errors to screen readers', () => {}); + }); +}); +``` + +### Step 2: Implement Tests Incrementally + +Remove `.skip` as you implement each test: + +```typescript +describe('ContactForm', () => { + describe('Initial Rendering', () => { + test('renders with default props', async () => { + render(ContactForm); + + await expect + .element(page.getByRole('textbox', { name: /email/i })) + .toBeInTheDocument(); + await expect + .element(page.getByRole('textbox', { name: /message/i })) + .toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /submit/i })) + .toBeInTheDocument(); + }); + + test.skip('renders all form fields', () => {}); + // Continue implementing... + }); +}); +``` + +--- diff --git a/.claude/skills/svelte-testing/references/server-ssr-examples.md b/.claude/skills/svelte-testing/references/server-ssr-examples.md new file mode 100644 index 000000000..8106b8ee4 --- /dev/null +++ b/.claude/skills/svelte-testing/references/server-ssr-examples.md @@ -0,0 +1,53 @@ +## SSR Test Examples + +Test server-side rendering output: + +```typescript +// page.ssr.test.ts +import { test, expect, describe } from 'vitest'; +import PageComponent from './+page.svelte'; + +describe('Page SSR', () => { + test('renders without errors', () => { + expect(() => + PageComponent.render({ + data: { title: 'Welcome' }, + }), + ).not.toThrow(); + }); + + test('renders correct HTML structure', () => { + const { html } = PageComponent.render({ + data: { + title: 'Welcome', + items: ['Alpha', 'Beta', 'Gamma'], + }, + }); + + expect(html).toContain('

Welcome

'); + expect(html).toContain('
  • Alpha
  • '); + expect(html).toContain('
  • Beta
  • '); + expect(html).toContain('
  • Gamma
  • '); + }); + + test('applies correct CSS classes', () => { + const { html } = PageComponent.render({ + data: { status: 'success' }, + }); + + // Test semantic CSS classes, not implementation details + expect(html).toContain('text-success'); + expect(html).toContain(' { + const { html } = PageComponent.render({ + data: { items: [] }, + }); + + expect(html).toContain('No items found'); + }); +}); +``` + +--- diff --git a/.claude/skills/svelte-testing/references/troubleshooting.md b/.claude/skills/svelte-testing/references/troubleshooting.md new file mode 100644 index 000000000..78d737a5a --- /dev/null +++ b/.claude/skills/svelte-testing/references/troubleshooting.md @@ -0,0 +1,178 @@ +## Common Errors & Solutions + +### Error 1: Strict Mode Violation + +**Error:** `strict mode violation: getByRole() resolved to X elements` + +**Cause:** Multiple elements match (common with responsive design - +desktop + mobile nav) + +**Solution:** + +```typescript +// Before +page.getByRole('link', { name: 'Home' }); + +// After +page.getByRole('link', { name: 'Home' }).first(); +``` + +### Error 2: Async Assertion Failures + +**Error:** Element assertions fail intermittently + +**Cause:** Not using `await expect.element()` + +**Solution:** + +```typescript +// ❌ WRONG - No auto-retry +expect(element).toHaveTextContent('text'); + +// ✅ CORRECT - Waits for element +await expect.element(element).toHaveTextContent('text'); +``` + +### Error 3: Cannot Access $derived + +**Error:** Cannot read $derived value in test + +**Cause:** Svelte 5 reactive values need `untrack()` + +**Solution:** + +```typescript +import { untrack } from 'svelte'; + +// Before +const value = component.derivedValue; // Error! + +// After +const value = untrack(() => component.derivedValue); +``` + +### Error 4: Form Submit Hangs + +**Error:** Test hangs after clicking submit button + +**Cause:** SvelteKit form submission triggers full page navigation + +**Solution:** + +```typescript +// ❌ DON'T +await submitButton.click(); // Hangs! + +// ✅ DO - Test form state directly +render(MyForm, { props: { errors: { email: 'Required' } } }); +await expect.element(page.getByText('Required')).toBeInTheDocument(); +``` + +### Error 5: Wrong ARIA Role + +**Error:** Locator doesn't find element + +**Cause:** Using wrong role name + +**Solution:** + +```typescript +// ❌ Wrong roles +page.getByRole('input', { name: 'Email' }); // No "input" role +page.getByRole('div', { name: 'Container' }); // No "div" role + +// ✅ Correct roles +page.getByRole('textbox', { name: 'Email' }); // For +page.getByRole('button', { name: 'Submit' }); // For