{"manifest":{"name":"Vitest Test Writer","version":"1.0.0","description":"Generate complete Vitest + Testing Library test files for Next.js components and hooks following SkillSlap's exact patterns: vi.mock for next/navigation and Supabase, getAllByText for dual-rendered elements, proper beforeEach cleanup.","tags":["vitest","testing","react","typescript","nextjs","skillslap"],"standard":"agentskills.io","standard_version":"1.0","content_checksum":"6bbedcebeebe6002512c541d5caafc66f7dcc7561dc2d3b300d28679b7dbee70","bundle_checksum":null,"metadata":{},"files":[]},"files":{"SKILL.md":"# Vitest Test Writer\n\n> **Purpose:** Generate a complete, passing Vitest + Testing Library test file for a Next.js component or hook. The output must follow SkillSlap's exact test patterns — no guessing at mock shapes, no test utils the project doesn't have.\n\n---\n\n## Invocation\n\n```\n/test <component-path> [description]\n```\n\n**Examples:**\n- `/test components/skills/skill-card.tsx`\n- `/test components/skills/skill-search.tsx Tests all sort/filter/tag interactions`\n- `/test hooks/use-hover-intent.ts`\n\n---\n\n## SkillSlap Test Conventions\n\n### Test file location\n```\ntests/unit/components/[component-name].test.tsx\ntests/unit/hooks/[hook-name].test.ts\n```\n\n### Required imports\n```tsx\nimport { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { render, screen, fireEvent, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\n```\n\n### next/navigation mock — always needed for components using router/params\n```tsx\nconst mockPush = vi.fn()\nconst mockSearchParams = new URLSearchParams()\n\nvi.mock('next/navigation', () => ({\n  useRouter: () => ({ push: mockPush }),\n  useSearchParams: () => mockSearchParams,\n  usePathname: () => '/',\n}))\n```\n\n**Critical:** `useSearchParams()` must return a **stable reference** across renders. Create it once outside `vi.mock()` and use that reference. Otherwise `useEffect([searchParams])` will loop infinitely in tests.\n\n```tsx\n// ✅ Stable reference\nconst stableSearchParams = new URLSearchParams()\nvi.mock('next/navigation', () => ({\n  useSearchParams: () => stableSearchParams,\n  ...\n}))\n\n// ❌ New object every render — causes infinite loop\nvi.mock('next/navigation', () => ({\n  useSearchParams: () => new URLSearchParams(),\n  ...\n}))\n```\n\n### next/link mock\n```tsx\nvi.mock('next/link', () => ({\n  default: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,\n}))\n```\n\n### Supabase client mock\n```tsx\nconst mockFrom = vi.fn()\nconst mockSupabase = { from: mockFrom }\n\nvi.mock('@/lib/supabase/client', () => ({\n  createClient: () => mockSupabase,\n}))\n\n// In test: chain the query mock\nbeforeEach(() => {\n  mockFrom.mockReturnValue({\n    select: vi.fn().mockReturnThis(),\n    eq: vi.fn().mockReturnThis(),\n    order: vi.fn().mockReturnThis(),\n    limit: vi.fn().mockResolvedValue({ data: [], error: null }),\n    insert: vi.fn().mockResolvedValue({ data: null, error: null }),\n  })\n})\n```\n\n### Dual-rendered elements (mobile + desktop)\nMany SkillSlap components render the same content in both a mobile overlay and a desktop section. Use `getAllByText` not `getByText`:\n\n```tsx\n// ❌ Fails if element appears twice\nexpect(screen.getByText('testuser')).toBeInTheDocument()\n\n// ✅ Works for both mobile and desktop renders\nexpect(screen.getAllByText('testuser').length).toBeGreaterThan(0)\n// Or target a specific instance:\nconst badge = screen.getAllByText('85%')[0].closest('[class]')\n```\n\n### Timer mocking for debounce\n```tsx\nbeforeEach(() => {\n  vi.useFakeTimers()\n})\nafterEach(() => {\n  vi.useRealTimers()\n})\n\n// Advance timers in test\nfireEvent.change(input, { target: { value: 'hello' } })\nvi.advanceTimersByTime(400)  // match debounce delay\nexpect(mockPush).toHaveBeenCalledWith('/?search=hello')\n```\n\n### Hooks testing\n```tsx\nimport { renderHook, act } from '@testing-library/react'\n\nit('sets isHovering true after delay', async () => {\n  vi.useFakeTimers()\n  const { result } = renderHook(() => useHoverIntent({ delay: 100 }))\n  act(() => { result.current.onMouseEnter() })\n  expect(result.current.isHovering).toBe(false)  // not yet\n  act(() => { vi.advanceTimersByTime(100) })\n  expect(result.current.isHovering).toBe(true)\n})\n```\n\n### beforeEach cleanup pattern\n```tsx\nbeforeEach(() => {\n  mockPush.mockClear()\n  stableSearchParams.delete('search')\n  stableSearchParams.delete('tag')\n  stableSearchParams.delete('sort')\n})\n```\n\n### Async assertions\n```tsx\n// Wait for async state updates\nawait waitFor(() => {\n  expect(screen.getByText('Success')).toBeInTheDocument()\n})\n```\n\n---\n\n## What to Test\n\n### Components — cover these cases\n1. Renders without crashing (smoke test)\n2. Renders key content (title, description, labels)\n3. Link hrefs are correct\n4. Conditional rendering (show X when Y is true, hide when false)\n5. User interactions: click, type, submit\n6. Loading state while async operation is pending\n7. Error state when operation fails\n8. Empty state when data is absent\n\n### Hooks — cover these cases\n1. Initial return value\n2. State transitions after trigger\n3. Cleanup on unmount (no lingering timers/listeners)\n4. Edge cases (rapid toggling, zero delay)\n\n---\n\n## Output Format\n\nComplete test file, ready to save. Include:\n1. All necessary mocks at the top (before component import)\n2. A `makeX()` factory function for complex test data\n3. `describe` block with `it` tests grouped logically\n4. No skipped or placeholder tests — every test must be asserting something real\n"}}