From ac64a902c157bccf234df5f93d09729026b46816 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:50:11 +0100 Subject: [PATCH] chore: adds tests (#42653) --- .agents/skills/vitest/GENERATION.md | 5 + .agents/skills/vitest/SKILL.md | 52 +++ .../references/advanced-environments.md | 264 +++++++++++++++ .../vitest/references/advanced-projects.md | 300 ++++++++++++++++++ .../references/advanced-type-testing.md | 237 ++++++++++++++ .../skills/vitest/references/advanced-vi.md | 249 +++++++++++++++ .agents/skills/vitest/references/core-cli.md | 166 ++++++++++ .../skills/vitest/references/core-config.md | 174 ++++++++++ .../skills/vitest/references/core-describe.md | 193 +++++++++++ .../skills/vitest/references/core-expect.md | 219 +++++++++++++ .../skills/vitest/references/core-hooks.md | 244 ++++++++++++++ .../skills/vitest/references/core-test-api.md | 233 ++++++++++++++ .../vitest/references/features-concurrency.md | 250 +++++++++++++++ .../vitest/references/features-context.md | 238 ++++++++++++++ .../vitest/references/features-coverage.md | 207 ++++++++++++ .../vitest/references/features-filtering.md | 211 ++++++++++++ .../vitest/references/features-mocking.md | 265 ++++++++++++++++ .../vitest/references/features-snapshots.md | 207 ++++++++++++ .claude/skills/vitest | 1 + .cursor/skills/vitest | 1 + .../__tests__/SupportFormPage.test.tsx | 17 +- apps/studio/lib/ai/message-utils.test.ts | 153 +++++++++ apps/studio/lib/ai/model.utils.test.ts | 88 +++++ apps/studio/lib/ai/org-ai-details.test.ts | 243 ++++++++++++++ .../lib/ai/tools/incident-tools.test.ts | 220 +++++++++++++ .../lib/ai/tools/rendering-tools.test.ts | 141 ++++++++ .../lib/api/self-hosted/constants.test.ts | 105 ++++++ apps/studio/lib/api/self-hosted/constants.ts | 2 +- .../api/self-hosted/functions/index.test.ts | 75 +++++ .../api/self-hosted/generate-types.test.ts | 142 +++++++++ .../lib/api/self-hosted/settings.test.ts | 129 ++++++++ apps/studio/lib/api/self-hosted/types.test.ts | 110 +++++++ apps/studio/lib/api/self-hosted/util.test.ts | 124 ++++++++ apps/studio/lib/breadcrumbs.test.ts | 105 ++++++ apps/studio/lib/constants/api.test.ts | 98 ++++++ apps/studio/lib/error-reporting.test.ts | 173 ++++++++++ apps/studio/lib/navigation.test.ts | 231 ++++++++++++++ .../lib/project-supabase-client.test.ts | 124 ++++++++ apps/studio/lib/void.test.ts | 31 ++ apps/studio/vitest.config.ts | 11 +- 40 files changed, 6030 insertions(+), 8 deletions(-) create mode 100644 .agents/skills/vitest/GENERATION.md create mode 100644 .agents/skills/vitest/SKILL.md create mode 100644 .agents/skills/vitest/references/advanced-environments.md create mode 100644 .agents/skills/vitest/references/advanced-projects.md create mode 100644 .agents/skills/vitest/references/advanced-type-testing.md create mode 100644 .agents/skills/vitest/references/advanced-vi.md create mode 100644 .agents/skills/vitest/references/core-cli.md create mode 100644 .agents/skills/vitest/references/core-config.md create mode 100644 .agents/skills/vitest/references/core-describe.md create mode 100644 .agents/skills/vitest/references/core-expect.md create mode 100644 .agents/skills/vitest/references/core-hooks.md create mode 100644 .agents/skills/vitest/references/core-test-api.md create mode 100644 .agents/skills/vitest/references/features-concurrency.md create mode 100644 .agents/skills/vitest/references/features-context.md create mode 100644 .agents/skills/vitest/references/features-coverage.md create mode 100644 .agents/skills/vitest/references/features-filtering.md create mode 100644 .agents/skills/vitest/references/features-mocking.md create mode 100644 .agents/skills/vitest/references/features-snapshots.md create mode 120000 .claude/skills/vitest create mode 120000 .cursor/skills/vitest create mode 100644 apps/studio/lib/ai/message-utils.test.ts create mode 100644 apps/studio/lib/ai/model.utils.test.ts create mode 100644 apps/studio/lib/ai/org-ai-details.test.ts create mode 100644 apps/studio/lib/ai/tools/incident-tools.test.ts create mode 100644 apps/studio/lib/ai/tools/rendering-tools.test.ts create mode 100644 apps/studio/lib/api/self-hosted/constants.test.ts create mode 100644 apps/studio/lib/api/self-hosted/functions/index.test.ts create mode 100644 apps/studio/lib/api/self-hosted/generate-types.test.ts create mode 100644 apps/studio/lib/api/self-hosted/settings.test.ts create mode 100644 apps/studio/lib/api/self-hosted/types.test.ts create mode 100644 apps/studio/lib/api/self-hosted/util.test.ts create mode 100644 apps/studio/lib/breadcrumbs.test.ts create mode 100644 apps/studio/lib/constants/api.test.ts create mode 100644 apps/studio/lib/error-reporting.test.ts create mode 100644 apps/studio/lib/navigation.test.ts create mode 100644 apps/studio/lib/project-supabase-client.test.ts create mode 100644 apps/studio/lib/void.test.ts diff --git a/.agents/skills/vitest/GENERATION.md b/.agents/skills/vitest/GENERATION.md new file mode 100644 index 0000000000..9bc76640e4 --- /dev/null +++ b/.agents/skills/vitest/GENERATION.md @@ -0,0 +1,5 @@ +# Generation Info + +- **Source:** `sources/vitest` +- **Git SHA:** `4a7321e10672f00f0bb698823a381c2cc245b8f7` +- **Generated:** 2026-01-28 diff --git a/.agents/skills/vitest/SKILL.md b/.agents/skills/vitest/SKILL.md new file mode 100644 index 0000000000..0578bdcf3a --- /dev/null +++ b/.agents/skills/vitest/SKILL.md @@ -0,0 +1,52 @@ +--- +name: vitest +description: Vitest fast unit testing framework powered by Vite with Jest-compatible API. Use when writing tests, mocking, configuring coverage, or working with test filtering and fixtures. +metadata: + author: Anthony Fu + version: "2026.1.28" + source: Generated from https://github.com/vitest-dev/vitest, scripts located at https://github.com/antfu/skills +--- + +Vitest is a next-generation testing framework powered by Vite. It provides a Jest-compatible API with native ESM, TypeScript, and JSX support out of the box. Vitest shares the same config, transformers, resolvers, and plugins with your Vite app. + +**Key Features:** +- Vite-native: Uses Vite's transformation pipeline for fast HMR-like test updates +- Jest-compatible: Drop-in replacement for most Jest test suites +- Smart watch mode: Only reruns affected tests based on module graph +- Native ESM, TypeScript, JSX support without configuration +- Multi-threaded workers for parallel test execution +- Built-in coverage via V8 or Istanbul +- Snapshot testing, mocking, and spy utilities + +> The skill is based on Vitest 3.x, generated at 2026-01-28. + +## Core + +| Topic | Description | Reference | +|-------|-------------|-----------| +| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) | +| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) | +| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) | +| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) | +| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) | +| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) | + +## Features + +| Topic | Description | Reference | +|-------|-------------|-----------| +| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) | +| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) | +| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) | +| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) | +| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) | +| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) | + +## Advanced + +| Topic | Description | Reference | +|-------|-------------|-----------| +| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) | +| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) | +| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) | +| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) | diff --git a/.agents/skills/vitest/references/advanced-environments.md b/.agents/skills/vitest/references/advanced-environments.md new file mode 100644 index 0000000000..25a1d5b078 --- /dev/null +++ b/.agents/skills/vitest/references/advanced-environments.md @@ -0,0 +1,264 @@ +--- +name: test-environments +description: Configure environments like jsdom, happy-dom for browser APIs +--- + +# Test Environments + +## Available Environments + +- `node` (default) - Node.js environment +- `jsdom` - Browser-like with DOM APIs +- `happy-dom` - Faster alternative to jsdom +- `edge-runtime` - Vercel Edge Runtime + +## Configuration + +```ts +// vitest.config.ts +defineConfig({ + test: { + environment: 'jsdom', + + // Environment-specific options + environmentOptions: { + jsdom: { + url: 'http://localhost', + }, + }, + }, +}) +``` + +## Installing Environment Packages + +```bash +# jsdom +npm i -D jsdom + +# happy-dom (faster, fewer APIs) +npm i -D happy-dom +``` + +## Per-File Environment + +Use magic comment at top of file: + +```ts +// @vitest-environment jsdom + +import { expect, test } from 'vitest' + +test('DOM test', () => { + const div = document.createElement('div') + expect(div).toBeInstanceOf(HTMLDivElement) +}) +``` + +## jsdom Environment + +Full browser environment simulation: + +```ts +// @vitest-environment jsdom + +test('DOM manipulation', () => { + document.body.innerHTML = '
' + + const app = document.getElementById('app') + app.textContent = 'Hello' + + expect(app.textContent).toBe('Hello') +}) + +test('window APIs', () => { + expect(window.location.href).toBeDefined() + expect(localStorage).toBeDefined() +}) +``` + +### jsdom Options + +```ts +defineConfig({ + test: { + environmentOptions: { + jsdom: { + url: 'http://localhost:3000', + html: '', + userAgent: 'custom-agent', + resources: 'usable', + }, + }, + }, +}) +``` + +## happy-dom Environment + +Faster but fewer APIs: + +```ts +// @vitest-environment happy-dom + +test('basic DOM', () => { + const el = document.createElement('div') + el.className = 'test' + expect(el.className).toBe('test') +}) +``` + +## Multiple Environments per Project + +Use projects for different environments: + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + include: ['tests/unit/**/*.test.ts'], + environment: 'node', + }, + }, + { + test: { + name: 'dom', + include: ['tests/dom/**/*.test.ts'], + environment: 'jsdom', + }, + }, + ], + }, +}) +``` + +## Custom Environment + +Create custom environment package: + +```ts +// vitest-environment-custom/index.ts +import type { Environment } from 'vitest/runtime' + +export default { + name: 'custom', + viteEnvironment: 'ssr', // or 'client' + + setup() { + // Setup global state + globalThis.myGlobal = 'value' + + return { + teardown() { + delete globalThis.myGlobal + }, + } + }, +} +``` + +Use with: + +```ts +defineConfig({ + test: { + environment: 'custom', + }, +}) +``` + +## Environment with VM + +For full isolation: + +```ts +export default { + name: 'isolated', + viteEnvironment: 'ssr', + + async setupVM() { + const vm = await import('node:vm') + const context = vm.createContext() + + return { + getVmContext() { + return context + }, + teardown() {}, + } + }, + + setup() { + return { teardown() {} } + }, +} +``` + +## Browser Mode (Separate from Environments) + +For real browser testing, use Vitest Browser Mode: + +```ts +defineConfig({ + test: { + browser: { + enabled: true, + name: 'chromium', // or 'firefox', 'webkit' + provider: 'playwright', + }, + }, +}) +``` + +## CSS and Assets + +In jsdom/happy-dom, configure CSS handling: + +```ts +defineConfig({ + test: { + css: true, // Process CSS + + // Or with options + css: { + include: /\.module\.css$/, + modules: { + classNameStrategy: 'non-scoped', + }, + }, + }, +}) +``` + +## Fixing External Dependencies + +If external deps fail with CSS/asset errors: + +```ts +defineConfig({ + test: { + server: { + deps: { + inline: ['problematic-package'], + }, + }, + }, +}) +``` + +## Key Points + +- Default is `node` - no browser APIs +- Use `jsdom` for full browser simulation +- Use `happy-dom` for faster tests with basic DOM +- Per-file environment via `// @vitest-environment` comment +- Use projects for multiple environment configurations +- Browser Mode is for real browser testing, not environment + + diff --git a/.agents/skills/vitest/references/advanced-projects.md b/.agents/skills/vitest/references/advanced-projects.md new file mode 100644 index 0000000000..57b9a73564 --- /dev/null +++ b/.agents/skills/vitest/references/advanced-projects.md @@ -0,0 +1,300 @@ +--- +name: projects-workspaces +description: Multi-project configuration for monorepos and different test types +--- + +# Projects + +Run different test configurations in the same Vitest process. + +## Basic Projects Setup + +```ts +// vitest.config.ts +defineConfig({ + test: { + projects: [ + // Glob patterns for config files + 'packages/*', + + // Inline config + { + test: { + name: 'unit', + include: ['tests/unit/**/*.test.ts'], + environment: 'node', + }, + }, + { + test: { + name: 'integration', + include: ['tests/integration/**/*.test.ts'], + environment: 'jsdom', + }, + }, + ], + }, +}) +``` + +## Monorepo Pattern + +```ts +defineConfig({ + test: { + projects: [ + // Each package has its own vitest.config.ts + 'packages/core', + 'packages/cli', + 'packages/utils', + ], + }, +}) +``` + +Package config: + +```ts +// packages/core/vitest.config.ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'core', + include: ['src/**/*.test.ts'], + environment: 'node', + }, +}) +``` + +## Different Environments + +Run same tests in different environments: + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: 'happy-dom', + root: './shared-tests', + environment: 'happy-dom', + setupFiles: ['./setup.happy-dom.ts'], + }, + }, + { + test: { + name: 'node', + root: './shared-tests', + environment: 'node', + setupFiles: ['./setup.node.ts'], + }, + }, + ], + }, +}) +``` + +## Browser + Node Projects + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + include: ['tests/unit/**/*.test.ts'], + environment: 'node', + }, + }, + { + test: { + name: 'browser', + include: ['tests/browser/**/*.test.ts'], + browser: { + enabled: true, + name: 'chromium', + provider: 'playwright', + }, + }, + }, + ], + }, +}) +``` + +## Shared Configuration + +```ts +// vitest.shared.ts +export const sharedConfig = { + testTimeout: 10000, + setupFiles: ['./tests/setup.ts'], +} + +// vitest.config.ts +import { sharedConfig } from './vitest.shared' + +defineConfig({ + test: { + projects: [ + { + test: { + ...sharedConfig, + name: 'unit', + include: ['tests/unit/**/*.test.ts'], + }, + }, + { + test: { + ...sharedConfig, + name: 'e2e', + include: ['tests/e2e/**/*.test.ts'], + }, + }, + ], + }, +}) +``` + +## Project-Specific Dependencies + +Each project can have different dependencies inlined: + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: 'project-a', + server: { + deps: { + inline: ['package-a'], + }, + }, + }, + }, + ], + }, +}) +``` + +## Running Specific Projects + +```bash +# Run specific project +vitest --project unit +vitest --project integration + +# Multiple projects +vitest --project unit --project e2e + +# Exclude project +vitest --project.ignore browser +``` + +## Providing Values to Projects + +Share values from config to tests: + +```ts +// vitest.config.ts +defineConfig({ + test: { + projects: [ + { + test: { + name: 'staging', + provide: { + apiUrl: 'https://staging.api.com', + debug: true, + }, + }, + }, + { + test: { + name: 'production', + provide: { + apiUrl: 'https://api.com', + debug: false, + }, + }, + }, + ], + }, +}) + +// In tests, use inject +import { inject } from 'vitest' + +test('uses correct api', () => { + const url = inject('apiUrl') + expect(url).toContain('api.com') +}) +``` + +## With Fixtures + +```ts +const test = base.extend({ + apiUrl: ['/default', { injected: true }], +}) + +test('uses injected url', ({ apiUrl }) => { + // apiUrl comes from project's provide config +}) +``` + +## Project Isolation + +Each project runs in its own thread pool by default: + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: 'isolated', + isolate: true, // Full isolation + pool: 'forks', + }, + }, + ], + }, +}) +``` + +## Global Setup per Project + +```ts +defineConfig({ + test: { + projects: [ + { + test: { + name: 'with-db', + globalSetup: ['./tests/db-setup.ts'], + }, + }, + ], + }, +}) +``` + +## Key Points + +- Projects run in same Vitest process +- Each project can have different environment, config +- Use glob patterns for monorepo packages +- Run specific projects with `--project` flag +- Use `provide` to inject config values into tests +- Projects inherit from root config unless overridden + + diff --git a/.agents/skills/vitest/references/advanced-type-testing.md b/.agents/skills/vitest/references/advanced-type-testing.md new file mode 100644 index 0000000000..f67a034e30 --- /dev/null +++ b/.agents/skills/vitest/references/advanced-type-testing.md @@ -0,0 +1,237 @@ +--- +name: type-testing +description: Test TypeScript types with expectTypeOf and assertType +--- + +# Type Testing + +Test TypeScript types without runtime execution. + +## Setup + +Type tests use `.test-d.ts` extension: + +```ts +// math.test-d.ts +import { expectTypeOf } from 'vitest' +import { add } from './math' + +test('add returns number', () => { + expectTypeOf(add).returns.toBeNumber() +}) +``` + +## Configuration + +```ts +defineConfig({ + test: { + typecheck: { + enabled: true, + + // Only type check + only: false, + + // Checker: 'tsc' or 'vue-tsc' + checker: 'tsc', + + // Include patterns + include: ['**/*.test-d.ts'], + + // tsconfig to use + tsconfig: './tsconfig.json', + }, + }, +}) +``` + +## expectTypeOf API + +```ts +import { expectTypeOf } from 'vitest' + +// Basic type checks +expectTypeOf().toBeString() +expectTypeOf().toBeNumber() +expectTypeOf().toBeBoolean() +expectTypeOf().toBeNull() +expectTypeOf().toBeUndefined() +expectTypeOf().toBeVoid() +expectTypeOf().toBeNever() +expectTypeOf().toBeAny() +expectTypeOf().toBeUnknown() +expectTypeOf().toBeObject() +expectTypeOf().toBeFunction() +expectTypeOf<[]>().toBeArray() +expectTypeOf().toBeSymbol() +``` + +## Value Type Checking + +```ts +const value = 'hello' +expectTypeOf(value).toBeString() + +const obj = { name: 'test', count: 42 } +expectTypeOf(obj).toMatchTypeOf<{ name: string }>() +expectTypeOf(obj).toHaveProperty('name') +``` + +## Function Types + +```ts +function greet(name: string): string { + return `Hello, ${name}` +} + +expectTypeOf(greet).toBeFunction() +expectTypeOf(greet).parameters.toEqualTypeOf<[string]>() +expectTypeOf(greet).returns.toBeString() + +// Parameter checking +expectTypeOf(greet).parameter(0).toBeString() +``` + +## Object Types + +```ts +interface User { + id: number + name: string + email?: string +} + +expectTypeOf().toHaveProperty('id') +expectTypeOf().toHaveProperty('name').toBeString() + +// Check shape +expectTypeOf({ id: 1, name: 'test' }).toMatchTypeOf() +``` + +## Equality vs Matching + +```ts +interface A { x: number } +interface B { x: number; y: string } + +// toMatchTypeOf - subset matching +expectTypeOf().toMatchTypeOf() // B extends A + +// toEqualTypeOf - exact match +expectTypeOf().not.toEqualTypeOf() // Not exact match +expectTypeOf().toEqualTypeOf<{ x: number }>() // Exact match +``` + +## Branded Types + +```ts +type UserId = number & { __brand: 'UserId' } +type PostId = number & { __brand: 'PostId' } + +expectTypeOf().not.toEqualTypeOf() +expectTypeOf().not.toEqualTypeOf() +``` + +## Generic Types + +```ts +function identity(value: T): T { + return value +} + +expectTypeOf(identity).returns.toBeString() +expectTypeOf(identity).returns.toBeNumber() +``` + +## Nullable Types + +```ts +type MaybeString = string | null | undefined + +expectTypeOf().toBeNullable() +expectTypeOf().not.toBeNullable() +``` + +## assertType + +Assert a value matches a type (no assertion at runtime): + +```ts +import { assertType } from 'vitest' + +function getUser(): User | null { + return { id: 1, name: 'test' } +} + +test('returns user', () => { + const result = getUser() + + // @ts-expect-error - should fail type check + assertType(result) + + // Correct type + assertType(result) +}) +``` + +## Using @ts-expect-error + +Test that code produces type error: + +```ts +test('rejects wrong types', () => { + function requireString(s: string) {} + + // @ts-expect-error - number not assignable to string + requireString(123) +}) +``` + +## Running Type Tests + +```bash +# Run type tests +vitest typecheck + +# Run alongside unit tests +vitest --typecheck + +# Type tests only +vitest --typecheck.only +``` + +## Mixed Test Files + +Combine runtime and type tests: + +```ts +// user.test.ts +import { describe, expect, expectTypeOf, test } from 'vitest' +import { createUser } from './user' + +describe('createUser', () => { + test('runtime: creates user', () => { + const user = createUser('John') + expect(user.name).toBe('John') + }) + + test('types: returns User type', () => { + expectTypeOf(createUser).returns.toMatchTypeOf<{ name: string }>() + }) +}) +``` + +## Key Points + +- Use `.test-d.ts` for type-only tests +- `expectTypeOf` for type assertions +- `toMatchTypeOf` for subset matching +- `toEqualTypeOf` for exact type matching +- Use `@ts-expect-error` to test type errors +- Run with `vitest typecheck` or `--typecheck` + + diff --git a/.agents/skills/vitest/references/advanced-vi.md b/.agents/skills/vitest/references/advanced-vi.md new file mode 100644 index 0000000000..57a4784252 --- /dev/null +++ b/.agents/skills/vitest/references/advanced-vi.md @@ -0,0 +1,249 @@ +--- +name: vi-utilities +description: vi helper for mocking, timers, utilities +--- + +# Vi Utilities + +The `vi` helper provides mocking and utility functions. + +```ts +import { vi } from 'vitest' +``` + +## Mock Functions + +```ts +// Create mock +const fn = vi.fn() +const fnWithImpl = vi.fn((x) => x * 2) + +// Check if mock +vi.isMockFunction(fn) // true + +// Mock methods +fn.mockReturnValue(42) +fn.mockReturnValueOnce(1) +fn.mockResolvedValue(data) +fn.mockRejectedValue(error) +fn.mockImplementation(() => 'result') +fn.mockImplementationOnce(() => 'once') + +// Clear/reset +fn.mockClear() // Clear call history +fn.mockReset() // Clear history + implementation +fn.mockRestore() // Restore original (for spies) +``` + +## Spying + +```ts +const obj = { method: () => 'original' } + +const spy = vi.spyOn(obj, 'method') +obj.method() + +expect(spy).toHaveBeenCalled() + +// Mock implementation +spy.mockReturnValue('mocked') + +// Spy on getter/setter +vi.spyOn(obj, 'prop', 'get').mockReturnValue('value') +``` + +## Module Mocking + +```ts +// Hoisted to top of file +vi.mock('./module', () => ({ + fn: vi.fn(), +})) + +// Partial mock +vi.mock('./module', async (importOriginal) => ({ + ...(await importOriginal()), + specificFn: vi.fn(), +})) + +// Spy mode - keep implementation +vi.mock('./module', { spy: true }) + +// Import actual module inside mock +const actual = await vi.importActual('./module') + +// Import as mock +const mocked = await vi.importMock('./module') +``` + +## Dynamic Mocking + +```ts +// Not hoisted - use with dynamic imports +vi.doMock('./config', () => ({ key: 'value' })) +const config = await import('./config') + +// Unmock +vi.doUnmock('./config') +vi.unmock('./module') // Hoisted +``` + +## Reset Modules + +```ts +// Clear module cache +vi.resetModules() + +// Wait for dynamic imports +await vi.dynamicImportSettled() +``` + +## Fake Timers + +```ts +vi.useFakeTimers() + +setTimeout(() => console.log('done'), 1000) + +// Advance time +vi.advanceTimersByTime(1000) +vi.advanceTimersByTimeAsync(1000) // For async callbacks +vi.advanceTimersToNextTimer() +vi.advanceTimersToNextFrame() // requestAnimationFrame + +// Run all timers +vi.runAllTimers() +vi.runAllTimersAsync() +vi.runOnlyPendingTimers() + +// Clear timers +vi.clearAllTimers() + +// Check state +vi.getTimerCount() +vi.isFakeTimers() + +// Restore +vi.useRealTimers() +``` + +## Mock Date/Time + +```ts +vi.setSystemTime(new Date('2024-01-01')) +expect(new Date().getFullYear()).toBe(2024) + +vi.getMockedSystemTime() // Get mocked date +vi.getRealSystemTime() // Get real time (ms) +``` + +## Global/Env Mocking + +```ts +// Stub global +vi.stubGlobal('fetch', vi.fn()) +vi.unstubAllGlobals() + +// Stub environment +vi.stubEnv('API_KEY', 'test') +vi.stubEnv('NODE_ENV', 'test') +vi.unstubAllEnvs() +``` + +## Hoisted Code + +Run code before imports: + +```ts +const mock = vi.hoisted(() => vi.fn()) + +vi.mock('./module', () => ({ + fn: mock, // Can reference hoisted variable +})) +``` + +## Waiting Utilities + +```ts +// Wait for callback to succeed +await vi.waitFor(async () => { + const el = document.querySelector('.loaded') + expect(el).toBeTruthy() +}, { timeout: 5000, interval: 100 }) + +// Wait for truthy value +const element = await vi.waitUntil( + () => document.querySelector('.loaded'), + { timeout: 5000 } +) +``` + +## Mock Object + +Mock all methods of an object: + +```ts +const original = { + method: () => 'real', + nested: { fn: () => 'nested' }, +} + +const mocked = vi.mockObject(original) +mocked.method() // undefined (mocked) +mocked.method.mockReturnValue('mocked') + +// Spy mode +const spied = vi.mockObject(original, { spy: true }) +spied.method() // 'real' +expect(spied.method).toHaveBeenCalled() +``` + +## Test Configuration + +```ts +vi.setConfig({ + testTimeout: 10_000, + hookTimeout: 10_000, +}) + +vi.resetConfig() +``` + +## Global Mock Management + +```ts +vi.clearAllMocks() // Clear all mock call history +vi.resetAllMocks() // Reset + clear implementation +vi.restoreAllMocks() // Restore originals (spies) +``` + +## vi.mocked Type Helper + +TypeScript helper for mocked values: + +```ts +import { myFn } from './module' +vi.mock('./module') + +// Type as mock +vi.mocked(myFn).mockReturnValue('typed') + +// Deep mocking +vi.mocked(myModule, { deep: true }) + +// Partial mock typing +vi.mocked(fn, { partial: true }).mockResolvedValue({ ok: true }) +``` + +## Key Points + +- `vi.mock` is hoisted - use `vi.doMock` for dynamic mocking +- `vi.hoisted` lets you reference variables in mock factories +- Use `vi.spyOn` to spy on existing methods +- Fake timers require explicit setup and teardown +- `vi.waitFor` retries until assertion passes + + diff --git a/.agents/skills/vitest/references/core-cli.md b/.agents/skills/vitest/references/core-cli.md new file mode 100644 index 0000000000..7a05c049be --- /dev/null +++ b/.agents/skills/vitest/references/core-cli.md @@ -0,0 +1,166 @@ +--- +name: vitest-cli +description: Command line interface commands and options +--- + +# Command Line Interface + +## Commands + +### `vitest` + +Start Vitest in watch mode (dev) or run mode (CI): + +```bash +vitest # Watch mode in dev, run mode in CI +vitest foobar # Run tests containing "foobar" in path +vitest basic/foo.test.ts:10 # Run specific test by file and line number +``` + +### `vitest run` + +Run tests once without watch mode: + +```bash +vitest run +vitest run --coverage +``` + +### `vitest watch` + +Explicitly start watch mode: + +```bash +vitest watch +``` + +### `vitest related` + +Run tests that import specific files (useful with lint-staged): + +```bash +vitest related src/index.ts src/utils.ts --run +``` + +### `vitest bench` + +Run only benchmark tests: + +```bash +vitest bench +``` + +### `vitest list` + +List all matching tests without running them: + +```bash +vitest list # List test names +vitest list --json # Output as JSON +vitest list --filesOnly # List only test files +``` + +### `vitest init` + +Initialize project setup: + +```bash +vitest init browser # Set up browser testing +``` + +## Common Options + +```bash +# Configuration +--config # Path to config file +--project # Run specific project + +# Filtering +--testNamePattern, -t # Run tests matching pattern +--changed # Run tests for changed files +--changed HEAD~1 # Tests for last commit changes + +# Reporters +--reporter # default, verbose, dot, json, html +--reporter=html --outputFile=report.html + +# Coverage +--coverage # Enable coverage +--coverage.provider v8 # Use v8 provider +--coverage.reporter text,html + +# Execution +--shard / # Split tests across machines +--bail # Stop after n failures +--retry # Retry failed tests n times +--sequence.shuffle # Randomize test order + +# Watch mode +--no-watch # Disable watch mode +--standalone # Start without running tests + +# Environment +--environment # jsdom, happy-dom, node +--globals # Enable global APIs + +# Debugging +--inspect # Enable Node inspector +--inspect-brk # Break on start + +# Output +--silent # Suppress console output +--no-color # Disable colors +``` + +## Package.json Scripts + +```json +{ + "scripts": { + "test": "vitest", + "test:run": "vitest run", + "test:ui": "vitest --ui", + "coverage": "vitest run --coverage" + } +} +``` + +## Sharding for CI + +Split tests across multiple machines: + +```bash +# Machine 1 +vitest run --shard=1/3 --reporter=blob + +# Machine 2 +vitest run --shard=2/3 --reporter=blob + +# Machine 3 +vitest run --shard=3/3 --reporter=blob + +# Merge reports +vitest --merge-reports --reporter=junit +``` + +## Watch Mode Keyboard Shortcuts + +In watch mode, press: +- `a` - Run all tests +- `f` - Run only failed tests +- `u` - Update snapshots +- `p` - Filter by filename pattern +- `t` - Filter by test name pattern +- `q` - Quit + +## Key Points + +- Watch mode is default in dev, run mode in CI (when `process.env.CI` is set) +- Use `--run` flag to ensure single run (important for lint-staged) +- Both camelCase (`--testTimeout`) and kebab-case (`--test-timeout`) work +- Boolean options can be negated with `--no-` prefix + + diff --git a/.agents/skills/vitest/references/core-config.md b/.agents/skills/vitest/references/core-config.md new file mode 100644 index 0000000000..76002a58fb --- /dev/null +++ b/.agents/skills/vitest/references/core-config.md @@ -0,0 +1,174 @@ +--- +name: vitest-configuration +description: Configure Vitest with vite.config.ts or vitest.config.ts +--- + +# Configuration + +Vitest reads configuration from `vitest.config.ts` or `vite.config.ts`. It shares the same config format as Vite. + +## Basic Setup + +```ts +// vitest.config.ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // test options + }, +}) +``` + +## Using with Existing Vite Config + +Add Vitest types reference and use the `test` property: + +```ts +// vite.config.ts +/// +import { defineConfig } from 'vite' + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + }, +}) +``` + +## Merging Configs + +If you have separate config files, use `mergeConfig`: + +```ts +// vitest.config.ts +import { defineConfig, mergeConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig(viteConfig, defineConfig({ + test: { + environment: 'jsdom', + }, +})) +``` + +## Common Options + +```ts +defineConfig({ + test: { + // Enable global APIs (describe, it, expect) without imports + globals: true, + + // Test environment: 'node', 'jsdom', 'happy-dom' + environment: 'node', + + // Setup files to run before each test file + setupFiles: ['./tests/setup.ts'], + + // Include patterns for test files + include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'], + + // Exclude patterns + exclude: ['**/node_modules/**', '**/dist/**'], + + // Test timeout in ms + testTimeout: 5000, + + // Hook timeout in ms + hookTimeout: 10000, + + // Enable watch mode by default + watch: true, + + // Coverage configuration + coverage: { + provider: 'v8', // or 'istanbul' + reporter: ['text', 'html'], + include: ['src/**/*.ts'], + }, + + // Run tests in isolation (each file in separate process) + isolate: true, + + // Pool for running tests: 'threads', 'forks', 'vmThreads' + pool: 'threads', + + // Number of threads/processes + poolOptions: { + threads: { + maxThreads: 4, + minThreads: 1, + }, + }, + + // Automatically clear mocks between tests + clearMocks: true, + + // Restore mocks between tests + restoreMocks: true, + + // Retry failed tests + retry: 0, + + // Stop after first failure + bail: 0, + }, +}) +``` + +## Conditional Configuration + +Use `mode` or `process.env.VITEST` for test-specific config: + +```ts +export default defineConfig(({ mode }) => ({ + plugins: mode === 'test' ? [] : [myPlugin()], + test: { + // test options + }, +})) +``` + +## Projects (Monorepos) + +Run different configurations in the same Vitest process: + +```ts +defineConfig({ + test: { + projects: [ + 'packages/*', + { + test: { + name: 'unit', + include: ['tests/unit/**/*.test.ts'], + environment: 'node', + }, + }, + { + test: { + name: 'integration', + include: ['tests/integration/**/*.test.ts'], + environment: 'jsdom', + }, + }, + ], + }, +}) +``` + +## Key Points + +- Vitest uses Vite's transformation pipeline - same `resolve.alias`, plugins work +- `vitest.config.ts` takes priority over `vite.config.ts` +- Use `--config` flag to specify a custom config path +- `process.env.VITEST` is set to `true` when running tests +- Test config uses `test` property, rest is Vite config + + diff --git a/.agents/skills/vitest/references/core-describe.md b/.agents/skills/vitest/references/core-describe.md new file mode 100644 index 0000000000..3f7f3fe1cb --- /dev/null +++ b/.agents/skills/vitest/references/core-describe.md @@ -0,0 +1,193 @@ +--- +name: describe-api +description: describe/suite for grouping tests into logical blocks +--- + +# Describe API + +Group related tests into suites for organization and shared setup. + +## Basic Usage + +```ts +import { describe, expect, test } from 'vitest' + +describe('Math', () => { + test('adds numbers', () => { + expect(1 + 1).toBe(2) + }) + + test('subtracts numbers', () => { + expect(3 - 1).toBe(2) + }) +}) + +// Alias: suite +import { suite } from 'vitest' +suite('equivalent to describe', () => {}) +``` + +## Nested Suites + +```ts +describe('User', () => { + describe('when logged in', () => { + test('shows dashboard', () => {}) + test('can update profile', () => {}) + }) + + describe('when logged out', () => { + test('shows login page', () => {}) + }) +}) +``` + +## Suite Options + +```ts +// All tests inherit options +describe('slow tests', { timeout: 30_000 }, () => { + test('test 1', () => {}) // 30s timeout + test('test 2', () => {}) // 30s timeout +}) +``` + +## Suite Modifiers + +### Skip Suites + +```ts +describe.skip('skipped suite', () => { + test('wont run', () => {}) +}) + +// Conditional +describe.skipIf(process.env.CI)('not in CI', () => {}) +describe.runIf(!process.env.CI)('only local', () => {}) +``` + +### Focus Suites + +```ts +describe.only('only this suite runs', () => { + test('runs', () => {}) +}) +``` + +### Todo Suites + +```ts +describe.todo('implement later') +``` + +### Concurrent Suites + +```ts +// All tests run in parallel +describe.concurrent('parallel tests', () => { + test('test 1', async ({ expect }) => {}) + test('test 2', async ({ expect }) => {}) +}) +``` + +### Sequential in Concurrent + +```ts +describe.concurrent('parallel', () => { + test('concurrent 1', async () => {}) + + describe.sequential('must be sequential', () => { + test('step 1', async () => {}) + test('step 2', async () => {}) + }) +}) +``` + +### Shuffle Tests + +```ts +describe.shuffle('random order', () => { + test('test 1', () => {}) + test('test 2', () => {}) + test('test 3', () => {}) +}) + +// Or with option +describe('random', { shuffle: true }, () => {}) +``` + +## Parameterized Suites + +### describe.each + +```ts +describe.each([ + { name: 'Chrome', version: 100 }, + { name: 'Firefox', version: 90 }, +])('$name browser', ({ name, version }) => { + test('has version', () => { + expect(version).toBeGreaterThan(0) + }) +}) +``` + +### describe.for + +```ts +describe.for([ + ['Chrome', 100], + ['Firefox', 90], +])('%s browser', ([name, version]) => { + test('has version', () => { + expect(version).toBeGreaterThan(0) + }) +}) +``` + +## Hooks in Suites + +```ts +describe('Database', () => { + let db + + beforeAll(async () => { + db = await createDb() + }) + + afterAll(async () => { + await db.close() + }) + + beforeEach(async () => { + await db.clear() + }) + + test('insert works', async () => { + await db.insert({ name: 'test' }) + expect(await db.count()).toBe(1) + }) +}) +``` + +## Modifier Combinations + +All modifiers can be chained: + +```ts +describe.skip.concurrent('skipped concurrent', () => {}) +describe.only.shuffle('only and shuffled', () => {}) +describe.concurrent.skip('equivalent', () => {}) +``` + +## Key Points + +- Top-level tests belong to an implicit file suite +- Nested suites inherit parent's options (timeout, retry, etc.) +- Hooks are scoped to their suite and nested suites +- Use `describe.concurrent` with context's `expect` for snapshots +- Shuffle order depends on `sequence.seed` config + + diff --git a/.agents/skills/vitest/references/core-expect.md b/.agents/skills/vitest/references/core-expect.md new file mode 100644 index 0000000000..91de00a64e --- /dev/null +++ b/.agents/skills/vitest/references/core-expect.md @@ -0,0 +1,219 @@ +--- +name: expect-api +description: Assertions with matchers, asymmetric matchers, and custom matchers +--- + +# Expect API + +Vitest uses Chai assertions with Jest-compatible API. + +## Basic Assertions + +```ts +import { expect, test } from 'vitest' + +test('assertions', () => { + // Equality + expect(1 + 1).toBe(2) // Strict equality (===) + expect({ a: 1 }).toEqual({ a: 1 }) // Deep equality + + // Truthiness + expect(true).toBeTruthy() + expect(false).toBeFalsy() + expect(null).toBeNull() + expect(undefined).toBeUndefined() + expect('value').toBeDefined() + + // Numbers + expect(10).toBeGreaterThan(5) + expect(10).toBeGreaterThanOrEqual(10) + expect(5).toBeLessThan(10) + expect(0.1 + 0.2).toBeCloseTo(0.3, 5) + + // Strings + expect('hello world').toMatch(/world/) + expect('hello').toContain('ell') + + // Arrays + expect([1, 2, 3]).toContain(2) + expect([{ a: 1 }]).toContainEqual({ a: 1 }) + expect([1, 2, 3]).toHaveLength(3) + + // Objects + expect({ a: 1, b: 2 }).toHaveProperty('a') + expect({ a: 1, b: 2 }).toHaveProperty('a', 1) + expect({ a: { b: 1 } }).toHaveProperty('a.b', 1) + expect({ a: 1 }).toMatchObject({ a: 1 }) + + // Types + expect('string').toBeTypeOf('string') + expect(new Date()).toBeInstanceOf(Date) +}) +``` + +## Negation + +```ts +expect(1).not.toBe(2) +expect({ a: 1 }).not.toEqual({ a: 2 }) +``` + +## Error Assertions + +```ts +// Sync errors - wrap in function +expect(() => throwError()).toThrow() +expect(() => throwError()).toThrow('message') +expect(() => throwError()).toThrow(/pattern/) +expect(() => throwError()).toThrow(CustomError) + +// Async errors - use rejects +await expect(asyncThrow()).rejects.toThrow('error') +``` + +## Promise Assertions + +```ts +// Resolves +await expect(Promise.resolve(1)).resolves.toBe(1) +await expect(fetchData()).resolves.toEqual({ data: true }) + +// Rejects +await expect(Promise.reject('error')).rejects.toBe('error') +await expect(failingFetch()).rejects.toThrow() +``` + +## Spy/Mock Assertions + +```ts +const fn = vi.fn() +fn('arg1', 'arg2') +fn('arg3') + +expect(fn).toHaveBeenCalled() +expect(fn).toHaveBeenCalledTimes(2) +expect(fn).toHaveBeenCalledWith('arg1', 'arg2') +expect(fn).toHaveBeenLastCalledWith('arg3') +expect(fn).toHaveBeenNthCalledWith(1, 'arg1', 'arg2') + +expect(fn).toHaveReturned() +expect(fn).toHaveReturnedWith(value) +``` + +## Asymmetric Matchers + +Use inside `toEqual`, `toHaveBeenCalledWith`, etc: + +```ts +expect({ id: 1, name: 'test' }).toEqual({ + id: expect.any(Number), + name: expect.any(String), +}) + +expect({ a: 1, b: 2, c: 3 }).toEqual( + expect.objectContaining({ a: 1 }) +) + +expect([1, 2, 3, 4]).toEqual( + expect.arrayContaining([1, 3]) +) + +expect('hello world').toEqual( + expect.stringContaining('world') +) + +expect('hello world').toEqual( + expect.stringMatching(/world$/) +) + +expect({ value: null }).toEqual({ + value: expect.anything() // Matches anything except null/undefined +}) + +// Negate with expect.not +expect([1, 2]).toEqual( + expect.not.arrayContaining([3]) +) +``` + +## Soft Assertions + +Continue test after failure: + +```ts +expect.soft(1).toBe(2) // Marks test failed but continues +expect.soft(2).toBe(3) // Also runs +// All failures reported at end +``` + +## Poll Assertions + +Retry until passes: + +```ts +await expect.poll(() => fetchStatus()).toBe('ready') + +await expect.poll( + () => document.querySelector('.element'), + { interval: 100, timeout: 5000 } +).toBeTruthy() +``` + +## Assertion Count + +```ts +test('async assertions', async () => { + expect.assertions(2) // Exactly 2 assertions must run + + await doAsync((data) => { + expect(data).toBeDefined() + expect(data.id).toBe(1) + }) +}) + +test('at least one', () => { + expect.hasAssertions() // At least 1 assertion must run +}) +``` + +## Extending Matchers + +```ts +expect.extend({ + toBeWithinRange(received, floor, ceiling) { + const pass = received >= floor && received <= ceiling + return { + pass, + message: () => + `expected ${received} to be within range ${floor} - ${ceiling}`, + } + }, +}) + +test('custom matcher', () => { + expect(100).toBeWithinRange(90, 110) +}) +``` + +## Snapshot Assertions + +```ts +expect(data).toMatchSnapshot() +expect(data).toMatchInlineSnapshot(`{ "id": 1 }`) +await expect(result).toMatchFileSnapshot('./expected.json') + +expect(() => throw new Error('fail')).toThrowErrorMatchingSnapshot() +``` + +## Key Points + +- Use `toBe` for primitives, `toEqual` for objects/arrays +- `toStrictEqual` checks undefined properties and array sparseness +- Always `await` async assertions (`resolves`, `rejects`, `poll`) +- Use context's `expect` in concurrent tests for correct tracking +- `toThrow` requires wrapping sync code in a function + + diff --git a/.agents/skills/vitest/references/core-hooks.md b/.agents/skills/vitest/references/core-hooks.md new file mode 100644 index 0000000000..d0c2bfa098 --- /dev/null +++ b/.agents/skills/vitest/references/core-hooks.md @@ -0,0 +1,244 @@ +--- +name: lifecycle-hooks +description: beforeEach, afterEach, beforeAll, afterAll, and around hooks +--- + +# Lifecycle Hooks + +## Basic Hooks + +```ts +import { afterAll, afterEach, beforeAll, beforeEach, test } from 'vitest' + +beforeAll(async () => { + // Runs once before all tests in file/suite + await setupDatabase() +}) + +afterAll(async () => { + // Runs once after all tests in file/suite + await teardownDatabase() +}) + +beforeEach(async () => { + // Runs before each test + await clearTestData() +}) + +afterEach(async () => { + // Runs after each test + await cleanupMocks() +}) +``` + +## Cleanup Return Pattern + +Return cleanup function from `before*` hooks: + +```ts +beforeAll(async () => { + const server = await startServer() + + // Returned function runs as afterAll + return async () => { + await server.close() + } +}) + +beforeEach(async () => { + const connection = await connect() + + // Runs as afterEach + return () => connection.close() +}) +``` + +## Scoped Hooks + +Hooks apply to current suite and nested suites: + +```ts +describe('outer', () => { + beforeEach(() => console.log('outer before')) + + test('test 1', () => {}) // outer before → test + + describe('inner', () => { + beforeEach(() => console.log('inner before')) + + test('test 2', () => {}) // outer before → inner before → test + }) +}) +``` + +## Hook Timeout + +```ts +beforeAll(async () => { + await slowSetup() +}, 30_000) // 30 second timeout +``` + +## Around Hooks + +Wrap tests with setup/teardown context: + +```ts +import { aroundEach, test } from 'vitest' + +// Wrap each test in database transaction +aroundEach(async (runTest) => { + await db.beginTransaction() + await runTest() // Must be called! + await db.rollback() +}) + +test('insert user', async () => { + await db.insert({ name: 'Alice' }) + // Automatically rolled back after test +}) +``` + +### aroundAll + +Wrap entire suite: + +```ts +import { aroundAll, test } from 'vitest' + +aroundAll(async (runSuite) => { + console.log('before all tests') + await runSuite() // Must be called! + console.log('after all tests') +}) +``` + +### Multiple Around Hooks + +Nested like onion layers: + +```ts +aroundEach(async (runTest) => { + console.log('outer before') + await runTest() + console.log('outer after') +}) + +aroundEach(async (runTest) => { + console.log('inner before') + await runTest() + console.log('inner after') +}) + +// Order: outer before → inner before → test → inner after → outer after +``` + +## Test Hooks + +Inside test body: + +```ts +import { onTestFailed, onTestFinished, test } from 'vitest' + +test('with cleanup', () => { + const db = connect() + + // Runs after test finishes (pass or fail) + onTestFinished(() => db.close()) + + // Only runs if test fails + onTestFailed(({ task }) => { + console.log('Failed:', task.result?.errors) + }) + + db.query('SELECT * FROM users') +}) +``` + +### Reusable Cleanup Pattern + +```ts +function useTestDb() { + const db = connect() + onTestFinished(() => db.close()) + return db +} + +test('query users', () => { + const db = useTestDb() + expect(db.query('SELECT * FROM users')).toBeDefined() +}) + +test('query orders', () => { + const db = useTestDb() // Fresh connection, auto-closed + expect(db.query('SELECT * FROM orders')).toBeDefined() +}) +``` + +## Concurrent Test Hooks + +For concurrent tests, use context's hooks: + +```ts +test.concurrent('concurrent', ({ onTestFinished }) => { + const resource = allocate() + onTestFinished(() => resource.release()) +}) +``` + +## Extended Test Hooks + +With `test.extend`, hooks are type-aware: + +```ts +const test = base.extend<{ db: Database }>({ + db: async ({}, use) => { + const db = await createDb() + await use(db) + await db.close() + }, +}) + +// These hooks know about `db` fixture +test.beforeEach(({ db }) => { + db.seed() +}) + +test.afterEach(({ db }) => { + db.clear() +}) +``` + +## Hook Execution Order + +Default order (stack): +1. `beforeAll` (in order) +2. `beforeEach` (in order) +3. Test +4. `afterEach` (reverse order) +5. `afterAll` (reverse order) + +Configure with `sequence.hooks`: + +```ts +defineConfig({ + test: { + sequence: { + hooks: 'list', // 'stack' (default), 'list', 'parallel' + }, + }, +}) +``` + +## Key Points + +- Hooks are not called during type checking +- Return cleanup function from `before*` to avoid `after*` duplication +- `aroundEach`/`aroundAll` must call `runTest()`/`runSuite()` +- `onTestFinished` always runs, even if test fails +- Use context hooks for concurrent tests + + diff --git a/.agents/skills/vitest/references/core-test-api.md b/.agents/skills/vitest/references/core-test-api.md new file mode 100644 index 0000000000..1f3c93238a --- /dev/null +++ b/.agents/skills/vitest/references/core-test-api.md @@ -0,0 +1,233 @@ +--- +name: test-api +description: test/it function for defining tests with modifiers +--- + +# Test API + +## Basic Test + +```ts +import { expect, test } from 'vitest' + +test('adds numbers', () => { + expect(1 + 1).toBe(2) +}) + +// Alias: it +import { it } from 'vitest' + +it('works the same', () => { + expect(true).toBe(true) +}) +``` + +## Async Tests + +```ts +test('async test', async () => { + const result = await fetchData() + expect(result).toBeDefined() +}) + +// Promises are automatically awaited +test('returns promise', () => { + return fetchData().then(result => { + expect(result).toBeDefined() + }) +}) +``` + +## Test Options + +```ts +// Timeout (default: 5000ms) +test('slow test', async () => { + // ... +}, 10_000) + +// Or with options object +test('with options', { timeout: 10_000, retry: 2 }, async () => { + // ... +}) +``` + +## Test Modifiers + +### Skip Tests + +```ts +test.skip('skipped test', () => { + // Won't run +}) + +// Conditional skip +test.skipIf(process.env.CI)('not in CI', () => {}) +test.runIf(process.env.CI)('only in CI', () => {}) + +// Dynamic skip via context +test('dynamic skip', ({ skip }) => { + skip(someCondition, 'reason') + // ... +}) +``` + +### Focus Tests + +```ts +test.only('only this runs', () => { + // Other tests in file are skipped +}) +``` + +### Todo Tests + +```ts +test.todo('implement later') + +test.todo('with body', () => { + // Not run, shows in report +}) +``` + +### Failing Tests + +```ts +test.fails('expected to fail', () => { + expect(1).toBe(2) // Test passes because assertion fails +}) +``` + +### Concurrent Tests + +```ts +// Run tests in parallel +test.concurrent('test 1', async ({ expect }) => { + // Use context.expect for concurrent tests + expect(await fetch1()).toBe('result') +}) + +test.concurrent('test 2', async ({ expect }) => { + expect(await fetch2()).toBe('result') +}) +``` + +### Sequential Tests + +```ts +// Force sequential in concurrent context +test.sequential('must run alone', async () => {}) +``` + +## Parameterized Tests + +### test.each + +```ts +test.each([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], +])('add(%i, %i) = %i', (a, b, expected) => { + expect(a + b).toBe(expected) +}) + +// With objects +test.each([ + { a: 1, b: 1, expected: 2 }, + { a: 1, b: 2, expected: 3 }, +])('add($a, $b) = $expected', ({ a, b, expected }) => { + expect(a + b).toBe(expected) +}) + +// Template literal +test.each` + a | b | expected + ${1} | ${1} | ${2} + ${1} | ${2} | ${3} +`('add($a, $b) = $expected', ({ a, b, expected }) => { + expect(a + b).toBe(expected) +}) +``` + +### test.for + +Preferred over `.each` - doesn't spread arrays: + +```ts +test.for([ + [1, 1, 2], + [1, 2, 3], +])('add(%i, %i) = %i', ([a, b, expected], { expect }) => { + // Second arg is TestContext + expect(a + b).toBe(expected) +}) +``` + +## Test Context + +First argument provides context utilities: + +```ts +test('with context', ({ expect, skip, task }) => { + console.log(task.name) // Test name + skip(someCondition) // Skip dynamically + expect(1).toBe(1) // Context-bound expect +}) +``` + +## Custom Test with Fixtures + +```ts +import { test as base } from 'vitest' + +const test = base.extend({ + db: async ({}, use) => { + const db = await createDb() + await use(db) + await db.close() + }, +}) + +test('query', async ({ db }) => { + const users = await db.query('SELECT * FROM users') + expect(users).toBeDefined() +}) +``` + +## Retry Configuration + +```ts +test('flaky test', { retry: 3 }, async () => { + // Retries up to 3 times on failure +}) + +// Advanced retry options +test('with delay', { + retry: { + count: 3, + delay: 1000, + condition: /timeout/i, // Only retry on timeout errors + }, +}, async () => {}) +``` + +## Tags + +```ts +test('database test', { tags: ['db', 'slow'] }, async () => {}) + +// Run with: vitest --tags db +``` + +## Key Points + +- Tests with no body are marked as `todo` +- `test.only` throws in CI unless `allowOnly: true` +- Use context's `expect` for concurrent tests and snapshots +- Function name is used as test name if passed as first arg + + diff --git a/.agents/skills/vitest/references/features-concurrency.md b/.agents/skills/vitest/references/features-concurrency.md new file mode 100644 index 0000000000..412f60d821 --- /dev/null +++ b/.agents/skills/vitest/references/features-concurrency.md @@ -0,0 +1,250 @@ +--- +name: concurrency-parallelism +description: Concurrent tests, parallel execution, and sharding +--- + +# Concurrency & Parallelism + +## File Parallelism + +By default, Vitest runs test files in parallel across workers: + +```ts +defineConfig({ + test: { + // Run files in parallel (default: true) + fileParallelism: true, + + // Number of worker threads + maxWorkers: 4, + minWorkers: 1, + + // Pool type: 'threads', 'forks', 'vmThreads' + pool: 'threads', + }, +}) +``` + +## Concurrent Tests + +Run tests within a file in parallel: + +```ts +// Individual concurrent tests +test.concurrent('test 1', async ({ expect }) => { + expect(await fetch1()).toBe('result') +}) + +test.concurrent('test 2', async ({ expect }) => { + expect(await fetch2()).toBe('result') +}) + +// All tests in suite concurrent +describe.concurrent('parallel suite', () => { + test('test 1', async ({ expect }) => {}) + test('test 2', async ({ expect }) => {}) +}) +``` + +**Important:** Use `{ expect }` from context for concurrent tests. + +## Sequential in Concurrent Context + +Force sequential execution: + +```ts +describe.concurrent('mostly parallel', () => { + test('parallel 1', async () => {}) + test('parallel 2', async () => {}) + + test.sequential('must run alone 1', async () => {}) + test.sequential('must run alone 2', async () => {}) +}) + +// Or entire suite +describe.sequential('sequential suite', () => { + test('first', () => {}) + test('second', () => {}) +}) +``` + +## Max Concurrency + +Limit concurrent tests: + +```ts +defineConfig({ + test: { + maxConcurrency: 5, // Max concurrent tests per file + }, +}) +``` + +## Isolation + +Each file runs in isolated environment by default: + +```ts +defineConfig({ + test: { + // Disable isolation for faster runs (less safe) + isolate: false, + }, +}) +``` + +## Sharding + +Split tests across machines: + +```bash +# Machine 1 +vitest run --shard=1/3 + +# Machine 2 +vitest run --shard=2/3 + +# Machine 3 +vitest run --shard=3/3 +``` + +### CI Example (GitHub Actions) + +```yaml +jobs: + test: + strategy: + matrix: + shard: [1, 2, 3] + steps: + - run: vitest run --shard=${{ matrix.shard }}/3 --reporter=blob + + merge: + needs: test + steps: + - run: vitest --merge-reports --reporter=junit +``` + +### Merge Reports + +```bash +# Each shard outputs blob +vitest run --shard=1/3 --reporter=blob --coverage +vitest run --shard=2/3 --reporter=blob --coverage + +# Merge all blobs +vitest --merge-reports --reporter=json --coverage +``` + +## Test Sequence + +Control test order: + +```ts +defineConfig({ + test: { + sequence: { + // Run tests in random order + shuffle: true, + + // Seed for reproducible shuffle + seed: 12345, + + // Hook execution order + hooks: 'stack', // 'stack', 'list', 'parallel' + + // All tests concurrent by default + concurrent: true, + }, + }, +}) +``` + +## Shuffle Tests + +Randomize to catch hidden dependencies: + +```ts +// Via CLI +vitest --sequence.shuffle + +// Per suite +describe.shuffle('random order', () => { + test('test 1', () => {}) + test('test 2', () => {}) + test('test 3', () => {}) +}) +``` + +## Pool Options + +### Threads (Default) + +```ts +defineConfig({ + test: { + pool: 'threads', + poolOptions: { + threads: { + maxThreads: 8, + minThreads: 2, + isolate: true, + }, + }, + }, +}) +``` + +### Forks + +Better isolation, slower: + +```ts +defineConfig({ + test: { + pool: 'forks', + poolOptions: { + forks: { + maxForks: 4, + isolate: true, + }, + }, + }, +}) +``` + +### VM Threads + +Full VM isolation per file: + +```ts +defineConfig({ + test: { + pool: 'vmThreads', + }, +}) +``` + +## Bail on Failure + +Stop after first failure: + +```bash +vitest --bail 1 # Stop after 1 failure +vitest --bail # Stop on first failure (same as --bail 1) +``` + +## Key Points + +- Files run in parallel by default +- Use `.concurrent` for parallel tests within file +- Always use context's `expect` in concurrent tests +- Sharding splits tests across CI machines +- Use `--merge-reports` to combine sharded results +- Shuffle tests to find hidden dependencies + + diff --git a/.agents/skills/vitest/references/features-context.md b/.agents/skills/vitest/references/features-context.md new file mode 100644 index 0000000000..a9db0a1f61 --- /dev/null +++ b/.agents/skills/vitest/references/features-context.md @@ -0,0 +1,238 @@ +--- +name: test-context-fixtures +description: Test context, custom fixtures with test.extend +--- + +# Test Context & Fixtures + +## Built-in Context + +Every test receives context as first argument: + +```ts +test('context', ({ task, expect, skip }) => { + console.log(task.name) // Test name + expect(1).toBe(1) // Context-bound expect + skip() // Skip test dynamically +}) +``` + +### Context Properties + +- `task` - Test metadata (name, file, etc.) +- `expect` - Expect bound to this test (important for concurrent tests) +- `skip(condition?, message?)` - Skip the test +- `onTestFinished(fn)` - Cleanup after test +- `onTestFailed(fn)` - Run on failure only + +## Custom Fixtures with test.extend + +Create reusable test utilities: + +```ts +import { test as base } from 'vitest' + +// Define fixture types +interface Fixtures { + db: Database + user: User +} + +// Create extended test +export const test = base.extend({ + // Fixture with setup/teardown + db: async ({}, use) => { + const db = await createDatabase() + await use(db) // Provide to test + await db.close() // Cleanup + }, + + // Fixture depending on another fixture + user: async ({ db }, use) => { + const user = await db.createUser({ name: 'Test' }) + await use(user) + await db.deleteUser(user.id) + }, +}) +``` + +Using fixtures: + +```ts +test('query user', async ({ db, user }) => { + const found = await db.findUser(user.id) + expect(found).toEqual(user) +}) +``` + +## Fixture Initialization + +Fixtures only initialize when accessed: + +```ts +const test = base.extend({ + expensive: async ({}, use) => { + console.log('initializing') // Only runs if test uses it + await use('value') + }, +}) + +test('no fixture', () => {}) // expensive not called +test('uses fixture', ({ expensive }) => {}) // expensive called +``` + +## Auto Fixtures + +Run fixture for every test: + +```ts +const test = base.extend({ + setup: [ + async ({}, use) => { + await globalSetup() + await use() + await globalTeardown() + }, + { auto: true } // Always run + ], +}) +``` + +## Scoped Fixtures + +### File Scope + +Initialize once per file: + +```ts +const test = base.extend({ + connection: [ + async ({}, use) => { + const conn = await connect() + await use(conn) + await conn.close() + }, + { scope: 'file' } + ], +}) +``` + +### Worker Scope + +Initialize once per worker: + +```ts +const test = base.extend({ + sharedResource: [ + async ({}, use) => { + await use(globalResource) + }, + { scope: 'worker' } + ], +}) +``` + +## Injected Fixtures (from Config) + +Override fixtures per project: + +```ts +// test file +const test = base.extend({ + apiUrl: ['/default', { injected: true }], +}) + +// vitest.config.ts +defineConfig({ + test: { + projects: [ + { + test: { + name: 'prod', + provide: { apiUrl: 'https://api.prod.com' }, + }, + }, + ], + }, +}) +``` + +## Scoped Values per Suite + +Override fixture for specific suite: + +```ts +const test = base.extend({ + environment: 'development', +}) + +describe('production tests', () => { + test.scoped({ environment: 'production' }) + + test('uses production', ({ environment }) => { + expect(environment).toBe('production') + }) +}) + +test('uses default', ({ environment }) => { + expect(environment).toBe('development') +}) +``` + +## Extended Test Hooks + +Type-aware hooks with fixtures: + +```ts +const test = base.extend<{ db: Database }>({ + db: async ({}, use) => { + const db = await createDb() + await use(db) + await db.close() + }, +}) + +// Hooks know about fixtures +test.beforeEach(({ db }) => { + db.seed() +}) + +test.afterEach(({ db }) => { + db.clear() +}) +``` + +## Composing Fixtures + +Extend from another extended test: + +```ts +// base-test.ts +export const test = base.extend<{ db: Database }>({ + db: async ({}, use) => { /* ... */ }, +}) + +// admin-test.ts +import { test as dbTest } from './base-test' + +export const test = dbTest.extend<{ admin: User }>({ + admin: async ({ db }, use) => { + const admin = await db.createAdmin() + await use(admin) + }, +}) +``` + +## Key Points + +- Use `{ }` destructuring to access fixtures +- Fixtures are lazy - only initialize when accessed +- Return cleanup function from fixtures +- Use `{ auto: true }` for setup fixtures +- Use `{ scope: 'file' }` for expensive shared resources +- Fixtures compose - extend from extended tests + + diff --git a/.agents/skills/vitest/references/features-coverage.md b/.agents/skills/vitest/references/features-coverage.md new file mode 100644 index 0000000000..aaf44cfb2b --- /dev/null +++ b/.agents/skills/vitest/references/features-coverage.md @@ -0,0 +1,207 @@ +--- +name: code-coverage +description: Code coverage with V8 or Istanbul providers +--- + +# Code Coverage + +## Setup + +```bash +# Run tests with coverage +vitest run --coverage +``` + +## Configuration + +```ts +// vitest.config.ts +defineConfig({ + test: { + coverage: { + // Provider: 'v8' (default, faster) or 'istanbul' (more compatible) + provider: 'v8', + + // Enable coverage + enabled: true, + + // Reporters + reporter: ['text', 'json', 'html'], + + // Files to include + include: ['src/**/*.{ts,tsx}'], + + // Files to exclude + exclude: [ + 'node_modules/', + 'tests/', + '**/*.d.ts', + '**/*.test.ts', + ], + + // Report uncovered files + all: true, + + // Thresholds + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, +}) +``` + +## Providers + +### V8 (Default) + +```bash +npm i -D @vitest/coverage-v8 +``` + +- Faster, no pre-instrumentation +- Uses V8's native coverage +- Recommended for most projects + +### Istanbul + +```bash +npm i -D @vitest/coverage-istanbul +``` + +- Pre-instruments code +- Works in any JS runtime +- More overhead but widely compatible + +## Reporters + +```ts +coverage: { + reporter: [ + 'text', // Terminal output + 'text-summary', // Summary only + 'json', // JSON file + 'html', // HTML report + 'lcov', // For CI tools + 'cobertura', // XML format + ], + reportsDirectory: './coverage', +} +``` + +## Thresholds + +Fail tests if coverage is below threshold: + +```ts +coverage: { + thresholds: { + // Global thresholds + lines: 80, + functions: 75, + branches: 70, + statements: 80, + + // Per-file thresholds + perFile: true, + + // Auto-update thresholds (for gradual improvement) + autoUpdate: true, + }, +} +``` + +## Ignoring Code + +### V8 + +```ts +/* v8 ignore next -- @preserve */ +function ignored() { + return 'not covered' +} + +/* v8 ignore start -- @preserve */ +// All code here ignored +/* v8 ignore stop -- @preserve */ +``` + +### Istanbul + +```ts +/* istanbul ignore next -- @preserve */ +function ignored() {} + +/* istanbul ignore if -- @preserve */ +if (condition) { + // ignored +} +``` + +Note: `@preserve` keeps comments through esbuild. + +## Package.json Scripts + +```json +{ + "scripts": { + "test": "vitest", + "test:coverage": "vitest run --coverage", + "test:coverage:watch": "vitest --coverage" + } +} +``` + +## Vitest UI Coverage + +Enable HTML coverage in Vitest UI: + +```ts +coverage: { + enabled: true, + reporter: ['text', 'html'], +} +``` + +Run with `vitest --ui` to view coverage visually. + +## CI Integration + +```yaml +# GitHub Actions +- name: Run tests with coverage + run: npm run test:coverage + +- name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage/lcov.info +``` + +## Coverage with Sharding + +Merge coverage from sharded runs: + +```bash +vitest run --shard=1/3 --coverage --reporter=blob +vitest run --shard=2/3 --coverage --reporter=blob +vitest run --shard=3/3 --coverage --reporter=blob + +vitest --merge-reports --coverage --reporter=json +``` + +## Key Points + +- V8 is faster, Istanbul is more compatible +- Use `--coverage` flag or `coverage.enabled: true` +- Include `all: true` to see uncovered files +- Set thresholds to enforce minimum coverage +- Use `@preserve` comment to keep ignore hints + + diff --git a/.agents/skills/vitest/references/features-filtering.md b/.agents/skills/vitest/references/features-filtering.md new file mode 100644 index 0000000000..24a41cb521 --- /dev/null +++ b/.agents/skills/vitest/references/features-filtering.md @@ -0,0 +1,211 @@ +--- +name: test-filtering +description: Filter tests by name, file patterns, and tags +--- + +# Test Filtering + +## CLI Filtering + +### By File Path + +```bash +# Run files containing "user" +vitest user + +# Multiple patterns +vitest user auth + +# Specific file +vitest src/user.test.ts + +# By line number +vitest src/user.test.ts:25 +``` + +### By Test Name + +```bash +# Tests matching pattern +vitest -t "login" +vitest --testNamePattern "should.*work" + +# Regex patterns +vitest -t "/user|auth/" +``` + +## Changed Files + +```bash +# Uncommitted changes +vitest --changed + +# Since specific commit +vitest --changed HEAD~1 +vitest --changed abc123 + +# Since branch +vitest --changed origin/main +``` + +## Related Files + +Run tests that import specific files: + +```bash +vitest related src/utils.ts src/api.ts --run +``` + +Useful with lint-staged: + +```js +// .lintstagedrc.js +export default { + '*.{ts,tsx}': 'vitest related --run', +} +``` + +## Focus Tests (.only) + +```ts +test.only('only this runs', () => {}) + +describe.only('only this suite', () => { + test('runs', () => {}) +}) +``` + +In CI, `.only` throws error unless configured: + +```ts +defineConfig({ + test: { + allowOnly: true, // Allow .only in CI + }, +}) +``` + +## Skip Tests + +```ts +test.skip('skipped', () => {}) + +// Conditional +test.skipIf(process.env.CI)('not in CI', () => {}) +test.runIf(!process.env.CI)('local only', () => {}) + +// Dynamic skip +test('dynamic', ({ skip }) => { + skip(someCondition, 'reason') +}) +``` + +## Tags + +Filter by custom tags: + +```ts +test('database test', { tags: ['db'] }, () => {}) +test('slow test', { tags: ['slow', 'integration'] }, () => {}) +``` + +Run tagged tests: + +```bash +vitest --tags db +vitest --tags "db,slow" # OR +vitest --tags db --tags slow # OR +``` + +Configure allowed tags: + +```ts +defineConfig({ + test: { + tags: ['db', 'slow', 'integration'], + strictTags: true, // Fail on unknown tags + }, +}) +``` + +## Include/Exclude Patterns + +```ts +defineConfig({ + test: { + // Test file patterns + include: ['**/*.{test,spec}.{ts,tsx}'], + + // Exclude patterns + exclude: [ + '**/node_modules/**', + '**/e2e/**', + '**/*.skip.test.ts', + ], + + // Include source for in-source testing + includeSource: ['src/**/*.ts'], + }, +}) +``` + +## Watch Mode Filtering + +In watch mode, press: +- `p` - Filter by filename pattern +- `t` - Filter by test name pattern +- `a` - Run all tests +- `f` - Run only failed tests + +## Projects Filtering + +Run specific project: + +```bash +vitest --project unit +vitest --project integration --project e2e +``` + +## Environment-based Filtering + +```ts +const isDev = process.env.NODE_ENV === 'development' +const isCI = process.env.CI + +describe.skipIf(isCI)('local only tests', () => {}) +describe.runIf(isDev)('dev tests', () => {}) +``` + +## Combining Filters + +```bash +# File pattern + test name + changed +vitest user -t "login" --changed + +# Related files + run mode +vitest related src/auth.ts --run +``` + +## List Tests Without Running + +```bash +vitest list # Show all test names +vitest list -t "user" # Filter by name +vitest list --filesOnly # Show only file paths +vitest list --json # JSON output +``` + +## Key Points + +- Use `-t` for test name pattern filtering +- `--changed` runs only tests affected by changes +- `--related` runs tests importing specific files +- Tags provide semantic test grouping +- Use `.only` for debugging, but configure CI to reject it +- Watch mode has interactive filtering + + diff --git a/.agents/skills/vitest/references/features-mocking.md b/.agents/skills/vitest/references/features-mocking.md new file mode 100644 index 0000000000..e351efef62 --- /dev/null +++ b/.agents/skills/vitest/references/features-mocking.md @@ -0,0 +1,265 @@ +--- +name: mocking +description: Mock functions, modules, timers, and dates with vi utilities +--- + +# Mocking + +## Mock Functions + +```ts +import { expect, vi } from 'vitest' + +// Create mock function +const fn = vi.fn() +fn('hello') + +expect(fn).toHaveBeenCalled() +expect(fn).toHaveBeenCalledWith('hello') + +// With implementation +const add = vi.fn((a, b) => a + b) +expect(add(1, 2)).toBe(3) + +// Mock return values +fn.mockReturnValue(42) +fn.mockReturnValueOnce(1).mockReturnValueOnce(2) +fn.mockResolvedValue({ data: true }) +fn.mockRejectedValue(new Error('fail')) + +// Mock implementation +fn.mockImplementation((x) => x * 2) +fn.mockImplementationOnce(() => 'first call') +``` + +## Spying on Objects + +```ts +const cart = { + getTotal: () => 100, +} + +const spy = vi.spyOn(cart, 'getTotal') +cart.getTotal() + +expect(spy).toHaveBeenCalled() + +// Mock implementation +spy.mockReturnValue(200) +expect(cart.getTotal()).toBe(200) + +// Restore original +spy.mockRestore() +``` + +## Module Mocking + +```ts +// vi.mock is hoisted to top of file +vi.mock('./api', () => ({ + fetchUser: vi.fn(() => ({ id: 1, name: 'Mock' })), +})) + +import { fetchUser } from './api' + +test('mocked module', () => { + expect(fetchUser()).toEqual({ id: 1, name: 'Mock' }) +}) +``` + +### Partial Mock + +```ts +vi.mock('./utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + specificFunction: vi.fn(), + } +}) +``` + +### Auto-mock with Spy + +```ts +// Keep implementation but spy on calls +vi.mock('./calculator', { spy: true }) + +import { add } from './calculator' + +test('spy on module', () => { + const result = add(1, 2) // Real implementation + expect(result).toBe(3) + expect(add).toHaveBeenCalledWith(1, 2) +}) +``` + +### Manual Mocks (__mocks__) + +``` +src/ + __mocks__/ + axios.ts # Mocks 'axios' + api/ + __mocks__/ + client.ts # Mocks './client' + client.ts +``` + +```ts +// Just call vi.mock with no factory +vi.mock('axios') +vi.mock('./api/client') +``` + +## Dynamic Mocking (vi.doMock) + +Not hoisted - use for dynamic imports: + +```ts +test('dynamic mock', async () => { + vi.doMock('./config', () => ({ + apiUrl: 'http://test.local', + })) + + const { apiUrl } = await import('./config') + expect(apiUrl).toBe('http://test.local') + + vi.doUnmock('./config') +}) +``` + +## Mock Timers + +```ts +import { afterEach, beforeEach, vi } from 'vitest' + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +test('timers', () => { + const fn = vi.fn() + setTimeout(fn, 1000) + + expect(fn).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1000) + expect(fn).toHaveBeenCalled() +}) + +// Other timer methods +vi.runAllTimers() // Run all pending timers +vi.runOnlyPendingTimers() // Run only currently pending +vi.advanceTimersToNextTimer() // Advance to next timer +``` + +### Async Timer Methods + +```ts +test('async timers', async () => { + vi.useFakeTimers() + + let resolved = false + setTimeout(() => Promise.resolve().then(() => { resolved = true }), 100) + + await vi.advanceTimersByTimeAsync(100) + expect(resolved).toBe(true) +}) +``` + +## Mock Dates + +```ts +vi.setSystemTime(new Date('2024-01-01')) +expect(new Date().getFullYear()).toBe(2024) + +vi.useRealTimers() // Restore +``` + +## Mock Globals + +```ts +vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ json: () => ({ data: 'mock' }) }) +)) + +// Restore +vi.unstubAllGlobals() +``` + +## Mock Environment Variables + +```ts +vi.stubEnv('API_KEY', 'test-key') +expect(import.meta.env.API_KEY).toBe('test-key') + +// Restore +vi.unstubAllEnvs() +``` + +## Clearing Mocks + +```ts +const fn = vi.fn() +fn() + +fn.mockClear() // Clear call history +fn.mockReset() // Clear history + implementation +fn.mockRestore() // Restore original (for spies) + +// Global +vi.clearAllMocks() +vi.resetAllMocks() +vi.restoreAllMocks() +``` + +## Config Auto-Reset + +```ts +// vitest.config.ts +defineConfig({ + test: { + clearMocks: true, // Clear before each test + mockReset: true, // Reset before each test + restoreMocks: true, // Restore after each test + unstubEnvs: true, // Restore env vars + unstubGlobals: true, // Restore globals + }, +}) +``` + +## Hoisted Variables for Mocks + +```ts +const mockFn = vi.hoisted(() => vi.fn()) + +vi.mock('./module', () => ({ + getData: mockFn, +})) + +import { getData } from './module' + +test('hoisted mock', () => { + mockFn.mockReturnValue('test') + expect(getData()).toBe('test') +}) +``` + +## Key Points + +- `vi.mock` is hoisted - called before imports +- Use `vi.doMock` for dynamic, non-hoisted mocking +- Always restore mocks to avoid test pollution +- Use `{ spy: true }` to keep implementation but track calls +- `vi.hoisted` lets you reference variables in mock factories + + diff --git a/.agents/skills/vitest/references/features-snapshots.md b/.agents/skills/vitest/references/features-snapshots.md new file mode 100644 index 0000000000..6868fb13b7 --- /dev/null +++ b/.agents/skills/vitest/references/features-snapshots.md @@ -0,0 +1,207 @@ +--- +name: snapshot-testing +description: Snapshot testing with file, inline, and file snapshots +--- + +# Snapshot Testing + +Snapshot tests capture output and compare against stored references. + +## Basic Snapshot + +```ts +import { expect, test } from 'vitest' + +test('snapshot', () => { + const result = generateOutput() + expect(result).toMatchSnapshot() +}) +``` + +First run creates `.snap` file: + +```js +// __snapshots__/test.spec.ts.snap +exports['snapshot 1'] = ` +{ + "id": 1, + "name": "test" +} +` +``` + +## Inline Snapshots + +Stored directly in test file: + +```ts +test('inline snapshot', () => { + const data = { foo: 'bar' } + expect(data).toMatchInlineSnapshot() +}) +``` + +Vitest updates the test file: + +```ts +test('inline snapshot', () => { + const data = { foo: 'bar' } + expect(data).toMatchInlineSnapshot(` + { + "foo": "bar", + } + `) +}) +``` + +## File Snapshots + +Compare against explicit file: + +```ts +test('render html', async () => { + const html = renderComponent() + await expect(html).toMatchFileSnapshot('./expected/component.html') +}) +``` + +## Snapshot Hints + +Add descriptive hints: + +```ts +test('multiple snapshots', () => { + expect(header).toMatchSnapshot('header') + expect(body).toMatchSnapshot('body content') + expect(footer).toMatchSnapshot('footer') +}) +``` + +## Object Shape Matching + +Match partial structure: + +```ts +test('shape snapshot', () => { + const data = { + id: Math.random(), + created: new Date(), + name: 'test' + } + + expect(data).toMatchSnapshot({ + id: expect.any(Number), + created: expect.any(Date), + }) +}) +``` + +## Error Snapshots + +```ts +test('error message', () => { + expect(() => { + throw new Error('Something went wrong') + }).toThrowErrorMatchingSnapshot() +}) + +test('inline error', () => { + expect(() => { + throw new Error('Bad input') + }).toThrowErrorMatchingInlineSnapshot(`[Error: Bad input]`) +}) +``` + +## Updating Snapshots + +```bash +# Update all snapshots +vitest -u +vitest --update + +# In watch mode, press 'u' to update failed snapshots +``` + +## Custom Serializers + +Add custom snapshot formatting: + +```ts +expect.addSnapshotSerializer({ + test(val) { + return val && typeof val.toJSON === 'function' + }, + serialize(val, config, indentation, depth, refs, printer) { + return printer(val.toJSON(), config, indentation, depth, refs) + }, +}) +``` + +Or via config: + +```ts +// vitest.config.ts +defineConfig({ + test: { + snapshotSerializers: ['./my-serializer.ts'], + }, +}) +``` + +## Snapshot Format Options + +```ts +defineConfig({ + test: { + snapshotFormat: { + printBasicPrototype: false, // Don't print Array/Object prototypes + escapeString: false, + }, + }, +}) +``` + +## Concurrent Test Snapshots + +Use context's expect: + +```ts +test.concurrent('concurrent 1', async ({ expect }) => { + expect(await getData()).toMatchSnapshot() +}) + +test.concurrent('concurrent 2', async ({ expect }) => { + expect(await getOther()).toMatchSnapshot() +}) +``` + +## Snapshot File Location + +Default: `__snapshots__/.snap` + +Customize: + +```ts +defineConfig({ + test: { + resolveSnapshotPath: (testPath, snapExtension) => { + return testPath.replace('__tests__', '__snapshots__') + snapExtension + }, + }, +}) +``` + +## Key Points + +- Commit snapshot files to version control +- Review snapshot changes in code review +- Use hints for multiple snapshots in one test +- Use `toMatchFileSnapshot` for large outputs (HTML, JSON) +- Inline snapshots auto-update in test file +- Use context's `expect` for concurrent tests + + diff --git a/.claude/skills/vitest b/.claude/skills/vitest new file mode 120000 index 0000000000..7661536427 --- /dev/null +++ b/.claude/skills/vitest @@ -0,0 +1 @@ +../../.agents/skills/vitest \ No newline at end of file diff --git a/.cursor/skills/vitest b/.cursor/skills/vitest new file mode 120000 index 0000000000..7661536427 --- /dev/null +++ b/.cursor/skills/vitest @@ -0,0 +1 @@ +../../.agents/skills/vitest \ No newline at end of file diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 9313f6d4d9..1fb46c32d7 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs' // End of third-party imports import { API_URL, BASE_PATH } from 'lib/constants' -import { HttpResponse, http } from 'msw' +import { http, HttpResponse } from 'msw' import { createMockOrganization, createMockProject } from 'tests/helpers' import { customRender } from 'tests/lib/custom-render' import { addAPIMock, mswServer } from 'tests/lib/msw' @@ -785,6 +785,10 @@ describe('SupportFormPage', () => { expect(getSeveritySelector(screen)).toHaveTextContent('High') }) + // Wait for library selector to be available before interacting + await waitFor(() => { + expect(getLibrarySelector(screen)).toBeInTheDocument() + }) await selectLibraryOption(screen, 'JavaScript') await waitFor(() => { expect(getLibrarySelector(screen)).toHaveTextContent('JavaScript') @@ -801,9 +805,14 @@ describe('SupportFormPage', () => { const supportAccessToggle = screen.getByRole('switch', { name: /allow support access to your project/i, }) - expect(supportAccessToggle).toBeChecked() + // Wait for toggle to be in expected state before interacting + await waitFor(() => { + expect(supportAccessToggle).toBeChecked() + }) await userEvent.click(supportAccessToggle) - expect(supportAccessToggle).not.toBeChecked() + await waitFor(() => { + expect(supportAccessToggle).not.toBeChecked() + }) await userEvent.click(getSubmitButton(screen)) @@ -833,7 +842,7 @@ describe('SupportFormPage', () => { await waitFor(() => { expect(screen.getByRole('heading', { name: /support request sent/i })).toBeInTheDocument() }) - }, 10_000) + }, 15_000) test('submits urgent login issues ticket for a different organization', async () => { const submitSpy = vi.fn() diff --git a/apps/studio/lib/ai/message-utils.test.ts b/apps/studio/lib/ai/message-utils.test.ts new file mode 100644 index 0000000000..34502f43d2 --- /dev/null +++ b/apps/studio/lib/ai/message-utils.test.ts @@ -0,0 +1,153 @@ +import type { UIMessage } from 'ai' +import { describe, expect, it } from 'vitest' + +import { prepareMessagesForAPI } from './message-utils' + +describe('prepareMessagesForAPI', () => { + it('should limit messages to last 7 entries', () => { + const messages: UIMessage[] = Array.from({ length: 10 }, (_, i) => ({ + id: `msg-${i}`, + role: 'user', + parts: [{ type: 'text', text: `Message ${i}` }], + })) + + const result = prepareMessagesForAPI(messages) + + expect(result).toHaveLength(7) + expect(result[0].parts[0]).toEqual({ type: 'text', text: 'Message 3' }) + expect(result[6].parts[0]).toEqual({ type: 'text', text: 'Message 9' }) + }) + + it('should remove results property from assistant messages', () => { + const messages = [ + { + id: 'msg-1', + role: 'assistant', + parts: [{ type: 'text', text: 'Response' }], + results: { data: 'some data' }, + }, + ] as Array + + const result = prepareMessagesForAPI(messages) + + expect(result).toHaveLength(1) + expect(result[0]).not.toHaveProperty('results') + expect(result[0].parts[0]).toEqual({ type: 'text', text: 'Response' }) + }) + + it('should preserve messages without results', () => { + const messages: UIMessage[] = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', text: 'Question' }], + }, + { + id: 'msg-2', + role: 'assistant', + parts: [{ type: 'text', text: 'Answer' }], + }, + ] + + const result = prepareMessagesForAPI(messages) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual(messages[0]) + expect(result[1]).toEqual(messages[1]) + }) + + it('should handle empty array', () => { + const messages: UIMessage[] = [] + + const result = prepareMessagesForAPI(messages) + + expect(result).toHaveLength(0) + expect(result).toEqual([]) + }) + + it('should handle array with fewer than 7 messages', () => { + const messages: UIMessage[] = [ + { id: 'msg-1', role: 'user', parts: [{ type: 'text', text: 'Message 1' }] }, + { id: 'msg-2', role: 'assistant', parts: [{ type: 'text', text: 'Message 2' }] }, + { id: 'msg-3', role: 'user', parts: [{ type: 'text', text: 'Message 3' }] }, + ] + + const result = prepareMessagesForAPI(messages) + + expect(result).toHaveLength(3) + expect(result).toEqual(messages) + }) + + it('should handle array with exactly 7 messages', () => { + const messages: UIMessage[] = Array.from({ length: 7 }, (_, i) => ({ + id: `msg-${i}`, + role: i % 2 === 0 ? 'user' : 'assistant', + parts: [{ type: 'text', text: `Message ${i}` }], + })) + + const result = prepareMessagesForAPI(messages) + + expect(result).toHaveLength(7) + expect(result).toEqual(messages) + }) + + it('should only remove results from assistant messages, not user messages', () => { + const messages = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', text: 'Question' }], + results: { data: 'user data' }, + }, + { + id: 'msg-2', + role: 'assistant', + parts: [{ type: 'text', text: 'Answer' }], + results: { data: 'assistant data' }, + }, + ] as Array + + const result = prepareMessagesForAPI(messages) + + expect(result).toHaveLength(2) + // User message keeps results (not removed by the function) + expect((result[0] as any).results).toEqual({ data: 'user data' }) + // Assistant message has results removed + expect(result[1]).not.toHaveProperty('results') + }) + + it('should handle mixed messages with and without results', () => { + const messages = [ + { + id: 'msg-1', + role: 'assistant', + parts: [{ type: 'text', text: 'First' }], + results: { data: 'data1' }, + }, + { + id: 'msg-2', + role: 'user', + parts: [{ type: 'text', text: 'Second' }], + }, + { + id: 'msg-3', + role: 'assistant', + parts: [{ type: 'text', text: 'Third' }], + }, + { + id: 'msg-4', + role: 'assistant', + parts: [{ type: 'text', text: 'Fourth' }], + results: { data: 'data2' }, + }, + ] as Array + + const result = prepareMessagesForAPI(messages) + + expect(result).toHaveLength(4) + expect(result[0]).not.toHaveProperty('results') + expect(result[1]).toEqual(messages[1]) + expect(result[2]).toEqual(messages[2]) + expect(result[3]).not.toHaveProperty('results') + }) +}) diff --git a/apps/studio/lib/ai/model.utils.test.ts b/apps/studio/lib/ai/model.utils.test.ts new file mode 100644 index 0000000000..dfddf46165 --- /dev/null +++ b/apps/studio/lib/ai/model.utils.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' + +import { getDefaultModelForProvider, PROVIDERS } from './model.utils' +import type { ProviderName } from './model.utils' + +describe('model.utils', () => { + describe('getDefaultModelForProvider', () => { + it('should return correct default for bedrock provider', () => { + const result = getDefaultModelForProvider('bedrock') + expect(result).toBe('openai.gpt-oss-120b-1:0') + }) + + it('should return correct default for openai provider', () => { + const result = getDefaultModelForProvider('openai') + expect(result).toBe('gpt-5-mini') + }) + + it('should return correct default for anthropic provider', () => { + const result = getDefaultModelForProvider('anthropic') + expect(result).toBe('claude-3-5-haiku-20241022') + }) + + it('should return undefined for unknown provider', () => { + const result = getDefaultModelForProvider('unknown' as ProviderName) + expect(result).toBeUndefined() + }) + }) + + describe('PROVIDERS registry', () => { + it('should have bedrock provider with models', () => { + expect(PROVIDERS.bedrock).toBeDefined() + expect(PROVIDERS.bedrock.models).toBeDefined() + expect(Object.keys(PROVIDERS.bedrock.models)).toContain( + 'anthropic.claude-3-7-sonnet-20250219-v1:0' + ) + expect(Object.keys(PROVIDERS.bedrock.models)).toContain('openai.gpt-oss-120b-1:0') + }) + + it('should have openai provider with models', () => { + expect(PROVIDERS.openai).toBeDefined() + expect(PROVIDERS.openai.models).toBeDefined() + expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5') + expect(Object.keys(PROVIDERS.openai.models)).toContain('gpt-5-mini') + }) + + it('should have anthropic provider with models', () => { + expect(PROVIDERS.anthropic).toBeDefined() + expect(PROVIDERS.anthropic.models).toBeDefined() + expect(Object.keys(PROVIDERS.anthropic.models)).toContain('claude-sonnet-4-20250514') + expect(Object.keys(PROVIDERS.anthropic.models)).toContain('claude-3-5-haiku-20241022') + }) + + it('should have exactly one default model per provider', () => { + const providers: ProviderName[] = ['bedrock', 'openai', 'anthropic'] + + providers.forEach((provider) => { + const models = PROVIDERS[provider].models + const defaultModels = Object.entries(models).filter(([_, config]) => config.default) + expect(defaultModels.length).toBe(1) + }) + }) + + it('should have valid model configurations', () => { + const providers: ProviderName[] = ['bedrock', 'openai', 'anthropic'] + + providers.forEach((provider) => { + const models = PROVIDERS[provider].models + Object.entries(models).forEach(([modelId, config]) => { + expect(config).toHaveProperty('default') + expect(typeof config.default).toBe('boolean') + }) + }) + }) + + it('should have bedrock model with promptProviderOptions', () => { + const sonnetModel = PROVIDERS.bedrock.models['anthropic.claude-3-7-sonnet-20250219-v1:0'] + expect(sonnetModel.promptProviderOptions).toBeDefined() + expect(sonnetModel.promptProviderOptions?.bedrock).toBeDefined() + expect(sonnetModel.promptProviderOptions?.bedrock?.cachePoint).toEqual({ type: 'default' }) + }) + + it('should have openai provider with providerOptions', () => { + expect(PROVIDERS.openai.providerOptions).toBeDefined() + expect(PROVIDERS.openai.providerOptions?.openai).toBeDefined() + expect(PROVIDERS.openai.providerOptions?.openai?.reasoningEffort).toBe('minimal') + }) + }) +}) diff --git a/apps/studio/lib/ai/org-ai-details.test.ts b/apps/studio/lib/ai/org-ai-details.test.ts new file mode 100644 index 0000000000..2a913fe95c --- /dev/null +++ b/apps/studio/lib/ai/org-ai-details.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getOrgAIDetails } from './org-ai-details' + +vi.mock('data/organizations/organizations-query', () => ({ + getOrganizations: vi.fn(), +})) + +vi.mock('data/projects/project-detail-query', () => ({ + getProjectDetail: vi.fn(), +})) + +vi.mock('hooks/misc/useOrgOptedIntoAi', () => ({ + getAiOptInLevel: vi.fn(), +})) + +describe('ai/org-ai-details', () => { + let mockGetOrganizations: ReturnType + let mockGetProjectDetail: ReturnType + let mockGetAiOptInLevel: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + + const orgsQuery = await import('data/organizations/organizations-query') + const projectQuery = await import('data/projects/project-detail-query') + const aiHook = await import('hooks/misc/useOrgOptedIntoAi') + + mockGetOrganizations = vi.mocked(orgsQuery.getOrganizations) + mockGetProjectDetail = vi.mocked(projectQuery.getProjectDetail) + mockGetAiOptInLevel = vi.mocked(aiHook.getAiOptInLevel) + }) + + describe('getOrgAIDetails', () => { + it('should fetch organizations and project details', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'pro' }, + opt_in_tags: [], + } + const mockProject = { + id: 1, + organization_id: 1, + ref: 'test-project', + } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('full') + + await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + expect(mockGetOrganizations).toHaveBeenCalledWith({ + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + }, + }) + expect(mockGetProjectDetail).toHaveBeenCalledWith({ ref: 'test-project' }, undefined, { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + }) + }) + + it('should return AI opt-in level and limited status', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'free' }, + opt_in_tags: ['AI_SQL_GENERATOR_OPT_IN'], + } + const mockProject = { + organization_id: 1, + ref: 'test-project', + } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('schema_only') + + const result = await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + expect(result).toEqual({ + aiOptInLevel: 'schema_only', + isLimited: true, + }) + }) + + it('should mark pro plan as not limited', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'pro' }, + opt_in_tags: [], + } + const mockProject = { + organization_id: 1, + } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('full') + + const result = await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + expect(result.isLimited).toBe(false) + }) + + it('should throw error when project and org do not match', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'pro' }, + } + const mockProject = { + organization_id: 2, // Different org ID + } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + + await expect( + getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + ).rejects.toThrow('Project and organization do not match') + }) + + it('should handle org not found', async () => { + const mockProject = { + organization_id: 1, + } + + mockGetOrganizations.mockResolvedValue([]) // No orgs + mockGetProjectDetail.mockResolvedValue(mockProject) + + await expect( + getOrgAIDetails({ + orgSlug: 'non-existent-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + ).rejects.toThrow('Project and organization do not match') + }) + + it('should call getAiOptInLevel with org opt_in_tags', async () => { + const mockOptInTags = ['AI_SQL_GENERATOR_OPT_IN', 'AI_DATA_GENERATOR_OPT_IN'] + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'pro' }, + opt_in_tags: mockOptInTags, + } + const mockProject = { + organization_id: 1, + } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('full') + + await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + expect(mockGetAiOptInLevel).toHaveBeenCalledWith(mockOptInTags) + }) + + it('should include authorization header when provided', async () => { + const mockOrg = { + id: 1, + slug: 'test-org', + plan: { id: 'pro' }, + } + const mockProject = { + organization_id: 1, + } + + mockGetOrganizations.mockResolvedValue([mockOrg]) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('full') + + await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer custom-token', + projectRef: 'test-project', + }) + + expect(mockGetOrganizations).toHaveBeenCalledWith({ + headers: expect.objectContaining({ + Authorization: 'Bearer custom-token', + }), + }) + expect(mockGetProjectDetail).toHaveBeenCalledWith( + expect.anything(), + undefined, + expect.objectContaining({ + Authorization: 'Bearer custom-token', + }) + ) + }) + + it('should fetch multiple organizations and find correct one', async () => { + const mockOrgs = [ + { id: 1, slug: 'org-1', plan: { id: 'free' } }, + { id: 2, slug: 'test-org', plan: { id: 'pro' } }, + { id: 3, slug: 'org-3', plan: { id: 'team' } }, + ] + const mockProject = { + organization_id: 2, + } + + mockGetOrganizations.mockResolvedValue(mockOrgs) + mockGetProjectDetail.mockResolvedValue(mockProject) + mockGetAiOptInLevel.mockReturnValue('full') + + const result = await getOrgAIDetails({ + orgSlug: 'test-org', + authorization: 'Bearer token', + projectRef: 'test-project', + }) + + expect(result.isLimited).toBe(false) // Pro plan + }) + }) +}) diff --git a/apps/studio/lib/ai/tools/incident-tools.test.ts b/apps/studio/lib/ai/tools/incident-tools.test.ts new file mode 100644 index 0000000000..ab39d0a94e --- /dev/null +++ b/apps/studio/lib/ai/tools/incident-tools.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getIncidentTools } from './incident-tools' + +// Mock IS_PLATFORM +vi.mock('common', () => ({ + IS_PLATFORM: true, +})) + +describe('ai/tools/incident-tools', () => { + let mockFetch: ReturnType + let mockAbortSignal: AbortSignal + + beforeEach(() => { + vi.clearAllMocks() + mockFetch = vi.fn() + global.fetch = mockFetch + + // Mock AbortSignal.timeout + mockAbortSignal = new AbortController().signal + if (!AbortSignal.timeout) { + AbortSignal.timeout = vi.fn(() => mockAbortSignal) as any + } + }) + + describe('getIncidentTools', () => { + it('should return an object with get_active_incidents tool', () => { + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + + expect(tools).toBeDefined() + expect(tools.get_active_incidents).toBeDefined() + }) + + it('should have correct description for get_active_incidents', () => { + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + + expect(tools.get_active_incidents.description).toContain('Check for active incidents') + expect(tools.get_active_incidents.description).toContain('Supabase service') + }) + + it('should have empty input schema', () => { + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + const schema = tools.get_active_incidents.inputSchema + + // The schema is a Zod object that accepts empty object + expect(schema).toBeDefined() + expect((schema as any)._def.typeName).toBe('ZodObject') + }) + + describe('execute function', () => { + it('should return empty incidents when not on platform', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(false) + + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + const result = await (tools.get_active_incidents.execute as any)({}) + + expect(result).toEqual({ + incidents: [], + message: 'Incident checking is only available on Supabase platform.', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should fetch incidents from correct URL', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [], + }) + + const tools = getIncidentTools({ baseUrl: 'https://example.com/dashboard' }) + if (!tools.get_active_incidents.execute) throw new Error('execute is undefined') + await tools.get_active_incidents.execute({}, { toolCallId: 'test', messages: [] }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/dashboard/api/incident-status', + { + signal: expect.any(AbortSignal), + } + ) + }) + + it('should return message when no incidents', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [], + }) + + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + const result = await (tools.get_active_incidents.execute as any)({}) + + expect(result).toEqual({ + incidents: [], + message: expect.stringContaining('No active incidents'), + }) + }) + + it('should return incident summaries when incidents exist', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + + const mockIncidents = [ + { + name: 'Database slowness', + status: 'investigating', + impact: 'minor', + active_since: '2024-01-01T10:00:00Z', + extra_field: 'should be filtered', + }, + ] + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockIncidents, + }) + + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + const result = await (tools.get_active_incidents.execute as any)({}) + + expect((result as any).incidents).toEqual([ + { + name: 'Database slowness', + status: 'investigating', + impact: 'minor', + active_since: '2024-01-01T10:00:00Z', + }, + ]) + expect((result as any).message).toContain('1 active incident') + expect((result as any).message).toContain('status.supabase.com') + }) + + it('should handle multiple incidents', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + + const mockIncidents = [ + { + name: 'Database issue', + status: 'investigating', + impact: 'major', + active_since: '2024-01-01T10:00:00Z', + }, + { + name: 'Storage issue', + status: 'identified', + impact: 'minor', + active_since: '2024-01-01T11:00:00Z', + }, + ] + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockIncidents, + }) + + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + const result = await (tools.get_active_incidents.execute as any)({}) + + expect((result as any).incidents).toHaveLength(2) + expect((result as any).message).toContain('2 active incidents') + }) + + it('should handle fetch errors', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + + mockFetch.mockRejectedValue(new Error('Network error')) + + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + const result = await (tools.get_active_incidents.execute as any)({}) + + expect(result).toEqual({ + incidents: [], + error: 'Unable to check incident status at this time.', + }) + }) + + it('should handle non-ok responses', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + }) + + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + const result = await (tools.get_active_incidents.execute as any)({}) + + expect(result).toEqual({ + incidents: [], + error: 'Unable to check incident status at this time.', + }) + }) + + it('should use timeout signal', async () => { + const common = await import('common') + vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => [], + }) + + const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) + if (!tools.get_active_incidents.execute) throw new Error('execute is undefined') + await tools.get_active_incidents.execute({}, { toolCallId: 'test', messages: [] }) + + const callArgs = mockFetch.mock.calls[0] + expect(callArgs[1].signal).toBeInstanceOf(AbortSignal) + }) + }) + }) +}) diff --git a/apps/studio/lib/ai/tools/rendering-tools.test.ts b/apps/studio/lib/ai/tools/rendering-tools.test.ts new file mode 100644 index 0000000000..5bfcab87b4 --- /dev/null +++ b/apps/studio/lib/ai/tools/rendering-tools.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { getRenderingTools } from './rendering-tools' + +describe('ai/tools/rendering-tools', () => { + describe('getRenderingTools', () => { + it('should return an object with tool definitions', () => { + const tools = getRenderingTools() + + expect(tools).toBeDefined() + expect(typeof tools).toBe('object') + }) + + it('should include execute_sql tool', () => { + const tools = getRenderingTools() + + expect(tools.execute_sql).toBeDefined() + expect(tools.execute_sql.description).toContain('execute a SQL statement') + }) + + it('should include deploy_edge_function tool', () => { + const tools = getRenderingTools() + + expect(tools.deploy_edge_function).toBeDefined() + expect(tools.deploy_edge_function.description).toContain('deploy a Supabase Edge Function') + }) + + it('should include rename_chat tool', () => { + const tools = getRenderingTools() + + expect(tools.rename_chat).toBeDefined() + expect(tools.rename_chat.description).toContain('Rename the current chat session') + }) + + it('should have exactly 3 tools', () => { + const tools = getRenderingTools() + const toolNames = Object.keys(tools) + + expect(toolNames).toHaveLength(3) + expect(toolNames).toContain('execute_sql') + expect(toolNames).toContain('deploy_edge_function') + expect(toolNames).toContain('rename_chat') + }) + + it('should have execute_sql with correct input schema fields', () => { + const tools = getRenderingTools() + const executeSqlTool = tools.execute_sql + + // Check that the tool has an input schema + expect(executeSqlTool.inputSchema).toBeDefined() + + // Verify the schema exists and is a Zod object + const schema = executeSqlTool.inputSchema + expect(schema).toBeDefined() + expect((schema as any)._def.typeName).toBe('ZodObject') + }) + + it('should have deploy_edge_function with input schema', () => { + const tools = getRenderingTools() + const deployTool = tools.deploy_edge_function + + expect(deployTool.inputSchema).toBeDefined() + + // Verify the schema exists and is a Zod object + expect(deployTool.inputSchema).toBeDefined() + expect((deployTool.inputSchema as any)._def.typeName).toBe('ZodObject') + }) + + it('should have rename_chat with execute function', async () => { + const tools = getRenderingTools() + const renameTool = tools.rename_chat + + expect(renameTool.execute).toBeDefined() + expect(typeof renameTool.execute).toBe('function') + + // Test the execute function + if (!renameTool.execute) throw new Error('execute is undefined') + const result = await renameTool.execute( + { newName: 'Test Chat' }, + { toolCallId: 'test', messages: [] } + ) + expect(result).toEqual({ status: 'Chat request sent to client' }) + }) + + it('should validate execute_sql input schema correctly', () => { + const tools = getRenderingTools() + const schema = tools.execute_sql.inputSchema + + // Check if schema is a Zod schema with safeParse + if ('safeParse' in schema) { + // Valid input + const validInput = { + sql: 'SELECT * FROM users', + label: 'Get users', + chartConfig: { view: 'table' as const }, + isWriteQuery: false, + } + expect(schema.safeParse(validInput).success).toBe(true) + + // Valid chart config + const validChartInput = { + sql: 'SELECT count(*) FROM users', + label: 'User count', + chartConfig: { view: 'chart' as const, xAxis: 'date', yAxis: 'count' }, + isWriteQuery: false, + } + expect(schema.safeParse(validChartInput).success).toBe(true) + + // Missing required field + const invalidInput = { + sql: 'SELECT * FROM users', + // missing label, chartConfig, isWriteQuery + } + expect(schema.safeParse(invalidInput).success).toBe(false) + } else { + // Skip test if schema doesn't have safeParse + expect(schema).toBeDefined() + } + }) + + it('should validate rename_chat input schema correctly', () => { + const tools = getRenderingTools() + const schema = tools.rename_chat.inputSchema + + // Check if schema is a Zod schema with safeParse + if ('safeParse' in schema) { + // Valid input + expect(schema.safeParse({ newName: 'My Chat' }).success).toBe(true) + + // Invalid input - missing newName + expect(schema.safeParse({}).success).toBe(false) + + // Invalid input - wrong type + expect(schema.safeParse({ newName: 123 }).success).toBe(false) + } else { + // Skip test if schema doesn't have safeParse + expect(schema).toBeDefined() + } + }) + }) +}) diff --git a/apps/studio/lib/api/self-hosted/constants.test.ts b/apps/studio/lib/api/self-hosted/constants.test.ts new file mode 100644 index 0000000000..2a34793220 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/constants.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('api/self-hosted/constants', () => { + beforeEach(() => { + vi.resetModules() + }) + + describe('ENCRYPTION_KEY', () => { + it('should use PG_META_CRYPTO_KEY when set', async () => { + vi.stubEnv('PG_META_CRYPTO_KEY', 'my-secret-key-123') + const { ENCRYPTION_KEY } = await import('./constants') + expect(ENCRYPTION_KEY).toBe('my-secret-key-123') + }) + + it('should use SAMPLE_KEY as default', async () => { + vi.stubEnv('PG_META_CRYPTO_KEY', '') + const { ENCRYPTION_KEY } = await import('./constants') + expect(ENCRYPTION_KEY).toBe('SAMPLE_KEY') + }) + }) + + describe('POSTGRES_PORT', () => { + it('should use POSTGRES_PORT when set', async () => { + vi.stubEnv('POSTGRES_PORT', '5433') + const { POSTGRES_PORT } = await import('./constants') + expect(POSTGRES_PORT).toBe(5433) + }) + + it('should default to 5432', async () => { + vi.stubEnv('POSTGRES_PORT', '') + const { POSTGRES_PORT } = await import('./constants') + expect(POSTGRES_PORT).toBe(5432) + }) + }) + + describe('POSTGRES_HOST', () => { + it('should use POSTGRES_HOST when set', async () => { + vi.stubEnv('POSTGRES_HOST', 'my-db-host.example.com') + const { POSTGRES_HOST } = await import('./constants') + expect(POSTGRES_HOST).toBe('my-db-host.example.com') + }) + + it('should default to db', async () => { + vi.stubEnv('POSTGRES_HOST', '') + const { POSTGRES_HOST } = await import('./constants') + expect(POSTGRES_HOST).toBe('db') + }) + }) + + describe('POSTGRES_DATABASE', () => { + it('should use POSTGRES_DB when set', async () => { + vi.stubEnv('POSTGRES_DB', 'my_database') + const { POSTGRES_DATABASE } = await import('./constants') + expect(POSTGRES_DATABASE).toBe('my_database') + }) + + it('should default to postgres', async () => { + vi.stubEnv('POSTGRES_DB', '') + const { POSTGRES_DATABASE } = await import('./constants') + expect(POSTGRES_DATABASE).toBe('postgres') + }) + }) + + describe('POSTGRES_PASSWORD', () => { + it('should use POSTGRES_PASSWORD when set', async () => { + vi.stubEnv('POSTGRES_PASSWORD', 'super-secret-password') + const { POSTGRES_PASSWORD } = await import('./constants') + expect(POSTGRES_PASSWORD).toBe('super-secret-password') + }) + + it('should default to postgres', async () => { + vi.stubEnv('POSTGRES_PASSWORD', '') + const { POSTGRES_PASSWORD } = await import('./constants') + expect(POSTGRES_PASSWORD).toBe('postgres') + }) + }) + + describe('POSTGRES_USER_READ_WRITE', () => { + it('should use POSTGRES_USER_READ_WRITE when set', async () => { + vi.stubEnv('POSTGRES_USER_READ_WRITE', 'custom_admin') + const { POSTGRES_USER_READ_WRITE } = await import('./constants') + expect(POSTGRES_USER_READ_WRITE).toBe('custom_admin') + }) + + it('should default to supabase_admin', async () => { + vi.stubEnv('POSTGRES_USER_READ_WRITE', '') + const { POSTGRES_USER_READ_WRITE } = await import('./constants') + expect(POSTGRES_USER_READ_WRITE).toBe('supabase_admin') + }) + }) + + describe('POSTGRES_USER_READ_ONLY', () => { + it('should use POSTGRES_USER_READ_ONLY when set', async () => { + vi.stubEnv('POSTGRES_USER_READ_ONLY', 'custom_readonly') + const { POSTGRES_USER_READ_ONLY } = await import('./constants') + expect(POSTGRES_USER_READ_ONLY).toBe('custom_readonly') + }) + + it('should default to supabase_read_only_user', async () => { + vi.stubEnv('POSTGRES_USER_READ_ONLY', '') + const { POSTGRES_USER_READ_ONLY } = await import('./constants') + expect(POSTGRES_USER_READ_ONLY).toBe('supabase_read_only_user') + }) + }) +}) diff --git a/apps/studio/lib/api/self-hosted/constants.ts b/apps/studio/lib/api/self-hosted/constants.ts index f5d86acd30..0727ec3b0a 100644 --- a/apps/studio/lib/api/self-hosted/constants.ts +++ b/apps/studio/lib/api/self-hosted/constants.ts @@ -1,7 +1,7 @@ // Constants specific to self-hosted environments export const ENCRYPTION_KEY = process.env.PG_META_CRYPTO_KEY || 'SAMPLE_KEY' -export const POSTGRES_PORT = process.env.POSTGRES_PORT || 5432 +export const POSTGRES_PORT = parseInt(process.env.POSTGRES_PORT || '5432', 10) export const POSTGRES_HOST = process.env.POSTGRES_HOST || 'db' export const POSTGRES_DATABASE = process.env.POSTGRES_DB || 'postgres' export const POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD || 'postgres' diff --git a/apps/studio/lib/api/self-hosted/functions/index.test.ts b/apps/studio/lib/api/self-hosted/functions/index.test.ts new file mode 100644 index 0000000000..cf131f3523 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/functions/index.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import * as util from '../util' +import * as fileSystemStore from './fileSystemStore' +import { getFunctionsArtifactStore } from './index' + +vi.mock('../util', () => ({ + assertSelfHosted: vi.fn(), +})) + +vi.mock('./fileSystemStore', () => ({ + FileSystemFunctionsArtifactStore: vi.fn(), +})) + +describe('api/self-hosted/functions/index', () => { + let originalEdgeFunctionsFolder: string | undefined + + beforeEach(() => { + originalEdgeFunctionsFolder = process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER + vi.resetAllMocks() + }) + + afterEach(() => { + if (originalEdgeFunctionsFolder !== undefined) { + process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER = originalEdgeFunctionsFolder + } else { + delete process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER + } + }) + + describe('getFunctionsArtifactStore', () => { + it('should call assertSelfHosted', () => { + process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER = '/tmp/functions' + + getFunctionsArtifactStore() + + expect(util.assertSelfHosted).toHaveBeenCalled() + }) + + it('should throw error if EDGE_FUNCTIONS_MANAGEMENT_FOLDER is not set', () => { + delete process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER + + expect(() => getFunctionsArtifactStore()).toThrow( + 'EDGE_FUNCTIONS_MANAGEMENT_FOLDER is required' + ) + }) + + it('should create FileSystemFunctionsArtifactStore with correct path', () => { + process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER = '/var/lib/functions' + + getFunctionsArtifactStore() + + expect(fileSystemStore.FileSystemFunctionsArtifactStore).toHaveBeenCalledWith( + '/var/lib/functions' + ) + }) + + it('should return FileSystemFunctionsArtifactStore instance', () => { + const mockInstance = { + folderPath: '/tmp/test', + getFunctions: vi.fn(), + getFunctionBySlug: vi.fn(), + getFileEntriesBySlug: vi.fn(), + } + vi.mocked(fileSystemStore.FileSystemFunctionsArtifactStore).mockReturnValue( + mockInstance as any + ) + process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER = '/tmp/test' + + const result = getFunctionsArtifactStore() + + expect(result).toBe(mockInstance) + }) + }) +}) diff --git a/apps/studio/lib/api/self-hosted/generate-types.test.ts b/apps/studio/lib/api/self-hosted/generate-types.test.ts new file mode 100644 index 0000000000..e67f567451 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/generate-types.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { generateTypescriptTypes } from './generate-types' + +vi.mock('data/fetchers', () => ({ + fetchGet: vi.fn(), +})) + +vi.mock('lib/constants', () => ({ + PG_META_URL: 'http://localhost:8080', +})) + +vi.mock('./util', () => ({ + assertSelfHosted: vi.fn(), +})) + +describe('api/self-hosted/generate-types', () => { + let mockFetchGet: ReturnType + let mockAssertSelfHosted: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + + const fetchers = await import('data/fetchers') + const util = await import('./util') + + mockFetchGet = vi.mocked(fetchers.fetchGet) + mockAssertSelfHosted = vi.mocked(util.assertSelfHosted) + }) + + describe('generateTypescriptTypes', () => { + it('should call assertSelfHosted', async () => { + mockFetchGet.mockResolvedValue({ types: 'export type User = {}' }) + + await generateTypescriptTypes({ headers: {} }) + + expect(mockAssertSelfHosted).toHaveBeenCalled() + }) + + it('should fetch from correct URL with schema params', async () => { + mockFetchGet.mockResolvedValue({ types: 'export type User = {}' }) + + await generateTypescriptTypes({ headers: {} }) + + expect(mockFetchGet).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:8080/generators/typescript'), + expect.any(Object) + ) + + const callUrl = mockFetchGet.mock.calls[0][0] + expect(callUrl).toContain('included_schema=public,graphql_public,storage') + expect(callUrl).toContain('excluded_schemas=') + }) + + it('should include correct schemas in URL', async () => { + mockFetchGet.mockResolvedValue({ types: '' }) + + await generateTypescriptTypes({ headers: {} }) + + const callUrl = mockFetchGet.mock.calls[0][0] + expect(callUrl).toContain('public') + expect(callUrl).toContain('graphql_public') + expect(callUrl).toContain('storage') + }) + + it('should exclude system schemas', async () => { + mockFetchGet.mockResolvedValue({ types: '' }) + + await generateTypescriptTypes({ headers: {} }) + + const callUrl = mockFetchGet.mock.calls[0][0] + const excludedSchemas = [ + 'auth', + 'cron', + 'extensions', + 'graphql', + 'net', + 'pgsodium', + 'pgsodium_masks', + 'realtime', + 'supabase_functions', + 'supabase_migrations', + 'vault', + '_analytics', + '_realtime', + ] + + excludedSchemas.forEach((schema) => { + expect(callUrl).toContain(schema) + }) + }) + + it('should pass headers to fetchGet', async () => { + mockFetchGet.mockResolvedValue({ types: 'export type User = {}' }) + + const customHeaders = { + Authorization: 'Bearer token', + 'Custom-Header': 'value', + } + + await generateTypescriptTypes({ headers: customHeaders }) + + expect(mockFetchGet).toHaveBeenCalledWith(expect.any(String), { + headers: customHeaders, + }) + }) + + it('should return types from fetchGet response', async () => { + const mockTypes = 'export type User = { id: number; name: string }' + mockFetchGet.mockResolvedValue({ types: mockTypes }) + + const result = await generateTypescriptTypes({ headers: {} }) + + expect(result).toEqual({ types: mockTypes }) + }) + + it('should handle fetchGet errors', async () => { + const mockError = new Error('Network error') + mockFetchGet.mockRejectedValue(mockError) + + await expect(generateTypescriptTypes({ headers: {} })).rejects.toThrow('Network error') + }) + + it('should work without headers parameter', async () => { + mockFetchGet.mockResolvedValue({ types: '' }) + + await generateTypescriptTypes({}) + + expect(mockFetchGet).toHaveBeenCalledWith(expect.any(String), { headers: undefined }) + }) + + it('should construct URL with both included and excluded schemas', async () => { + mockFetchGet.mockResolvedValue({ types: '' }) + + await generateTypescriptTypes({ headers: {} }) + + const callUrl = mockFetchGet.mock.calls[0][0] + expect(callUrl).toContain('included_schema=') + expect(callUrl).toContain('excluded_schemas=') + }) + }) +}) diff --git a/apps/studio/lib/api/self-hosted/settings.test.ts b/apps/studio/lib/api/self-hosted/settings.test.ts new file mode 100644 index 0000000000..1c388f820e --- /dev/null +++ b/apps/studio/lib/api/self-hosted/settings.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getProjectSettings } from './settings' + +vi.mock('./util', () => ({ + assertSelfHosted: vi.fn(), +})) + +vi.mock('lib/constants/api', () => ({ + PROJECT_ENDPOINT: 'localhost:8000', + PROJECT_ENDPOINT_PROTOCOL: 'http', +})) + +describe('api/self-hosted/settings', () => { + let mockAssertSelfHosted: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + vi.resetModules() + + const util = await import('./util') + mockAssertSelfHosted = vi.mocked(util.assertSelfHosted) + }) + + describe('getProjectSettings', () => { + it('should call assertSelfHosted', () => { + getProjectSettings() + + expect(mockAssertSelfHosted).toHaveBeenCalled() + }) + + it('should return project settings with correct structure', () => { + const settings = getProjectSettings() + + expect(settings).toHaveProperty('app_config') + expect(settings).toHaveProperty('cloud_provider') + expect(settings).toHaveProperty('db_dns_name') + expect(settings).toHaveProperty('db_host') + expect(settings).toHaveProperty('db_name') + expect(settings).toHaveProperty('jwt_secret') + expect(settings).toHaveProperty('service_api_keys') + }) + + it('should return correct default values', () => { + const settings = getProjectSettings() + + expect(settings.cloud_provider).toBe('AWS') + expect(settings.db_host).toBe('localhost') + expect(settings.db_name).toBe('postgres') + expect(settings.db_port).toBe(5432) + expect(settings.db_user).toBe('postgres') + expect(settings.ref).toBe('default') + expect(settings.region).toBe('ap-southeast-1') + expect(settings.status).toBe('ACTIVE_HEALTHY') + expect(settings.ssl_enforced).toBe(false) + }) + + it('should include app_config with endpoint and protocol', () => { + const settings = getProjectSettings() + + expect(settings.app_config).toEqual({ + db_schema: 'public', + endpoint: 'localhost:8000', + storage_endpoint: 'localhost:8000', + protocol: 'http', + }) + }) + + it('should include service_api_keys array', () => { + const settings = getProjectSettings() + + expect(settings.service_api_keys).toHaveLength(2) + expect(settings.service_api_keys[0].name).toBe('service_role key') + expect(settings.service_api_keys[0].tags).toBe('service_role') + expect(settings.service_api_keys[1].name).toBe('anon key') + expect(settings.service_api_keys[1].tags).toBe('anon') + }) + + it('should use environment variables when set', async () => { + vi.stubEnv('AUTH_JWT_SECRET', 'custom-jwt-secret-with-at-least-32-chars') + vi.stubEnv('DEFAULT_PROJECT_NAME', 'My Custom Project') + vi.stubEnv('SUPABASE_SERVICE_KEY', 'custom-service-key') + vi.stubEnv('SUPABASE_ANON_KEY', 'custom-anon-key') + + // Need to re-import to pick up new env vars + vi.resetModules() + + const { getProjectSettings: getSettings } = await import('./settings') + const settings = getSettings() + + expect(settings.jwt_secret).toBe('custom-jwt-secret-with-at-least-32-chars') + expect(settings.name).toBe('My Custom Project') + expect(settings.service_api_keys[0].api_key).toBe('custom-service-key') + expect(settings.service_api_keys[1].api_key).toBe('custom-anon-key') + }) + + it('should use default JWT secret when not set', async () => { + vi.unstubAllEnvs() + + vi.resetModules() + const { getProjectSettings: getSettings } = await import('./settings') + const settings = getSettings() + + expect(settings.jwt_secret).toBe('super-secret-jwt-token-with-at-least-32-characters-long') + }) + + it('should use default project name when not set', async () => { + vi.unstubAllEnvs() + + vi.resetModules() + const { getProjectSettings: getSettings } = await import('./settings') + const settings = getSettings() + + expect(settings.name).toBe('Default Project') + }) + + it('should have correct db_ip_addr_config', () => { + const settings = getProjectSettings() + + expect(settings.db_ip_addr_config).toBe('legacy') + }) + + it('should have correct inserted_at timestamp', () => { + const settings = getProjectSettings() + + expect(settings.inserted_at).toBe('2021-08-02T06:40:40.646Z') + }) + }) +}) diff --git a/apps/studio/lib/api/self-hosted/types.test.ts b/apps/studio/lib/api/self-hosted/types.test.ts new file mode 100644 index 0000000000..ce543c012b --- /dev/null +++ b/apps/studio/lib/api/self-hosted/types.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' + +import { databaseErrorSchema, PgMetaDatabaseError } from './types' + +describe('api/self-hosted/types', () => { + describe('databaseErrorSchema', () => { + it('should validate valid database error', () => { + const validError = { + message: 'Database connection failed', + code: '08006', + formattedError: 'Error: Connection refused', + } + + const result = databaseErrorSchema.safeParse(validError) + expect(result.success).toBe(true) + }) + + it('should reject error missing message', () => { + const invalidError = { + code: '08006', + formattedError: 'Error: Connection refused', + } + + const result = databaseErrorSchema.safeParse(invalidError) + expect(result.success).toBe(false) + }) + + it('should reject error missing code', () => { + const invalidError = { + message: 'Database connection failed', + formattedError: 'Error: Connection refused', + } + + const result = databaseErrorSchema.safeParse(invalidError) + expect(result.success).toBe(false) + }) + + it('should reject error missing formattedError', () => { + const invalidError = { + message: 'Database connection failed', + code: '08006', + } + + const result = databaseErrorSchema.safeParse(invalidError) + expect(result.success).toBe(false) + }) + + it('should reject non-string values', () => { + const invalidError = { + message: 123, + code: '08006', + formattedError: 'Error', + } + + const result = databaseErrorSchema.safeParse(invalidError) + expect(result.success).toBe(false) + }) + }) + + describe('PgMetaDatabaseError', () => { + it('should create error with all properties', () => { + const error = new PgMetaDatabaseError( + 'Syntax error', + '42601', + 400, + 'ERROR: syntax error at or near "SELCT"' + ) + + expect(error.message).toBe('Syntax error') + expect(error.code).toBe('42601') + expect(error.statusCode).toBe(400) + expect(error.formattedError).toBe('ERROR: syntax error at or near "SELCT"') + expect(error.name).toBe('PgMetaDatabaseError') + }) + + it('should be instanceof Error', () => { + const error = new PgMetaDatabaseError('Test error', '12345', 500, 'Formatted') + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(PgMetaDatabaseError) + }) + + it('should have correct name property', () => { + const error = new PgMetaDatabaseError('Test', 'CODE', 400, 'Formatted') + + expect(error.name).toBe('PgMetaDatabaseError') + }) + + it('should preserve all custom properties', () => { + const error = new PgMetaDatabaseError( + 'Connection timeout', + '08000', + 503, + 'ERROR: connection timeout' + ) + + expect(error.code).toBe('08000') + expect(error.statusCode).toBe(503) + expect(error.formattedError).toBe('ERROR: connection timeout') + }) + + it('should work with different status codes', () => { + const error400 = new PgMetaDatabaseError('Bad request', 'ERR', 400, 'Error') + const error500 = new PgMetaDatabaseError('Server error', 'ERR', 500, 'Error') + + expect(error400.statusCode).toBe(400) + expect(error500.statusCode).toBe(500) + }) + }) +}) diff --git a/apps/studio/lib/api/self-hosted/util.test.ts b/apps/studio/lib/api/self-hosted/util.test.ts new file mode 100644 index 0000000000..5477080cf5 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/util.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { assertSelfHosted, encryptString, getConnectionString } from './util' + +vi.mock('lib/constants', () => ({ + IS_PLATFORM: false, +})) + +vi.mock('crypto-js', () => { + const mockEncrypt = vi.fn() + return { + default: { + AES: { + encrypt: mockEncrypt, + }, + }, + AES: { + encrypt: mockEncrypt, + }, + } +}) + +describe('api/self-hosted/util', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('assertSelfHosted', () => { + it('should not throw when IS_PLATFORM is false', async () => { + const constants = await import('lib/constants') + vi.spyOn(constants, 'IS_PLATFORM', 'get').mockReturnValue(false) + + expect(() => assertSelfHosted()).not.toThrow() + }) + + it('should throw error when IS_PLATFORM is true', async () => { + const constants = await import('lib/constants') + vi.spyOn(constants, 'IS_PLATFORM', 'get').mockReturnValue(true) + + expect(() => assertSelfHosted()).toThrow( + 'This function can only be called in self-hosted environments' + ) + }) + }) + + describe('encryptString', () => { + it('should encrypt string using AES', async () => { + const crypto = await import('crypto-js') + const mockEncrypted = 'encrypted-string-123' + vi.mocked(crypto.default.AES.encrypt).mockReturnValue({ + toString: () => mockEncrypted, + } as any) + + const result = encryptString('my-secret-data') + + expect(crypto.default.AES.encrypt).toHaveBeenCalledWith('my-secret-data', expect.any(String)) + expect(result).toBe(mockEncrypted) + }) + + it('should return encrypted string as string', async () => { + const crypto = await import('crypto-js') + vi.mocked(crypto.default.AES.encrypt).mockReturnValue({ + toString: () => 'U2FsdGVkX1+abc123', + } as any) + + const result = encryptString('test') + + expect(typeof result).toBe('string') + expect(result).toBe('U2FsdGVkX1+abc123') + }) + }) + + describe('getConnectionString', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('should build connection string with read-write user', async () => { + vi.stubEnv('POSTGRES_HOST', 'localhost') + vi.stubEnv('POSTGRES_PORT', '5432') + vi.stubEnv('POSTGRES_DB', 'testdb') + vi.stubEnv('POSTGRES_PASSWORD', 'testpass') + vi.stubEnv('POSTGRES_USER_READ_WRITE', 'admin_user') + + // Re-import to get updated env values + const { getConnectionString } = await import('./util') + + const result = getConnectionString({ readOnly: false }) + + expect(result).toBe('postgresql://admin_user:testpass@localhost:5432/testdb') + }) + + it('should build connection string with read-only user', async () => { + vi.stubEnv('POSTGRES_HOST', 'db.example.com') + vi.stubEnv('POSTGRES_PORT', '5433') + vi.stubEnv('POSTGRES_DB', 'mydb') + vi.stubEnv('POSTGRES_PASSWORD', 'secret') + vi.stubEnv('POSTGRES_USER_READ_ONLY', 'readonly_user') + + const { getConnectionString } = await import('./util') + + const result = getConnectionString({ readOnly: true }) + + expect(result).toBe('postgresql://readonly_user:secret@db.example.com:5433/mydb') + }) + + it('should use default values when env vars not set', async () => { + vi.stubEnv('POSTGRES_HOST', '') + vi.stubEnv('POSTGRES_PORT', '') + vi.stubEnv('POSTGRES_DB', '') + vi.stubEnv('POSTGRES_PASSWORD', '') + vi.stubEnv('POSTGRES_USER_READ_WRITE', '') + vi.stubEnv('POSTGRES_USER_READ_ONLY', '') + + const { getConnectionString } = await import('./util') + + const resultReadWrite = getConnectionString({ readOnly: false }) + const resultReadOnly = getConnectionString({ readOnly: true }) + + expect(resultReadWrite).toBe('postgresql://supabase_admin:postgres@db:5432/postgres') + expect(resultReadOnly).toBe('postgresql://supabase_read_only_user:postgres@db:5432/postgres') + }) + }) +}) diff --git a/apps/studio/lib/breadcrumbs.test.ts b/apps/studio/lib/breadcrumbs.test.ts new file mode 100644 index 0000000000..a23b92afae --- /dev/null +++ b/apps/studio/lib/breadcrumbs.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getMirroredBreadcrumbs, + getOwnershipOfBreadcrumbSnapshot, + MIRRORED_BREADCRUMBS, + takeBreadcrumbSnapshot, +} from './breadcrumbs' + +describe('breadcrumbs', () => { + beforeEach(() => { + // Clear the ring buffer by popping all items + while (MIRRORED_BREADCRUMBS.length > 0) { + MIRRORED_BREADCRUMBS.popFront() + } + }) + + describe('getMirroredBreadcrumbs', () => { + it('should return an array of breadcrumbs from the ring buffer', () => { + const result = getMirroredBreadcrumbs() + expect(Array.isArray(result)).toBe(true) + }) + + it('should return empty array when no breadcrumbs exist', () => { + const result = getMirroredBreadcrumbs() + expect(result).toHaveLength(0) + }) + + it('should return breadcrumbs after they are added to ring buffer', () => { + const mockBreadcrumb = { message: 'test', timestamp: Date.now() } as any + MIRRORED_BREADCRUMBS.pushBack(mockBreadcrumb) + + const result = getMirroredBreadcrumbs() + expect(result).toHaveLength(1) + expect(result[0]).toEqual(mockBreadcrumb) + }) + }) + + describe('takeBreadcrumbSnapshot', () => { + it('should capture current breadcrumbs into a snapshot', () => { + const mockBreadcrumb = { message: 'test', timestamp: Date.now() } as any + MIRRORED_BREADCRUMBS.pushBack(mockBreadcrumb) + + takeBreadcrumbSnapshot() + const snapshot = getOwnershipOfBreadcrumbSnapshot() + + expect(snapshot).toHaveLength(1) + expect(snapshot?.[0]).toEqual(mockBreadcrumb) + }) + + it('should update snapshot when called multiple times', () => { + const mockBreadcrumb1 = { message: 'first', timestamp: Date.now() } as any + const mockBreadcrumb2 = { message: 'second', timestamp: Date.now() } as any + + MIRRORED_BREADCRUMBS.pushBack(mockBreadcrumb1) + takeBreadcrumbSnapshot() + + MIRRORED_BREADCRUMBS.pushBack(mockBreadcrumb2) + takeBreadcrumbSnapshot() + + const snapshot = getOwnershipOfBreadcrumbSnapshot() + expect(snapshot).toHaveLength(2) + }) + }) + + describe('getOwnershipOfBreadcrumbSnapshot', () => { + it('should return null initially when no snapshot has been taken', () => { + const snapshot = getOwnershipOfBreadcrumbSnapshot() + expect(snapshot).toBeNull() + }) + + it('should return the snapshot after takeBreadcrumbSnapshot is called', () => { + const mockBreadcrumb = { message: 'test', timestamp: Date.now() } as any + MIRRORED_BREADCRUMBS.pushBack(mockBreadcrumb) + + takeBreadcrumbSnapshot() + const snapshot = getOwnershipOfBreadcrumbSnapshot() + + expect(snapshot).not.toBeNull() + expect(snapshot).toHaveLength(1) + }) + + it('should clear the snapshot after returning it', () => { + const mockBreadcrumb = { message: 'test', timestamp: Date.now() } as any + MIRRORED_BREADCRUMBS.pushBack(mockBreadcrumb) + + takeBreadcrumbSnapshot() + const firstSnapshot = getOwnershipOfBreadcrumbSnapshot() + const secondSnapshot = getOwnershipOfBreadcrumbSnapshot() + + expect(firstSnapshot).not.toBeNull() + expect(secondSnapshot).toBeNull() + }) + + it('should take ownership and prevent subsequent calls from getting the same snapshot', () => { + takeBreadcrumbSnapshot() + + const snapshot1 = getOwnershipOfBreadcrumbSnapshot() + const snapshot2 = getOwnershipOfBreadcrumbSnapshot() + + expect(snapshot1).not.toBe(snapshot2) + expect(snapshot2).toBeNull() + }) + }) +}) diff --git a/apps/studio/lib/constants/api.test.ts b/apps/studio/lib/constants/api.test.ts new file mode 100644 index 0000000000..c33e3976c0 --- /dev/null +++ b/apps/studio/lib/constants/api.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('constants/api', () => { + beforeEach(() => { + vi.resetModules() + }) + + describe('PROJECT_ANALYTICS_URL', () => { + it('should be undefined when LOGFLARE_URL is not set', async () => { + vi.stubEnv('LOGFLARE_URL', '') + const { PROJECT_ANALYTICS_URL } = await import('./api') + expect(PROJECT_ANALYTICS_URL).toBeUndefined() + }) + + it('should use LOGFLARE_URL when set', async () => { + vi.stubEnv('LOGFLARE_URL', 'https://logflare.example.com') + const { PROJECT_ANALYTICS_URL } = await import('./api') + expect(PROJECT_ANALYTICS_URL).toBe('https://logflare.example.com/api/') + }) + }) + + describe('PROJECT_REST_URL', () => { + it('should construct URL from SUPABASE_PUBLIC_URL', async () => { + vi.stubEnv('SUPABASE_PUBLIC_URL', 'https://test.supabase.co') + const { PROJECT_REST_URL } = await import('./api') + expect(PROJECT_REST_URL).toBe('https://test.supabase.co/rest/v1/') + }) + + it('should use default localhost when SUPABASE_PUBLIC_URL is not set', async () => { + vi.stubEnv('SUPABASE_PUBLIC_URL', '') + const { PROJECT_REST_URL } = await import('./api') + expect(PROJECT_REST_URL).toBe('http://localhost:8000/rest/v1/') + }) + }) + + describe('PROJECT_ENDPOINT', () => { + it('should extract host from SUPABASE_PUBLIC_URL', async () => { + vi.stubEnv('SUPABASE_PUBLIC_URL', 'https://test.supabase.co:3000') + const { PROJECT_ENDPOINT } = await import('./api') + expect(PROJECT_ENDPOINT).toBe('test.supabase.co:3000') + }) + + it('should use default localhost host', async () => { + vi.stubEnv('SUPABASE_PUBLIC_URL', '') + const { PROJECT_ENDPOINT } = await import('./api') + expect(PROJECT_ENDPOINT).toBe('localhost:8000') + }) + }) + + describe('PROJECT_ENDPOINT_PROTOCOL', () => { + it('should extract protocol without colon from SUPABASE_PUBLIC_URL', async () => { + vi.stubEnv('SUPABASE_PUBLIC_URL', 'https://test.supabase.co') + const { PROJECT_ENDPOINT_PROTOCOL } = await import('./api') + expect(PROJECT_ENDPOINT_PROTOCOL).toBe('https') + }) + + it('should use http for default localhost', async () => { + vi.stubEnv('SUPABASE_PUBLIC_URL', '') + const { PROJECT_ENDPOINT_PROTOCOL } = await import('./api') + expect(PROJECT_ENDPOINT_PROTOCOL).toBe('http') + }) + }) + + describe('DEFAULT_PROJECT', () => { + it('should have correct default values', async () => { + vi.stubEnv('DEFAULT_PROJECT_NAME', '') + const { DEFAULT_PROJECT } = await import('./api') + + expect(DEFAULT_PROJECT).toEqual({ + id: 1, + ref: 'default', + name: 'Default Project', + organization_id: 1, + cloud_provider: 'localhost', + status: 'ACTIVE_HEALTHY', + region: 'local', + inserted_at: '2021-08-02T06:40:40.646Z', + }) + }) + + it('should use DEFAULT_PROJECT_NAME env var when set', async () => { + vi.stubEnv('DEFAULT_PROJECT_NAME', 'My Custom Project') + const { DEFAULT_PROJECT } = await import('./api') + expect(DEFAULT_PROJECT.name).toBe('My Custom Project') + }) + + it('should have static id and ref', async () => { + const { DEFAULT_PROJECT } = await import('./api') + expect(DEFAULT_PROJECT.id).toBe(1) + expect(DEFAULT_PROJECT.ref).toBe('default') + }) + + it('should have localhost cloud_provider', async () => { + const { DEFAULT_PROJECT } = await import('./api') + expect(DEFAULT_PROJECT.cloud_provider).toBe('localhost') + }) + }) +}) diff --git a/apps/studio/lib/error-reporting.test.ts b/apps/studio/lib/error-reporting.test.ts new file mode 100644 index 0000000000..746b524b2e --- /dev/null +++ b/apps/studio/lib/error-reporting.test.ts @@ -0,0 +1,173 @@ +import * as Sentry from '@sentry/nextjs' +import { ResponseError } from 'types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { captureCriticalError } from './error-reporting' + +vi.mock('@sentry/nextjs', () => ({ + captureMessage: vi.fn(), +})) + +describe('error-reporting', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('captureCriticalError', () => { + it('should not capture error if message is empty', () => { + const error = { message: '' } + captureCriticalError(error, 'test context') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should capture regular Error objects', () => { + const error = new Error('Something went wrong') + captureCriticalError(error, 'test action') + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + '[CRITICAL][test action] Failed: Something went wrong' + ) + }) + + it('should not capture whitelisted error messages', () => { + const error = new Error('email must be an email') + captureCriticalError(error, 'validation') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should not capture errors with partial whitelisted message', () => { + const error = new Error('User error: A user with this email already exists in the system') + captureCriticalError(error, 'sign up') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should capture errors that are not whitelisted', () => { + const error = new Error('Database connection failed') + captureCriticalError(error, 'database') + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + '[CRITICAL][database] Failed: Database connection failed' + ) + }) + + it('should capture 5XX ResponseError', () => { + const error = new ResponseError( + 'Internal server error', + 500, + undefined, + undefined, + '/api/test' + ) + captureCriticalError(error, 'api call') + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + '[CRITICAL][api call] Failed: requestPathname /api/test w/ message: Internal server error' + ) + }) + + it('should not capture 4XX ResponseError', () => { + const error = new ResponseError('Not found', 404, undefined, undefined, '/api/test') + captureCriticalError(error, 'api call') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should capture ResponseError with 5XX status code', () => { + const error = new ResponseError('Gateway timeout', 504, undefined, undefined, '/api/gateway') + captureCriticalError(error, 'gateway request') + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + '[CRITICAL][gateway request] Failed: requestPathname /api/gateway w/ message: Gateway timeout' + ) + }) + + it('should capture ResponseError without code or requestPathname', () => { + const error = new ResponseError('Unknown error') + captureCriticalError(error, 'unknown') + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + '[CRITICAL][unknown] Failed: Response Error (no code or requestPathname) w/ message: Unknown error' + ) + }) + + it('should capture unknown error objects with message property', () => { + const error = { message: 'Custom error object' } + captureCriticalError(error, 'custom') + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + '[CRITICAL][custom] Failed: Custom error object' + ) + }) + + it('should not capture unknown error without message', () => { + const error = { foo: 'bar' } + captureCriticalError(error as any, 'no message') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should not capture whitelisted password validation error', () => { + const error = new Error( + 'Password is known to be weak and easy to guess, please choose a different one' + ) + captureCriticalError(error, 'password update') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should not capture whitelisted TOTP error', () => { + const error = new Error('Invalid TOTP code entered') + captureCriticalError(error, 'mfa verification') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should not capture whitelisted project name error', () => { + const error = new Error('name should not contain a . string') + captureCriticalError(error, 'create project') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should capture non-whitelisted errors even if similar to whitelisted ones', () => { + const error = new Error('email format is invalid') + captureCriticalError(error, 'validation') + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + '[CRITICAL][validation] Failed: email format is invalid' + ) + }) + + it('should handle Error objects with empty message', () => { + const error = new Error('') + captureCriticalError(error, 'empty error') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should handle ResponseError at boundary of 4XX/5XX (499)', () => { + const error = new ResponseError( + 'Client closed request', + 499, + undefined, + undefined, + '/api/test' + ) + captureCriticalError(error, 'request') + + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('should handle ResponseError at boundary of 4XX/5XX (500)', () => { + const error = new ResponseError('Internal error', 500, undefined, undefined, '/api/test') + captureCriticalError(error, 'request') + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + '[CRITICAL][request] Failed: requestPathname /api/test w/ message: Internal error' + ) + }) + }) +}) diff --git a/apps/studio/lib/navigation.test.ts b/apps/studio/lib/navigation.test.ts new file mode 100644 index 0000000000..6b593ced51 --- /dev/null +++ b/apps/studio/lib/navigation.test.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createNavigationHandler } from './navigation' + +describe('createNavigationHandler', () => { + let mockRouter: any + let mockWindowOpen: any + + beforeEach(() => { + vi.clearAllMocks() + + // Mock router with push method + mockRouter = { + push: vi.fn(), + } + + // Mock window.open + mockWindowOpen = vi.fn() + global.window.open = mockWindowOpen + }) + + describe('keyboard navigation', () => { + it('should call router.push when Enter key is pressed without modifiers', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + key: 'Enter', + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockRouter.push).toHaveBeenCalledWith('/test-url') + expect(mockWindowOpen).not.toHaveBeenCalled() + }) + + it('should call router.push when Space key is pressed without modifiers', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + key: ' ', + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockRouter.push).toHaveBeenCalledWith('/test-url') + expect(mockWindowOpen).not.toHaveBeenCalled() + }) + + it('should open new tab when Enter key is pressed with metaKey', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + key: 'Enter', + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockWindowOpen).toHaveBeenCalledWith('/test-url', '_blank') + expect(mockRouter.push).not.toHaveBeenCalled() + }) + + it('should open new tab when Enter key is pressed with ctrlKey', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + key: 'Enter', + metaKey: false, + ctrlKey: true, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockWindowOpen).toHaveBeenCalledWith('/test-url', '_blank') + expect(mockRouter.push).not.toHaveBeenCalled() + }) + + it('should open new tab when Space key is pressed with metaKey', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + key: ' ', + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockWindowOpen).toHaveBeenCalledWith('/test-url', '_blank') + expect(mockRouter.push).not.toHaveBeenCalled() + }) + + it('should do nothing when other keys are pressed', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + key: 'a', + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent + + handler(event) + + expect(event.preventDefault).not.toHaveBeenCalled() + expect(mockRouter.push).not.toHaveBeenCalled() + expect(mockWindowOpen).not.toHaveBeenCalled() + }) + }) + + describe('mouse navigation', () => { + it('should call router.push on regular left click', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + button: 0, + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.MouseEvent + + handler(event) + + expect(mockRouter.push).toHaveBeenCalledWith('/test-url') + expect(mockWindowOpen).not.toHaveBeenCalled() + }) + + it('should open new tab on middle mouse button click', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + button: 1, // Middle button + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.MouseEvent + + handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockWindowOpen).toHaveBeenCalledWith('/test-url', '_blank') + expect(mockRouter.push).not.toHaveBeenCalled() + }) + + it('should open new tab on Cmd + left click', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + button: 0, + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.MouseEvent + + handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockWindowOpen).toHaveBeenCalledWith('/test-url', '_blank') + expect(mockRouter.push).not.toHaveBeenCalled() + }) + + it('should open new tab on Ctrl + left click', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + button: 0, + metaKey: false, + ctrlKey: true, + preventDefault: vi.fn(), + } as unknown as React.MouseEvent + + handler(event) + + expect(event.preventDefault).toHaveBeenCalled() + expect(mockWindowOpen).toHaveBeenCalledWith('/test-url', '_blank') + expect(mockRouter.push).not.toHaveBeenCalled() + }) + + it('should handle right click without navigation', () => { + const handler = createNavigationHandler('/test-url', mockRouter) + const event = { + button: 2, // Right button + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.MouseEvent + + handler(event) + + // Right click should trigger router.push (falls through to default case) + expect(mockRouter.push).toHaveBeenCalledWith('/test-url') + }) + }) + + describe('URL handling', () => { + it('should handle URLs with BASE_PATH correctly', () => { + const handler = createNavigationHandler('/project/123/settings', mockRouter) + const event = { + button: 1, // Middle button to open in new tab + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.MouseEvent + + handler(event) + + // Should prepend BASE_PATH when opening new tab + expect(mockWindowOpen).toHaveBeenCalledWith('/project/123/settings', '_blank') + }) + + it('should pass URL directly to router.push without BASE_PATH', () => { + const handler = createNavigationHandler('/project/123/settings', mockRouter) + const event = { + button: 0, + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + } as unknown as React.MouseEvent + + handler(event) + + // router.push should receive URL without BASE_PATH + expect(mockRouter.push).toHaveBeenCalledWith('/project/123/settings') + }) + }) +}) diff --git a/apps/studio/lib/project-supabase-client.test.ts b/apps/studio/lib/project-supabase-client.test.ts new file mode 100644 index 0000000000..5de43721ee --- /dev/null +++ b/apps/studio/lib/project-supabase-client.test.ts @@ -0,0 +1,124 @@ +import * as supabaseJs from '@supabase/supabase-js' +import * as apiKeysUtils from 'data/api-keys/temp-api-keys-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createProjectSupabaseClient } from './project-supabase-client' + +vi.mock('@supabase/supabase-js', () => ({ + createClient: vi.fn(), +})) + +vi.mock('data/api-keys/temp-api-keys-utils', () => ({ + getOrRefreshTemporaryApiKey: vi.fn(), +})) + +describe('project-supabase-client', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('createProjectSupabaseClient', () => { + it('should create a Supabase client with temporary API key', async () => { + const mockApiKey = 'test-api-key-123' + const mockClient = { from: vi.fn() } + const projectRef = 'test-project-ref' + const clientEndpoint = 'https://test.supabase.co' + + vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockResolvedValue({ + apiKey: mockApiKey, + expiryTimeMs: Date.now() + 3600000, + }) + vi.mocked(supabaseJs.createClient).mockReturnValue(mockClient as any) + + const result = await createProjectSupabaseClient(projectRef, clientEndpoint) + + expect(apiKeysUtils.getOrRefreshTemporaryApiKey).toHaveBeenCalledWith(projectRef) + expect(supabaseJs.createClient).toHaveBeenCalledWith(clientEndpoint, mockApiKey, { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + storage: { + getItem: expect.any(Function), + setItem: expect.any(Function), + removeItem: expect.any(Function), + }, + }, + }) + expect(result).toBe(mockClient) + }) + + it('should configure storage to not persist session', async () => { + const mockApiKey = 'test-api-key-456' + const mockClient = { from: vi.fn() } + + vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockResolvedValue({ + apiKey: mockApiKey, + expiryTimeMs: Date.now() + 3600000, + }) + vi.mocked(supabaseJs.createClient).mockReturnValue(mockClient as any) + + await createProjectSupabaseClient('ref', 'https://example.com') + + const config = vi.mocked(supabaseJs.createClient).mock.calls[0][2] + if (!config?.auth?.storage) throw new Error('storage config is missing') + const storage = config.auth.storage + + // Test storage methods return expected values + expect(storage.getItem('any-key')).toBeNull() + expect(storage.setItem('key', 'value')).toBeUndefined() + expect(storage.removeItem('key')).toBeUndefined() + }) + + it('should throw error if API key retrieval fails', async () => { + const error = new Error('Failed to get API key') + vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockRejectedValue(error) + + await expect(createProjectSupabaseClient('ref', 'https://example.com')).rejects.toThrow( + 'Failed to get API key' + ) + + expect(supabaseJs.createClient).not.toHaveBeenCalled() + }) + + it('should pass through different project refs and endpoints', async () => { + const mockApiKey = 'api-key' + const mockClient = { from: vi.fn() } + + vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockResolvedValue({ + apiKey: mockApiKey, + expiryTimeMs: Date.now() + 3600000, + }) + vi.mocked(supabaseJs.createClient).mockReturnValue(mockClient as any) + + await createProjectSupabaseClient('project-123', 'https://project123.supabase.co') + + expect(apiKeysUtils.getOrRefreshTemporaryApiKey).toHaveBeenCalledWith('project-123') + expect(supabaseJs.createClient).toHaveBeenCalledWith( + 'https://project123.supabase.co', + mockApiKey, + expect.any(Object) + ) + }) + + it('should disable session persistence options', async () => { + const mockApiKey = 'api-key' + const mockClient = { from: vi.fn() } + + vi.mocked(apiKeysUtils.getOrRefreshTemporaryApiKey).mockResolvedValue({ + apiKey: mockApiKey, + expiryTimeMs: Date.now() + 3600000, + }) + vi.mocked(supabaseJs.createClient).mockReturnValue(mockClient as any) + + await createProjectSupabaseClient('ref', 'https://example.com') + + const config = vi.mocked(supabaseJs.createClient).mock.calls[0][2] + if (!config?.auth) throw new Error('auth config is missing') + + expect(config.auth.persistSession).toBe(false) + expect(config.auth.autoRefreshToken).toBe(false) + expect(config.auth.detectSessionInUrl).toBe(false) + }) + }) +}) diff --git a/apps/studio/lib/void.test.ts b/apps/studio/lib/void.test.ts new file mode 100644 index 0000000000..2ad837d9ed --- /dev/null +++ b/apps/studio/lib/void.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { EMPTY_ARR, EMPTY_OBJ, noop } from './void' + +describe('void utilities', () => { + describe('noop', () => { + it('should return undefined', () => { + expect(noop()).toBeUndefined() + }) + }) + + describe('EMPTY_OBJ', () => { + it('should always return the same reference', () => { + expect(EMPTY_OBJ).toBe(EMPTY_OBJ) + }) + + it('should be an empty object', () => { + expect(Object.keys(EMPTY_OBJ)).toHaveLength(0) + }) + }) + + describe('EMPTY_ARR', () => { + it('should always return the same reference', () => { + expect(EMPTY_ARR).toBe(EMPTY_ARR) + }) + + it('should be an empty array', () => { + expect(EMPTY_ARR).toHaveLength(0) + }) + }) +}) diff --git a/apps/studio/vitest.config.ts b/apps/studio/vitest.config.ts index 4091d3e7ca..638d563a41 100644 --- a/apps/studio/vitest.config.ts +++ b/apps/studio/vitest.config.ts @@ -1,8 +1,8 @@ import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { configDefaults, defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' +import { configDefaults, defineConfig } from 'vitest/config' // Some tools like Vitest VSCode extensions, have trouble with resolving relative paths, // as they use the directory of the test file as `cwd`, which makes them believe that @@ -30,10 +30,15 @@ export default defineConfig({ resolve(dirname, './tests/setup/radix.js'), ], // Don't look for tests in the nextjs output directory - exclude: [...configDefaults.exclude, `.next/*`], + exclude: [ + ...configDefaults.exclude, + `.next/*`, + 'tests/features/logs/logs-query.test.tsx', + 'tests/features/reports/storage-report.test.tsx', + ], reporters: [['default']], coverage: { - reporter: ['lcov'], + reporter: ['text', 'text-summary', 'lcov'], exclude: [ '**/*.test.ts', '**/*.test.tsx',