Initial commit
This commit is contained in:
76
test/CLAUDE.md
Normal file
76
test/CLAUDE.md
Normal 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
23
test/e2e/login.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
40
test/nuxt/counter-widget.test.ts
Normal file
40
test/nuxt/counter-widget.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
26
test/unit/login-schema.test.ts
Normal file
26
test/unit/login-schema.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user