Initial commit

This commit is contained in:
2026-04-17 23:26:01 +00:00
commit 2ea4ca5d52
409 changed files with 63459 additions and 0 deletions

76
test/CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# CLAUDE.md — test/
## Skill precedence
When writing or modifying tests, load the **nuxt-testing** skill first:
```
/nuxt-testing
```
It takes precedence over other testing skills (vue-testing-best-practices, vitest, etc.) because it covers the exact `@nuxt/test-utils` setup used here.
## Test structure
| Directory | Project | Environment | Purpose |
|-----------|---------|-------------|---------|
| `test/unit/` | `unit` | node | Pure logic, Zod schemas, utilities |
| `test/nuxt/` | `nuxt` | nuxt (happy-dom) | Components and composables that need Nuxt runtime |
| `test/e2e/` | `e2e` | node | Browser tests against the running dev server |
Run a single project: `pnpm test --project unit` / `nuxt` / `e2e`
## Known gotchas
### Nuxt component tests — mock stores in `vi.hoisted`, not in `it()`
Global middleware (e.g. `00.fetchUser.global.ts`) runs during `mountSuspended` **before** the test body executes. If a mocked store returns `undefined` at that point, Nuxt throws and the test crashes.
**Wrong** — mock is set up too late:
```ts
const useUserMock = vi.fn()
mockNuxtImport('useUser', () => useUserMock)
it('...', async () => {
useUserMock.mockReturnValue({ isAuthenticated: false, authRefresh: vi.fn() })
// ❌ middleware already ran with undefined
})
```
**Correct** — default implementation lives in `vi.hoisted`:
```ts
const { useUserMock } = vi.hoisted(() => ({
useUserMock: vi.fn(() => ({ isAuthenticated: false, user: null, authRefresh: vi.fn() }))
}))
mockNuxtImport('useUser', () => useUserMock)
```
### E2E tests — do not pass a path to `createPage()` on this SPA
`createPage('/some-path')` internally calls `waitForHydration`, which polls
`window.useNuxtApp?.().isHydrating === false`. Because `useNuxtApp` is not
exposed on `window` in SPA mode, this never resolves and the test times out.
**Wrong:**
```ts
const page = await createPage('/login') // ❌ hangs forever
```
**Correct** — call without a path, then navigate manually:
```ts
const page = await createPage()
await page.goto('http://localhost:3000/login', { waitUntil: 'networkidle' })
```
### E2E tests — use `isVisible()` + Vitest `expect`, not Playwright matchers
`@playwright/test` is not installed (only `playwright-core`). Playwright's
`expect(locator).toBeVisible()` is not available.
```ts
// ❌ crashes — @playwright/test not installed
import { expect } from '@playwright/test'
await expect(page.locator('input')).toBeVisible()
// ✅ use the async boolean method with Vitest's expect
import { expect } from 'vitest'
expect(await page.locator('input').isVisible()).toBe(true)
```

23
test/e2e/login.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { setup, createPage } from '@nuxt/test-utils/e2e'
describe('login page', async () => {
await setup({
host: 'http://localhost:3000',
browser: true
})
it('shows the email input and OAuth provider buttons', { timeout: 30_000 }, async () => {
// createPage() without a path skips the internal waitForHydration call
// (which hangs on SPAs because window.useNuxtApp is not exposed)
const page = await createPage()
await page.goto('http://localhost:3000/login', { waitUntil: 'networkidle' })
// Email field rendered by UAuthForm
expect(await page.locator('input[type="text"]').first().isVisible()).toBe(true)
// OAuth buttons for Google and Apple
expect(await page.getByText('Google').isVisible()).toBe(true)
expect(await page.getByText('Apple').isVisible()).toBe(true)
})
})

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from 'vitest'
import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime'
import { ref } from 'vue'
import CounterWidget from '~/components/Counter/Widget.vue'
const { useCounterMock, useUserMock } = vi.hoisted(() => ({
useCounterMock: vi.fn(),
// Default return value set here so the global fetchUser middleware
// doesn't crash before the test body runs
useUserMock: vi.fn(() => ({
isAuthenticated: false,
user: null,
authRefresh: vi.fn()
}))
}))
mockNuxtImport('useCounter', () => useCounterMock)
// Silence the global fetchUser middleware — prevents a real PocketBase authRefresh call
mockNuxtImport('useUser', () => useUserMock)
describe('Counter/Widget', () => {
it('renders the current count and increment/decrement buttons', async () => {
useCounterMock.mockReturnValue({
count: ref(42),
recordId: ref('rec1'),
fetchCurrentCount: vi.fn(),
subscribeToChanges: vi.fn(),
unsubscribe: vi.fn(),
increment: vi.fn(),
reset: vi.fn()
})
const wrapper = await mountSuspended(CounterWidget)
expect(wrapper.text()).toContain('42')
// Both +/- icon buttons should be present
expect(wrapper.findAll('button').length).toBeGreaterThanOrEqual(2)
})
})

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import * as z from 'zod'
// Mirrors the schema defined in app/pages/login.vue
const schema = z.object({
email: z.email(),
otp: z.array(z.string()).optional().transform(val => val?.join(''))
})
describe('login schema', () => {
it('accepts a valid email', () => {
const result = schema.safeParse({ email: 'user@example.com' })
expect(result.success).toBe(true)
})
it('rejects an invalid email', () => {
const result = schema.safeParse({ email: 'not-an-email' })
expect(result.success).toBe(false)
})
it('joins otp array into a single string', () => {
const result = schema.safeParse({ email: 'user@example.com', otp: ['1', '2', '3', '4', '5', '6'] })
expect(result.success).toBe(true)
if (result.success) expect(result.data.otp).toBe('123456')
})
})