Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ The project uses **Nx** for build orchestration and task management

## Testing Guidelines

See @docs/testing/*.md for detailed patterns.

- Frameworks:
- Vitest (unit/component, happy-dom)
- Playwright (E2E)
Expand Down
138 changes: 138 additions & 0 deletions docs/testing/vitest-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
---
globs:
- '**/*.test.ts'
- '**/*.spec.ts'
---

# Vitest Patterns

## Setup

Use `createTestingPinia` from `@pinia/testing`, not `createPinia`:

```typescript
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

describe('MyStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.resetAllMocks()
})

afterEach(() => {
vi.useRealTimers()
})
})
```

**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.

## Mock Patterns

### Reset all mocks at once

```typescript
beforeEach(() => {
vi.resetAllMocks() // Not individual mock.mockReset() calls
})
```

### Module mocks with vi.mock()

```typescript
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
fetchData: vi.fn()
}
}))

vi.mock('@/services/myService', () => ({
myService: {
doThing: vi.fn()
}
}))
```

### Configure mocks in tests

```typescript
import { api } from '@/scripts/api'
import { myService } from '@/services/myService'

it('handles success', () => {
vi.mocked(myService.doThing).mockResolvedValue({ data: 'test' })
// ... test code
})
```

## Testing Event Listeners

When a store registers event listeners at module load time:

```typescript
function getEventHandler() {
const call = vi.mocked(api.addEventListener).mock.calls.find(
([event]) => event === 'my_event'
)
return call?.[1] as (e: CustomEvent<MyEventType>) => void
}

function dispatch(data: MyEventType) {
const handler = getEventHandler()
handler(new CustomEvent('my_event', { detail: data }))
}

it('handles events', () => {
const store = useMyStore()
dispatch({ field: 'value' })
expect(store.items).toHaveLength(1)
})
```

## Testing with Fake Timers

For stores with intervals, timeouts, or polling:

```typescript
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

it('polls after delay', async () => {
const store = useMyStore()
store.startPolling()

await vi.advanceTimersByTimeAsync(30000)

expect(mockService.fetch).toHaveBeenCalled()
})
```

## Assertion Style

Prefer `.toHaveLength()` over `.length.toBe()`:

```typescript
// Good
expect(store.items).toHaveLength(1)

// Avoid
expect(store.items.length).toBe(1)
```

Use `.toMatchObject()` for partial matching:

```typescript
expect(store.completedItems[0]).toMatchObject({
id: 'task-123',
status: 'done'
})
```
1 change: 1 addition & 0 deletions src/components/honeyToast/HoneyToast.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
bytesDownloaded: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
...overrides
}
}
Expand Down
1 change: 1 addition & 0 deletions src/components/toast/ProgressToastItem.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
bytesDownloaded: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
...overrides
}
}
Expand Down
70 changes: 70 additions & 0 deletions src/platform/tasks/services/taskService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Task Service for polling background task status.
*
* CAVEAT: The `payload` and `result` schemas below are specific to
* `task:download_file` tasks. Other task types may have different
* payload/result structures. We are not generalizing this until
* additional use cases arise.
*/
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'

import { api } from '@/scripts/api'

const TASKS_ENDPOINT = '/tasks'

const zTaskStatus = z.enum(['created', 'running', 'completed', 'failed'])

const zDownloadFileResult = z.object({
success: z.boolean(),
file_path: z.string().optional(),
bytes_downloaded: z.number().optional(),
content_type: z.string().optional(),
hash: z.string().optional(),
filename: z.string().optional(),
asset_id: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
error: z.string().optional()
})

const zTaskResponse = z.object({
id: z.string().uuid(),
idempotency_key: z.string(),
task_name: z.string(),
payload: z.record(z.unknown()),
status: zTaskStatus,
result: zDownloadFileResult.optional(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you could make the a generic and inject the zDownloadFileResult in the getTask function to future proof this a bit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what the caveat at the top is for.
I don't like to make single use things generic. Reusable things, those should be generic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

YAGNI

error_message: z.string().optional(),
create_time: z.string().datetime(),
update_time: z.string().datetime(),
started_at: z.string().datetime().optional(),
completed_at: z.string().datetime().optional()
})

export type TaskResponse = z.infer<typeof zTaskResponse>

function createTaskService() {
async function getTask(taskId: string): Promise<TaskResponse> {
const res = await api.fetchApi(`${TASKS_ENDPOINT}/${taskId}`)

if (!res.ok) {
if (res.status === 404) {
throw new Error(`Task not found: ${taskId}`)
}
throw new Error(`Failed to get task ${taskId}: ${res.status}`)
}

const data = await res.json()
const result = zTaskResponse.safeParse(data)

if (!result.success) {
throw new Error(fromZodError(result.error).message)
}

return result.data
}

return { getTask }
}

export const taskService = createTaskService()
2 changes: 1 addition & 1 deletion src/schemas/apiSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,12 @@ const zFeatureFlagsWsMessage = z.record(z.string(), z.any())

const zAssetDownloadWsMessage = z.object({
task_id: z.string(),
asset_id: z.string(),
asset_name: z.string(),
bytes_total: z.number(),
bytes_downloaded: z.number(),
progress: z.number(),
status: z.enum(['created', 'running', 'completed', 'failed']),
asset_id: z.string().optional(),
error: z.string().optional()
})

Expand Down
Loading
Loading