Add Open AI Comatible API option in model configuration

Add unit tests for various UI components and context hooks

- Created tests for Progress, RadioGroup, Select, Separator, Sheet, Sidebar, Skeleton, Sonner, Switch, Table, Tabs, Textarea, Tooltip components.
- Implemented tests for UserConfigContext and other context hooks.
- Added utility tests for filters and utils functions.
- Set up MSW for API mocking in tests.
- Configured Vitest for testing environment with necessary setup.
This commit is contained in:
Chris Briddock 2026-05-25 19:26:41 +01:00
parent bbb4f91a27
commit 20617db37a
No known key found for this signature in database
44 changed files with 8426 additions and 5661 deletions

View file

@ -0,0 +1,22 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View file

@ -181,30 +181,58 @@ class UserConfigurationValidator:
api_key = service_config.api_key
try:
if not self._check_api_key(provider, api_key):
if not self._check_api_key(provider, api_key, service_config):
return [
{"model": service_name, "message": f"Invalid {provider} API key"}
{
"model": service_name,
"message": (
f"Invalid {provider} API key. Please verify your API key is "
f"correct, has not expired, and has the required permissions."
),
}
]
except ValueError as e:
return [{"model": service_name, "message": str(e)}]
return []
def _check_api_key(self, provider: str, api_key: str) -> bool:
def _check_api_key(
self, provider: str, api_key: str, service_config: Optional[ServiceConfig] = None
) -> bool:
"""Check if an API key for a provider is valid."""
validator = self._validator_map.get(provider)
if not validator:
return False
if provider in (
ServiceProviders.OPENAI.value,
ServiceProviders.OPENAI_REALTIME.value,
):
return validator(provider, api_key, service_config)
return validator(provider, api_key)
def _check_openai_api_key(self, model: str, api_key: str) -> bool:
client = openai.OpenAI(api_key=api_key)
def _check_openai_api_key(
self, model: str, api_key: str, service_config: Optional[ServiceConfig] = None
) -> bool:
client_kwargs: dict[str, str] = {"api_key": api_key}
base_url = getattr(service_config, "base_url", None) if service_config else None
if base_url:
client_kwargs["base_url"] = base_url
client = openai.OpenAI(**client_kwargs)
try:
client.models.list()
return True
except openai.AuthenticationError:
return False
if base_url and "openai.com" not in base_url:
raise ValueError(
f"Invalid OpenAI API key. The key was rejected by the API at {base_url}. "
"Please check that your API key is correct and has not been revoked."
)
raise ValueError(
"Invalid OpenAI API key. The key was rejected by the OpenAI API. "
"Please check that your API key is correct and has not been revoked. "
"You can verify your keys at https://platform.openai.com/api-keys."
)
def _check_deepgram_api_key(self, model: str, api_key: str) -> bool:
try:
@ -212,7 +240,11 @@ class UserConfigurationValidator:
deepgram.manage.v1.projects.list()
return True
except Exception:
return False
raise ValueError(
"Invalid Deepgram API key. The key was rejected by the Deepgram API. "
"Please check that your API key is correct and active. "
"You can verify your keys at https://console.deepgram.com/."
)
def _check_groq_api_key(self, model: str, api_key: str) -> bool:
client = Groq(api_key=api_key)
@ -220,7 +252,11 @@ class UserConfigurationValidator:
client.models.list()
return True
except Exception:
return False
raise ValueError(
"Invalid Groq API key. The key was rejected by the Groq API. "
"Please check that your API key is correct and active. "
"You can verify your keys at https://console.groq.com/keys."
)
def _validate_elevenlabs_api_key(self, model: str, api_key: str) -> bool:
return True

View file

@ -290,6 +290,10 @@ class OpenAILLMService(BaseLLMConfiguration):
description="OpenAI chat model to use.",
json_schema_extra={"examples": OPENAI_MODELS, "allow_custom_input": True},
)
base_url: str = Field(
default="https://api.openai.com/v1",
description="Override only if using an OpenAI-compatible API (e.g. local LLM, proxy).",
)
@register_llm

View file

@ -504,6 +504,9 @@ def create_llm_service_from_provider(
"""
logger.info(f"Creating LLM service: provider={provider}, model={model}")
if provider == ServiceProviders.OPENAI.value:
kwargs = {}
if base_url:
kwargs["base_url"] = base_url
if "gpt-5" in model:
return OpenAILLMService(
api_key=api_key,
@ -511,10 +514,12 @@ def create_llm_service_from_provider(
model=model,
extra={"reasoning_effort": "minimal", "verbosity": "low"},
),
**kwargs,
)
return OpenAILLMService(
api_key=api_key,
settings=OpenAILLMSettings(model=model, temperature=0.1),
**kwargs,
)
elif provider == ServiceProviders.GROQ.value:
return GroqLLMService(
@ -709,7 +714,9 @@ def create_llm_service(user_config):
api_key = user_config.llm.api_key
kwargs = {}
if provider == ServiceProviders.OPENROUTER.value:
if provider == ServiceProviders.OPENAI.value:
kwargs["base_url"] = user_config.llm.base_url
elif provider == ServiceProviders.OPENROUTER.value:
kwargs["base_url"] = user_config.llm.base_url
elif provider == ServiceProviders.AZURE.value:
kwargs["endpoint"] = user_config.llm.endpoint

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "dograh",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -0,0 +1,589 @@
# Frontend Testing Plan — Dograh UI
## Overview
The Dograh frontend is a Next.js 15 + React 19 + TypeScript application using:
- **UI**: shadcn/ui + Radix UI primitives + Tailwind CSS
- **State**: React Context (global), Zustand (workflow builder), React Hook Form (forms)
- **API**: Auto-generated OpenAPI client (`@hey-api/openapi-ts`)
- **Auth**: Stack Auth (`@stackframe/stack`)
- **Charts**: Recharts
- **Flow**: React Flow (`@xyflow/react`)
Current test coverage: **Zero** — no test framework, no test files, no CI test step.
---
## Phase 1: Tooling Setup
### 1.1 Install Test Dependencies
```bash
cd ui
npm install -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom \
@testing-library/user-event @testing-library/dom jsdom msw@latest \
@playwright/test @types/jest
```
**Rationale**:
- **Vitest**: Fast, Vite-native, excellent TypeScript support, replaces Jest
- **@testing-library/react**: Render components, fire events, query DOM
- **@testing-library/jest-dom**: Custom matchers (`toBeInTheDocument`, `toHaveValue`)
- **jsdom**: Browser-like environment for unit tests
- **MSW (Mock Service Worker)**: Intercept and mock HTTP requests at the network layer
- **Playwright**: E2E testing with real browser automation
### 1.2 Configuration Files
**`ui/vitest.config.ts`**:
```typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'src/client/',
'**/*.d.ts',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
```
**`ui/src/test/setup.ts`**:
```typescript
import '@testing-library/jest-dom';
import { server } from './mocks/server';
// Start MSW before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Close MSW after all tests
afterAll(() => server.close());
```
**`ui/src/test/mocks/server.ts`**:
```typescript
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
```
**`ui/src/test/mocks/handlers.ts`**:
```typescript
import { http, HttpResponse } from 'msw';
export const handlers = [
// Auth
http.get('/api/auth/session', () => {
return HttpResponse.json({ user: { id: '1', email: 'test@example.com' } });
}),
// User config
http.get('/api/v1/user/configurations/user', () => {
return HttpResponse.json({
llm: { provider: 'openai', model: 'gpt-4.1', base_url: 'https://api.openai.com/v1' },
tts: { provider: 'openai', model: 'gpt-4o-mini-tts' },
stt: { provider: 'deepgram', model: 'nova-2' },
});
}),
http.put('/api/v1/user/configurations/user', async () => {
return HttpResponse.json({ success: true });
}),
// Workflows
http.get('/api/v1/workflows', () => {
return HttpResponse.json({ items: [], total: 0 });
}),
// Add more handlers as needed
];
```
**`ui/src/test/test-utils.tsx`** — Custom render with providers:
```typescript
import { render as rtlRender, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
import { UserConfigProvider } from '@/context/UserConfigContext';
import { AppConfigProvider } from '@/context/AppConfigContext';
function AllProviders({ children }: { children: ReactNode }) {
return (
<AppConfigProvider>
<UserConfigProvider>
{children}
</UserConfigProvider>
</AppConfigProvider>
);
}
export function render(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return rtlRender(ui, { wrapper: AllProviders, ...options });
}
export * from '@testing-library/react';
```
**`ui/playwright.config.ts`**:
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
```
### 1.3 Update package.json Scripts
```json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
```
---
## Phase 2: Unit Tests
### 2.1 UI Primitive Components (`src/components/ui/`)
These are thin wrappers around Radix UI. Test focus: correct props forwarding, accessibility, styling.
| Component | Test File | Key Assertions |
|-----------|-----------|----------------|
| `Button` | `button.test.tsx` | Renders children, handles click, disabled state, variants |
| `Input` | `input.test.tsx` | Accepts value, onChange, placeholder, disabled |
| `Select` | `select.test.tsx` | Opens dropdown, selects option, displays value |
| `Dialog` | `dialog.test.tsx` | Opens/closes, renders title, focus trap, ESC to close |
| `Checkbox` | `checkbox.test.tsx` | Checked/unchecked state, onChange fires |
| `Tabs` | `tabs.test.tsx` | Switches panels, active tab indicator |
| `Switch` | `switch.test.tsx` | Toggles on click, respects checked prop |
| `Badge` | `badge.test.tsx` | Renders text, applies variant classes |
| `Card` | `card.test.tsx` | Renders header, content, footer slots |
| `Table` | `table.test.tsx` | Renders rows/columns, sortable headers |
| `Textarea` | `textarea.test.tsx` | Multi-line input, resize behavior |
| `Calendar` | `calendar.test.tsx` | Date selection, month navigation |
| `DropdownMenu` | `dropdown-menu.test.tsx` | Opens on click, item selection, keyboard nav |
| `Popover` | `popover.test.tsx` | Opens/closes, positions content |
| `Tooltip` | `tooltip.test.tsx` | Shows on hover, hides on leave |
| `RadioGroup` | `radio-group.test.tsx` | Single selection, keyboard navigation |
| `Progress` | `progress.test.tsx` | Renders correct width for value |
| `Separator` | `separator.test.tsx` | Renders horizontal/vertical |
| `Skeleton` | `skeleton.test.tsx` | Renders with pulse animation class |
| `Sonner` | `sonner.test.tsx` | Toast appears, auto-dismisses |
| `Sheet` | `sheet.test.tsx` | Slides in from side, closes on overlay click |
| `Sidebar` | `sidebar.test.tsx` | Expands/collapses, shows/hides items |
| `AlertDialog` | `alert-dialog.test.tsx` | Confirm/cancel actions, focus management |
| `Collapsible` | `collapsible.test.tsx` | Expands/collapses content |
| `ChoiceChips` | `choice-chips.test.tsx` | Single/multi selection, removable chips |
| `JSONEditor` | `json-editor.test.tsx` | Validates JSON, shows error state |
**Example — `button.test.tsx`**:
```typescript
import { render, screen, fireEvent } from '@/test/test-utils';
import { Button } from './button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies variant classes', () => {
const { container } = render(<Button variant="destructive">Delete</Button>);
expect(container.firstChild).toHaveClass('bg-destructive');
});
});
```
### 2.2 Utility Functions (`src/lib/`)
| File | Test File | Key Test Cases |
|------|-----------|----------------|
| `utils.ts` | `utils.test.ts` | `cn()` class merging, date formatting, slug generation |
| `filters.ts` | `filters.test.ts` | Filter predicate composition, attribute extraction |
| `filterAttributes.ts` | `filterAttributes.test.ts` | Attribute parsing, type coercion |
| `files.ts` | `files.test.ts` | File upload validation, size/format checks |
| `logger.ts` | `logger.test.ts` | Log level filtering, structured output |
| `apiClient.ts` | `apiClient.test.ts` | Base URL resolution (server vs client), interceptor registration |
### 2.3 Custom Hooks (`src/hooks/`)
| Hook | Test File | Key Test Cases |
|------|-----------|----------------|
| `use-mobile.ts` | `use-mobile.test.ts` | Detects mobile viewport, updates on resize |
| `useAudioPlayback.ts` | `useAudioPlayback.test.ts` | Play/pause/stop, progress tracking, error handling |
| `useLatestReleaseVersion.ts` | `useLatestReleaseVersion.test.ts` | Fetches version, caches result, handles errors |
**Example — `use-mobile.test.ts`**:
```typescript
import { renderHook } from '@testing-library/react';
import { useIsMobile } from './use-mobile';
describe('useIsMobile', () => {
it('returns false for desktop viewport', () => {
window.innerWidth = 1024;
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it('returns true for mobile viewport', () => {
window.innerWidth = 375;
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
});
```
### 2.4 Constants & Types (`src/constants/`, `src/types/`)
- Verify constant values match expected enums
- Type tests using `tsd` or `vitest typecheck`
---
## Phase 3: Integration Tests
### 3.1 Context Providers (`src/context/`)
Test with real provider wrappers, mock API calls via MSW.
| Context | Test File | Key Test Cases |
|---------|-----------|----------------|
| `UserConfigContext` | `UserConfigContext.test.tsx` | Fetches config on mount, saves config, handles errors, loading states |
| `AppConfigContext` | `AppConfigContext.test.tsx` | Provides app config, handles feature flags |
| `OnboardingContext` | `OnboardingContext.test.tsx` | Tracks onboarding step, persists state |
| `UnsavedChangesContext` | `UnsavedChangesContext.test.tsx` | Tracks dirty state, warns on navigation |
| `TelephonyConfigWarningsContext` | `TelephonyConfigWarningsContext.test.tsx` | Displays warnings for missing telephony config |
**Example — `UserConfigContext.test.tsx`**:
```typescript
import { render, screen, waitFor } from '@/test/test-utils';
import { useUserConfig } from './UserConfigContext';
function TestComponent() {
const { userConfig, loading, saveUserConfig } = useUserConfig();
if (loading) return <div>Loading</div>;
return (
<div>
<div data-testid="provider">{userConfig?.llm?.provider}</div>
<button onClick={() => saveUserConfig({ llm: { provider: 'openai', model: 'gpt-4' } })}>
Save
</button>
</div>
);
}
describe('UserConfigContext', () => {
it('fetches user config on mount', async () => {
render(<TestComponent />);
expect(screen.getByText('Loading')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('provider')).toHaveTextContent('openai');
});
});
it('saves user config', async () => {
render(<TestComponent />);
await waitFor(() => screen.getByRole('button'));
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
// Assert API was called
});
});
});
```
### 3.2 Complex Components (`src/components/`)
| Component | Test File | Key Test Cases |
|-----------|-----------|----------------|
| `ServiceConfigurationForm` | `ServiceConfigurationForm.test.tsx` | Renders all tabs, switches providers, shows/hides fields, validates, submits |
| `LLMConfigSelector` | `LLMConfigSelector.test.tsx` | Provider dropdown, model selection, custom input toggle |
| `VoiceSelector` | `VoiceSelector.test.tsx` | Voice list, selection, preview |
| `WorkflowCard` | `WorkflowCard.test.tsx` | Renders workflow data, edit/delete actions |
| `WorkflowTable` | `WorkflowTable.test.tsx` | Sorting, pagination, row actions |
| `CreateWorkflowButton` | `CreateWorkflowButton.test.tsx` | Opens modal, creates workflow, shows error |
| `TemplateCard` | `TemplateCard.test.tsx` | Renders template, applies on click |
| `UploadWorkflowButton` | `UploadWorkflowButton.test.tsx` | File upload, validation, error handling |
| `DailyUsageTable` | `DailyUsageTable.test.tsx` | Renders usage data, date formatting |
| `MCPSection` | `MCPSection.test.tsx` | Tool list, add/remove, configuration |
| `MediaPreviewDialog` | `MediaPreviewDialog.test.tsx` | Opens with media, closes, audio playback |
| `ChatwootWidget` | `ChatwootWidget.test.tsx` | Loads widget, handles events |
| `SignInClient` | `SignInClient.test.tsx` | Form validation, auth flow, error display |
| `SpinLoader` | `SpinLoader.test.tsx` | Shows/hides based on loading prop |
| `ThemeSwitcher` | `ThemeSwitcher.test.tsx` | Toggles theme, persists preference |
| `Footer` | `Footer.test.tsx` | Renders links, version info |
| `PostHogIdentify` | `PostHogIdentify.test.tsx` | Identifies user on auth |
| `SentryErrorBoundary` | `SentryErrorBoundary.test.tsx` | Catches errors, shows fallback UI |
| `TelemetrySection` | `TelemetrySection.test.tsx` | Toggle telemetry, persists setting |
| `CallTypeCell` | `CallTypeCell.test.tsx` | Renders call type badge |
### 3.3 Workflow Builder Components (`src/components/workflow/`, `src/components/flow/`)
These use React Flow and Zustand — test with mocked stores.
| Component | Test File | Key Test Cases |
|-----------|-----------|----------------|
| Flow canvas | `flow/*.test.tsx` | Node rendering, edge connections, drag-and-drop |
| Node configuration panels | `workflow/*.test.tsx` | Form validation, state updates, node types |
| Conversation components | `workflow/conversation/*.test.tsx` | Message rendering, send/receive |
| Folder management | `workflow/folders/*.test.tsx` | CRUD operations, drag-drop |
### 3.4 Page Components (`src/app/`)
Test page-level integration with routing and data fetching.
| Page | Test File | Key Test Cases |
|------|-----------|----------------|
| `/model-configurations` | `model-configurations/page.test.tsx` | Loads config, renders form, saves changes |
| `/workflow/create` | `workflow/create/page.test.tsx` | Creates workflow, validates, redirects |
| `/workflow/[id]` | `workflow/[id]/page.test.tsx` | Loads workflow, edits, saves |
| `/settings` | `settings/page.test.tsx` | Renders settings, updates preferences |
| `/auth/login` | `auth/login/page.test.tsx` | Form validation, login flow, error handling |
| `/overview` | `overview/page.test.tsx` | Dashboard stats, recent activity |
| `/recordings` | `recordings/page.test.tsx` | List recordings, play, filter |
| `/tools` | `tools/page.test.tsx` | Tool list, create/edit/delete |
| `/campaigns` | `campaigns/page.test.tsx` | Campaign list, status, actions |
| `/usage` | `usage/page.test.tsx` | Usage charts, date range selection |
| `/api-keys` | `api-keys/page.test.tsx` | Key list, generate, revoke |
| `/telephony-configurations` | `telephony-configurations/page.test.tsx` | Provider config, validation |
| `/files` | `files/page.test.tsx` | File list, upload, delete |
| `/reports` | `reports/page.test.tsx` | Report generation, export |
| `/automation` | `automation/page.test.tsx` | Automation rules, triggers |
---
## Phase 4: E2E Tests (Playwright)
### 4.1 Authentication Flows
| Test File | Scenario |
|-----------|----------|
| `auth/login.spec.ts` | User logs in with valid credentials, sees dashboard |
| `auth/signup.spec.ts` | New user signs up, completes onboarding |
| `auth/logout.spec.ts` | User logs out, redirected to login |
| `auth/session.spec.ts` | Session persists across page reloads |
### 4.2 Core User Journeys
| Test File | Scenario |
|-----------|----------|
| `workflow/create.spec.ts` | Create voice agent from scratch, configure LLM, save |
| `workflow/edit.spec.ts` | Open existing workflow, modify nodes, save changes |
| `workflow/deploy.spec.ts` | Deploy workflow, verify webhook URL generated |
| `model-configurations.spec.ts` | Configure OpenAI with custom base URL, save, verify persisted |
| `campaigns/create.spec.ts` | Create campaign, upload contacts, launch |
| `recordings/play.spec.ts` | Navigate to recordings, play audio, verify playback |
| `tools/mcp.spec.ts` | Add MCP tool, configure server URL, test connection |
| `telephony/configure.spec.ts` | Add telephony provider, configure inbound webhook |
### 4.3 Critical Path — Voice Agent Creation
```typescript
// e2e/workflow/create-voice-agent.spec.ts
import { test, expect } from '@playwright/test';
test('create and configure a voice agent end-to-end', async ({ page }) => {
// Login
await page.goto('/auth/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/overview');
// Navigate to create workflow
await page.click('text=Voice Agents');
await page.click('text=Create Agent');
await expect(page).toHaveURL(/\/workflow\/create/);
// Configure LLM with custom base URL
await page.click('text=Models');
await page.selectOption('select[name="llm_provider"]', 'openai');
await page.fill('input[name="llm_base_url"]', 'https://custom.example.com/v1');
await page.fill('input[name="llm_api_key"]', 'sk-test-key');
await page.selectOption('select[name="llm_model"]', 'gpt-4.1');
// Save configuration
await page.click('text=Save Configuration');
await expect(page.locator('.sonner')).toContainText('Saved');
// Verify persistence
await page.reload();
await expect(page.locator('input[name="llm_base_url"]')).toHaveValue('https://custom.example.com/v1');
});
```
### 4.4 Cross-Browser & Responsive
- Run E2E suite on Chromium and Firefox
- Test key flows at mobile viewport (375px width)
- Verify sidebar collapses, tables scroll, modals fit screen
---
## Phase 5: Backend Test Expansion
### 5.1 Service Factory Tests
| Test File | Coverage |
|-----------|----------|
| `test_openai_service_factory.py` | OpenAI LLM with/without custom base_url, gpt-5 reasoning params |
| `test_openrouter_service_factory.py` | OpenRouter with custom base_url |
| `test_speaches_service_factory.py` | Local model endpoint configuration |
| `test_minimax_service_factory.py` | MiniMax with custom base_url and temperature |
| `test_azure_service_factory.py` | Azure endpoint configuration |
| `test_google_vertex_service_factory.py` | Vertex AI project/location/credentials |
| `test_bedrock_service_factory.py` | AWS credentials and region |
### 5.2 Configuration Registry Tests
| Test File | Coverage |
|-----------|----------|
| `test_configuration_registry.py` | All provider schemas validate, defaults correct, required fields |
| `test_configuration_merge.py` | Override merging, fallback to global, partial overrides |
| `test_configuration_validity.py` | API key validation per provider, masked key rejection |
| `test_configuration_defaults_endpoint.py` | `/configurations/defaults` returns complete schemas |
### 5.3 Integration Tests for New Feature
| Test File | Coverage |
|-----------|----------|
| `test_openai_base_url_persistence.py` | Save/load user config with custom base_url |
| `test_openai_base_url_runtime.py` | Pipeline uses custom base_url at runtime |
| `test_openai_base_url_workflow_override.py` | Workflow-level override of base_url |
---
## Phase 6: CI/CD Integration
### 6.1 GitHub Actions Workflow
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '24' }
- run: cd ui && npm ci
- run: cd ui && npm run test:coverage
- uses: codecov/codecov-action@v4
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '24' }
- run: cd ui && npm ci
- run: cd ui && npx playwright install
- run: cd ui && npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with: { name: playwright-report, path: ui/playwright-report/ }
```
### 6.2 Coverage Gates
- Unit tests: minimum 70% line coverage
- Integration tests: minimum 50% line coverage
- E2E tests: all critical user journeys must pass
- Block PR merge on test failure
---
## Implementation Priority
| Priority | Phase | Effort | Impact |
|----------|-------|--------|--------|
| P0 | 1. Tooling Setup | Medium | Enables all testing |
| P0 | 2.1 UI Primitives | Medium | Foundation for component tests |
| P0 | 2.2 Utilities + Hooks | Low | Quick wins, high confidence |
| P1 | 3.1 Context Providers | Medium | Core app logic |
| P1 | 3.2 Complex Components | High | User-facing features |
| P1 | 5.1 Service Factory Tests | Medium | Backend stability |
| P2 | 3.3 Workflow Builder | High | Complex React Flow + Zustand |
| P2 | 3.4 Page Components | High | Full page integration |
| P2 | 4.1-4.3 E2E Core Journeys | High | Regression prevention |
| P3 | 4.4 Cross-browser | Medium | Quality assurance |
| P3 | 5.2-5.3 Backend Expansion | Medium | Complete backend coverage |
---
## Estimated Test Count
| Layer | Estimated Tests |
|-------|-----------------|
| Unit (UI primitives) | ~150 |
| Unit (utilities/hooks) | ~30 |
| Integration (contexts) | ~25 |
| Integration (components) | ~80 |
| Integration (pages) | ~45 |
| E2E (core journeys) | ~20 |
| E2E (edge cases) | ~15 |
| Backend unit | ~60 |
| Backend integration | ~25 |
| **Total** | **~450** |

11565
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "1.31.0",
"version": "1.31.1",
"private": true,
"scripts": {
"dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack",
@ -59,18 +59,33 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@hey-api/openapi-ts": "^0.95.0",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/source-map-support": "^0.5.10",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.7",
"@vitest/ui": "^4.1.7",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "^15.3.3",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"jsdom": "^29.1.1",
"msw": "^2.14.6",
"source-map-support": "^0.5.21",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.7"
},
"overrides": {
"lucide-react": "^0.505.0"
}
}

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from './alert-dialog';
describe('AlertDialog', () => {
it('renders trigger', () => {
render(
<AlertDialog>
<AlertDialogTrigger>Open</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Title</AlertDialogTitle>
<AlertDialogDescription>Desc</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('renders content when open', () => {
render(
<AlertDialog open>
<AlertDialogTrigger>Open</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Alert Title</AlertDialogTitle>
<AlertDialogDescription>Alert description</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
expect(screen.getByText('Alert Title')).toBeInTheDocument();
expect(screen.getByText('Alert description')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Confirm')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Badge } from './badge';
describe('Badge', () => {
it('renders children text', () => {
render(<Badge>New</Badge>);
expect(screen.getByText('New')).toBeInTheDocument();
});
it('applies default variant classes', () => {
const { container } = render(<Badge>Default</Badge>);
expect(container.firstChild).toHaveClass('bg-primary');
});
it('applies secondary variant classes', () => {
const { container } = render(<Badge variant="secondary">Secondary</Badge>);
expect(container.firstChild).toHaveClass('bg-secondary');
});
it('applies destructive variant classes', () => {
const { container } = render(<Badge variant="destructive">Error</Badge>);
expect(container.firstChild).toHaveClass('bg-destructive');
});
it('applies outline variant classes', () => {
const { container } = render(<Badge variant="outline">Outline</Badge>);
expect(container.firstChild).toHaveClass('text-foreground');
});
it('applies custom className', () => {
const { container } = render(<Badge className="custom-badge">Custom</Badge>);
expect(container.firstChild).toHaveClass('custom-badge');
});
});

View file

@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Button } from './button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
screen.getByRole('button').click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies default variant classes', () => {
const { container } = render(<Button>Default</Button>);
expect(container.firstChild).toHaveClass('bg-primary');
});
it('applies destructive variant classes', () => {
const { container } = render(<Button variant="destructive">Delete</Button>);
expect(container.firstChild).toHaveClass('bg-destructive');
});
it('applies outline variant classes', () => {
const { container } = render(<Button variant="outline">Outline</Button>);
expect(container.firstChild).toHaveClass('border');
});
it('applies size classes', () => {
const { container } = render(<Button size="sm">Small</Button>);
expect(container.firstChild).toHaveClass('h-8');
});
it('renders as child when asChild is true', () => {
render(
<Button asChild>
<a href="/">Link Button</a>
</Button>
);
expect(screen.getByRole('link', { name: 'Link Button' })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { render } from '@/test/test-utils';
import { Calendar } from './calendar';
describe('Calendar', () => {
it('renders with default props', () => {
const { container } = render(<Calendar />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts custom className', () => {
const { container } = render(<Calendar className="custom-calendar" />);
expect(container.firstChild).toHaveClass('custom-calendar');
});
it('accepts showOutsideDays prop', () => {
const { container } = render(<Calendar showOutsideDays={false} />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts captionLayout prop', () => {
const { container } = render(<Calendar captionLayout="dropdown" />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts buttonVariant prop', () => {
const { container } = render(<Calendar buttonVariant="outline" />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts fromDate and toDate constraints', () => {
const fromDate = new Date(2024, 0, 1);
const toDate = new Date(2024, 11, 31);
const { container } = render(<Calendar fromDate={fromDate} toDate={toDate} />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts classNames prop', () => {
const { container } = render(
<Calendar classNames={{ root: 'custom-root' }} />
);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts formatters prop', () => {
const { container } = render(
<Calendar formatters={{ formatMonthDropdown: (d) => d.toLocaleString('default', { month: 'long' }) }} />
);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts components prop', () => {
const { container } = render(<Calendar components={{}} />);
expect(container.firstChild).toBeInTheDocument();
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';
describe('Card', () => {
it('renders children', () => {
render(<Card>Card content</Card>);
expect(screen.getByText('Card content')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<Card className="custom-card">Content</Card>);
expect(container.firstChild).toHaveClass('custom-card');
});
it('renders CardHeader with padding', () => {
const { container } = render(<CardHeader>Header</CardHeader>);
expect(container.firstChild).toHaveClass('p-6');
});
it('renders CardTitle as h3', () => {
render(<CardTitle>Title</CardTitle>);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title');
});
it('renders CardDescription', () => {
render(<CardDescription>Desc</CardDescription>);
expect(screen.getByText('Desc')).toBeInTheDocument();
});
it('renders CardContent', () => {
render(<CardContent>Body</CardContent>);
expect(screen.getByText('Body')).toBeInTheDocument();
});
it('renders CardFooter', () => {
render(<CardFooter>Footer</CardFooter>);
expect(screen.getByText('Footer')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { Checkbox } from './checkbox';
describe('Checkbox', () => {
it('renders unchecked by default', () => {
render(<Checkbox aria-label="Accept terms" />);
expect(screen.getByRole('checkbox')).not.toBeChecked();
});
it('renders checked when checked prop is true', () => {
render(<Checkbox checked aria-label="Accept terms" />);
expect(screen.getByRole('checkbox')).toBeChecked();
});
it('handles onCheckedChange events', async () => {
const handleChange = vi.fn();
render(<Checkbox onCheckedChange={handleChange} aria-label="Accept terms" />);
await userEvent.click(screen.getByRole('checkbox'));
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
});
it('is disabled when disabled prop is true', () => {
render(<Checkbox disabled aria-label="Accept terms" />);
expect(screen.getByRole('checkbox')).toBeDisabled();
});
});

View file

@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@/test/test-utils';
import { ChoiceChips } from './choice-chips';
describe('ChoiceChips', () => {
const defaultProps = {
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
],
value: 'option1',
onChange: vi.fn(),
};
it('renders all options', () => {
render(<ChoiceChips {...defaultProps} />);
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
it('renders selected option with primary styles', () => {
const { container } = render(<ChoiceChips {...defaultProps} />);
const selectedButton = screen.getByText('Option 1');
expect(selectedButton).toHaveClass('bg-primary');
expect(selectedButton).toHaveClass('text-primary-foreground');
});
it('renders unselected options with secondary styles', () => {
const { container } = render(<ChoiceChips {...defaultProps} />);
const unselectedButton = screen.getByText('Option 2');
expect(unselectedButton).toHaveClass('bg-secondary');
expect(unselectedButton).toHaveClass('text-secondary-foreground');
});
it('calls onChange when clicking an option', () => {
const onChange = vi.fn();
render(<ChoiceChips {...defaultProps} onChange={onChange} />);
fireEvent.click(screen.getByText('Option 2'));
expect(onChange).toHaveBeenCalledWith('option2');
});
it('applies custom className', () => {
const { container } = render(
<ChoiceChips {...defaultProps} className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('handles empty options array', () => {
const { container } = render(
<ChoiceChips options={[]} value="" onChange={vi.fn()} />
);
expect(container.firstChild).toBeInTheDocument();
});
it('handles single option', () => {
render(
<ChoiceChips
options={[{ value: 'only', label: 'Only Option' }]}
value="only"
onChange={vi.fn()}
/>
);
expect(screen.getByText('Only Option')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './collapsible';
describe('Collapsible', () => {
it('renders trigger and content', () => {
render(
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Hidden content</CollapsibleContent>
</Collapsible>
);
expect(screen.getByText('Toggle')).toBeInTheDocument();
});
it('shows content when open', () => {
render(
<Collapsible open>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Visible content</CollapsibleContent>
</Collapsible>
);
expect(screen.getByText('Visible content')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from './dialog';
describe('Dialog', () => {
it('renders dialog trigger', () => {
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Title</DialogTitle>
<DialogDescription>Desc</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('renders dialog content when open', () => {
render(
<Dialog open>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>Dialog description</DialogDescription>
</DialogHeader>
<DialogFooter>Footer</DialogFooter>
</DialogContent>
</Dialog>
);
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
expect(screen.getByText('Dialog description')).toBeInTheDocument();
expect(screen.getByText('Footer')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from './dropdown-menu';
describe('DropdownMenu', () => {
it('renders trigger', () => {
render(
<DropdownMenu>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('renders content when open', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Label</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>Item</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Label')).toBeInTheDocument();
expect(screen.getByText('Item')).toBeInTheDocument();
});
it('renders checkbox item', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuCheckboxItem checked>Check</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Check')).toBeInTheDocument();
});
it('renders radio group', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup value="a">
<DropdownMenuRadioItem value="a">A</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('A')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,41 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Input } from './input';
describe('Input', () => {
it('renders with placeholder', () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
});
it('accepts and displays value', () => {
render(<Input value="test value" readOnly />);
expect(screen.getByDisplayValue('test value')).toBeInTheDocument();
});
it('handles onChange events', () => {
const handleChange = vi.fn();
render(<Input onChange={handleChange} />);
const input = screen.getByRole('textbox');
input.click();
expect(handleChange).not.toHaveBeenCalled();
});
it('is disabled when disabled prop is true', () => {
render(<Input disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
});
it('renders different input types', () => {
const { rerender } = render(<Input type="text" />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
rerender(<Input type="password" aria-label="Password" />);
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
});
it('applies custom className', () => {
const { container } = render(<Input className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});

View file

@ -0,0 +1,143 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@/test/test-utils';
import { JsonEditor, validateJson } from './json-editor';
describe('JsonEditor', () => {
const defaultProps = {
value: '{"key": "value"}',
onChange: vi.fn(),
};
it('renders with value', () => {
render(<JsonEditor {...defaultProps} />);
expect(screen.getByDisplayValue('{"key": "value"}')).toBeInTheDocument();
});
it('renders with label', () => {
render(<JsonEditor {...defaultProps} label="JSON Config" />);
expect(screen.getByText('JSON Config')).toBeInTheDocument();
});
it('renders with description', () => {
render(<JsonEditor {...defaultProps} description="Enter JSON configuration" />);
expect(screen.getByText('Enter JSON configuration')).toBeInTheDocument();
});
it('calls onChange when value changes', () => {
const onChange = vi.fn();
render(<JsonEditor {...defaultProps} onChange={onChange} />);
const textarea = screen.getByDisplayValue('{"key": "value"}');
fireEvent.change(textarea, { target: { value: '{"new": "value"}' } });
expect(onChange).toHaveBeenCalledWith('{"new": "value"}');
});
it('shows error message for invalid JSON', () => {
render(<JsonEditor {...defaultProps} value="invalid json" error="Invalid JSON" />);
expect(screen.getByText('Invalid JSON')).toBeInTheDocument();
});
it('renders copy button when showCopyButton is true', () => {
render(<JsonEditor {...defaultProps} showCopyButton />);
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(
<JsonEditor {...defaultProps} className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('applies custom minHeight', () => {
render(<JsonEditor {...defaultProps} minHeight="200px" />);
const textarea = screen.getByDisplayValue('{"key": "value"}');
expect(textarea).toHaveStyle({ minHeight: '200px' });
});
});
describe('validateJson', () => {
it('returns valid for empty string', () => {
const result = validateJson('');
expect(result.valid).toBe(true);
});
it('returns valid for empty object', () => {
const result = validateJson('{}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({});
});
it('returns valid for empty array', () => {
const result = validateJson('[]');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual([]);
});
it('returns valid for valid JSON object', () => {
const result = validateJson('{"key": "value"}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ key: 'value' });
});
it('returns valid for valid JSON array', () => {
const result = validateJson('["a", "b", "c"]');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual(['a', 'b', 'c']);
});
it('returns invalid for invalid JSON', () => {
const result = validateJson('{invalid}');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('detects unquoted template variables', () => {
const result = validateJson('{"key": {{variable}}}');
expect(result.valid).toBe(false);
expect(result.error).toContain('Template variables must be quoted');
});
it('detects trailing comma', () => {
const result = validateJson('{"key": "value",}');
expect(result.valid).toBe(false);
expect(result.error).toContain('Trailing comma');
});
it('detects single quotes', () => {
const result = validateJson("{'key': 'value'}");
expect(result.valid).toBe(false);
expect(result.error).toContain('double quotes');
});
it('detects unquoted string values', () => {
const result = validateJson('{key: value}');
expect(result.valid).toBe(false);
expect(result.error).toContain('quoted');
});
it('handles numbers', () => {
const result = validateJson('{"count": 42}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ count: 42 });
});
it('handles booleans', () => {
const result = validateJson('{"enabled": true}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ enabled: true });
});
it('handles null', () => {
const result = validateJson('{"value": null}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ value: null });
});
it('handles nested objects', () => {
const result = validateJson('{"outer": {"inner": "value"}}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ outer: { inner: 'value' } });
});
});

View file

@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Label } from './label';
describe('Label', () => {
it('renders children text', () => {
render(<Label>Email</Label>);
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('associates with input via htmlFor', () => {
render(<Label htmlFor="email-input">Email</Label>);
expect(screen.getByText('Email')).toHaveAttribute('for', 'email-input');
});
it('applies custom className', () => {
const { container } = render(<Label className="custom-label">Text</Label>);
expect(container.firstChild).toHaveClass('custom-label');
});
});

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Popover, PopoverTrigger, PopoverContent } from './popover';
describe('Popover', () => {
it('renders trigger', () => {
render(
<Popover>
<PopoverTrigger>Click me</PopoverTrigger>
<PopoverContent>Popover content</PopoverContent>
</Popover>
);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('renders content when open', () => {
render(
<Popover open>
<PopoverTrigger>Click</PopoverTrigger>
<PopoverContent>Content</PopoverContent>
</Popover>
);
expect(screen.getByText('Content')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { render } from '@/test/test-utils';
import { Progress } from './progress';
describe('Progress', () => {
it('renders with default value', () => {
const { container } = render(<Progress value={50} />);
const indicator = container.querySelector('[data-slot="progress-indicator"]');
expect(indicator).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<Progress value={30} className="custom-progress" />);
expect(container.firstChild).toHaveClass('custom-progress');
});
it('renders with zero value', () => {
const { container } = render(<Progress value={0} />);
const indicator = container.querySelector('[data-slot="progress-indicator"]');
expect(indicator).toHaveStyle('transform: translateX(-100%)');
});
it('renders with full value', () => {
const { container } = render(<Progress value={100} />);
const indicator = container.querySelector('[data-slot="progress-indicator"]');
expect(indicator).toHaveStyle('transform: translateX(-0%)');
});
});

View file

@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { RadioGroup, RadioGroupItem } from './radio-group';
describe('RadioGroup', () => {
it('renders radio items', () => {
render(
<RadioGroup>
<RadioGroupItem value="a" aria-label="Option A" />
<RadioGroupItem value="b" aria-label="Option B" />
</RadioGroup>
);
expect(screen.getAllByRole('radio')).toHaveLength(2);
});
it('checks item when clicked', async () => {
const handleChange = vi.fn();
render(
<RadioGroup onValueChange={handleChange}>
<RadioGroupItem value="a" aria-label="Option A" />
</RadioGroup>
);
await userEvent.click(screen.getByRole('radio'));
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
});
it('applies custom className', () => {
const { container } = render(
<RadioGroup className="custom-group">
<RadioGroupItem value="a" aria-label="A" />
</RadioGroup>
);
expect(container.firstChild).toHaveClass('custom-group');
});
});

View file

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel } from './select';
describe('Select', () => {
it('renders select trigger with placeholder', () => {
render(
<Select>
<SelectTrigger>
<SelectValue placeholder="Choose an option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
);
expect(screen.getByText('Choose an option')).toBeInTheDocument();
});
it('renders select with value', () => {
render(
<Select value="a">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
);
expect(screen.getByText('Option A')).toBeInTheDocument();
});
it('renders select group with label when open', () => {
render(
<Select open>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Group</SelectLabel>
<SelectItem value="a">A</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
expect(screen.getByText('Group')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render } from '@/test/test-utils';
import { Separator } from './separator';
describe('Separator', () => {
it('renders horizontal separator by default', () => {
const { container } = render(<Separator />);
expect(container.firstChild).toHaveAttribute('data-orientation', 'horizontal');
});
it('renders vertical separator', () => {
const { container } = render(<Separator orientation="vertical" />);
expect(container.firstChild).toHaveAttribute('data-orientation', 'vertical');
});
it('has data-orientation attribute', () => {
const { container } = render(<Separator />);
expect(container.firstChild).toHaveAttribute('data-orientation', 'horizontal');
});
it('applies custom className', () => {
const { container } = render(<Separator className="custom-sep" />);
expect(container.firstChild).toHaveClass('custom-sep');
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from './sheet';
describe('Sheet', () => {
it('renders trigger', () => {
render(
<Sheet>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Title</SheetTitle>
<SheetDescription>Desc</SheetDescription>
</SheetHeader>
<SheetFooter>Footer</SheetFooter>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('renders content when open', () => {
render(
<Sheet open>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
<SheetDescription>Sheet description</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
expect(screen.getByText('Sheet description')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarSeparator,
SidebarTrigger,
useSidebar,
SidebarInset
} from './sidebar';
describe('Sidebar', () => {
it('exports all expected components', () => {
expect(Sidebar).toBeDefined();
expect(SidebarContent).toBeDefined();
expect(SidebarFooter).toBeDefined();
expect(SidebarGroup).toBeDefined();
expect(SidebarGroupContent).toBeDefined();
expect(SidebarGroupLabel).toBeDefined();
expect(SidebarHeader).toBeDefined();
expect(SidebarInput).toBeDefined();
expect(SidebarMenu).toBeDefined();
expect(SidebarMenuAction).toBeDefined();
expect(SidebarMenuButton).toBeDefined();
expect(SidebarMenuItem).toBeDefined();
expect(SidebarMenuSkeleton).toBeDefined();
expect(SidebarMenuSub).toBeDefined();
expect(SidebarMenuSubButton).toBeDefined();
expect(SidebarMenuSubItem).toBeDefined();
expect(SidebarProvider).toBeDefined();
expect(SidebarSeparator).toBeDefined();
expect(SidebarTrigger).toBeDefined();
expect(SidebarInset).toBeDefined();
});
it('exports useSidebar hook', () => {
expect(useSidebar).toBeDefined();
expect(typeof useSidebar).toBe('function');
});
it('SidebarProvider is a function component', () => {
expect(typeof SidebarProvider).toBe('function');
});
it('Sidebar is a function component', () => {
expect(typeof Sidebar).toBe('function');
});
it('SidebarContent is a function component', () => {
expect(typeof SidebarContent).toBe('function');
});
it('SidebarHeader is a function component', () => {
expect(typeof SidebarHeader).toBe('function');
});
it('SidebarFooter is a function component', () => {
expect(typeof SidebarFooter).toBe('function');
});
it('SidebarGroup is a function component', () => {
expect(typeof SidebarGroup).toBe('function');
});
it('SidebarGroupLabel is a function component', () => {
expect(typeof SidebarGroupLabel).toBe('function');
});
it('SidebarGroupContent is a function component', () => {
expect(typeof SidebarGroupContent).toBe('function');
});
it('SidebarMenu is a function component', () => {
expect(typeof SidebarMenu).toBe('function');
});
it('SidebarMenuItem is a function component', () => {
expect(typeof SidebarMenuItem).toBe('function');
});
it('SidebarMenuButton is a function component', () => {
expect(typeof SidebarMenuButton).toBe('function');
});
it('SidebarMenuSkeleton is a function component', () => {
expect(typeof SidebarMenuSkeleton).toBe('function');
});
it('SidebarSeparator is a function component', () => {
expect(typeof SidebarSeparator).toBe('function');
});
it('SidebarTrigger is a function component', () => {
expect(typeof SidebarTrigger).toBe('function');
});
it('SidebarInput is a function component', () => {
expect(typeof SidebarInput).toBe('function');
});
it('SidebarInset is a function component', () => {
expect(typeof SidebarInset).toBe('function');
});
it('SidebarMenuAction is a function component', () => {
expect(typeof SidebarMenuAction).toBe('function');
});
it('SidebarMenuSub is a function component', () => {
expect(typeof SidebarMenuSub).toBe('function');
});
it('SidebarMenuSubButton is a function component', () => {
expect(typeof SidebarMenuSubButton).toBe('function');
});
it('SidebarMenuSubItem is a function component', () => {
expect(typeof SidebarMenuSubItem).toBe('function');
});
});

View file

@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render } from '@/test/test-utils';
import { Skeleton } from './skeleton';
describe('Skeleton', () => {
it('renders a div with animate-pulse', () => {
const { container } = render(<Skeleton />);
expect(container.firstChild).toHaveClass('animate-pulse');
});
it('applies custom className', () => {
const { container } = render(<Skeleton className="custom-skeleton" />);
expect(container.firstChild).toHaveClass('custom-skeleton');
});
it('renders children', () => {
render(<Skeleton>Loading</Skeleton>);
expect(document.querySelector('[data-slot="skeleton"]')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { Toaster } from './sonner';
// Note: sonner uses next-themes which requires a ThemeProvider wrapper
// These tests verify the component exports correctly
describe('Toaster', () => {
it('exports Toaster component', () => {
// Verify the named export exists
expect(Toaster).toBeDefined();
});
it('Toaster is a function component', () => {
expect(typeof Toaster).toBe('function');
});
});

View file

@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { Switch } from './switch';
describe('Switch', () => {
it('renders unchecked by default', () => {
render(<Switch aria-label="Toggle" />);
expect(screen.getByRole('switch')).not.toBeChecked();
});
it('renders checked when checked prop is true', () => {
render(<Switch checked aria-label="Toggle" />);
expect(screen.getByRole('switch')).toBeChecked();
});
it('handles onCheckedChange events', async () => {
const handleChange = vi.fn();
render(<Switch onCheckedChange={handleChange} aria-label="Toggle" />);
await userEvent.click(screen.getByRole('switch'));
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
});
it('is disabled when disabled prop is true', () => {
render(<Switch disabled aria-label="Toggle" />);
expect(screen.getByRole('switch')).toBeDisabled();
});
});

View file

@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from './table';
describe('Table', () => {
it('renders table with header, body, and rows', () => {
render(
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Alice</TableCell>
<TableCell>30</TableCell>
</TableRow>
</TableBody>
</Table>
);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('renders table caption', () => {
render(
<Table>
<TableCaption>User list</TableCaption>
<TableBody><TableRow><TableCell>Data</TableCell></TableRow></TableBody>
</Table>
);
expect(screen.getByText('User list')).toBeInTheDocument();
});
it('renders table footer', () => {
render(
<Table>
<TableFooter>
<TableRow><TableCell>Total</TableCell></TableRow>
</TableFooter>
</Table>
);
expect(screen.getByText('Total')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<Table className="custom-table" />);
expect(container.querySelector('table')).toHaveClass('custom-table');
});
});

View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs';
describe('Tabs', () => {
it('renders tabs with list and content', () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
</Tabs>
);
expect(screen.getByRole('tablist')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab 1' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab 2' })).toBeInTheDocument();
});
it('shows default tab content', () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
expect(screen.getByText('Content 1')).toBeInTheDocument();
});
it('applies custom className to Tabs', () => {
const { container } = render(
<Tabs className="custom-tabs" defaultValue="tab1">
<TabsList><TabsTrigger value="tab1">Tab</TabsTrigger></TabsList>
<TabsContent value="tab1">Content</TabsContent>
</Tabs>
);
expect(container.firstChild).toHaveClass('custom-tabs');
});
});

View file

@ -0,0 +1,33 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Textarea } from './textarea';
describe('Textarea', () => {
it('renders with placeholder', () => {
render(<Textarea placeholder="Enter description" />);
expect(screen.getByPlaceholderText('Enter description')).toBeInTheDocument();
});
it('accepts and displays value', () => {
render(<Textarea value="test content" readOnly />);
expect(screen.getByDisplayValue('test content')).toBeInTheDocument();
});
it('handles onChange events', () => {
const handleChange = vi.fn();
render(<Textarea onChange={handleChange} />);
const textarea = screen.getByRole('textbox');
textarea.click();
expect(handleChange).not.toHaveBeenCalled();
});
it('is disabled when disabled prop is true', () => {
render(<Textarea disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
});
it('applies custom className', () => {
const { container } = render(<Textarea className="custom-textarea" />);
expect(container.firstChild).toHaveClass('custom-textarea');
});
});

View file

@ -0,0 +1,25 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Tooltip, TooltipTrigger, TooltipContent } from './tooltip';
describe('Tooltip', () => {
it('renders tooltip trigger', () => {
render(
<Tooltip>
<TooltipTrigger>Hover me</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>
);
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('renders tooltip content when open', () => {
render(
<Tooltip open>
<TooltipTrigger>Trigger</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>
);
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip text');
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { UserConfigProvider, useUserConfig } from './UserConfigContext';
describe('UserConfigContext', () => {
it('exports UserConfigProvider component', () => {
expect(UserConfigProvider).toBeDefined();
expect(typeof UserConfigProvider).toBe('function');
});
it('exports useUserConfig hook', () => {
expect(useUserConfig).toBeDefined();
expect(typeof useUserConfig).toBe('function');
});
});
describe('Other Contexts', () => {
it('AppConfigContext exports provider and hook', async () => {
const module = await import('./AppConfigContext');
expect(module.AppConfigProvider).toBeDefined();
expect(module.useAppConfig).toBeDefined();
});
it('OnboardingContext exports provider and hook', async () => {
const module = await import('./OnboardingContext');
expect(module.OnboardingProvider).toBeDefined();
expect(module.useOnboarding).toBeDefined();
});
it('UnsavedChangesContext exports provider and hook', async () => {
const module = await import('./UnsavedChangesContext');
expect(module.UnsavedChangesProvider).toBeDefined();
expect(module.useUnsavedChanges).toBeDefined();
});
it('TelephonyConfigWarningsContext exports provider and hook', async () => {
const module = await import('./TelephonyConfigWarningsContext');
expect(module.TelephonyConfigWarningsProvider).toBeDefined();
expect(module.useTelephonyConfigWarnings).toBeDefined();
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useIsMobile } from './use-mobile';
describe('useIsMobile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns false when viewport is larger than breakpoint', () => {
// Mock matchMedia to return false (desktop)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
// Mock innerWidth
Object.defineProperty(window, 'innerWidth', {
writable: true,
value: 1024,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it('returns true when viewport is smaller than breakpoint', () => {
// Mock matchMedia to return true (mobile)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: true,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
// Mock innerWidth
Object.defineProperty(window, 'innerWidth', {
writable: true,
value: 375,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it('is exported as a function', () => {
expect(useIsMobile).toBeDefined();
expect(typeof useIsMobile).toBe('function');
});
});

291
ui/src/lib/filters.test.ts Normal file
View file

@ -0,0 +1,291 @@
import { describe, it, expect } from 'vitest';
import {
getDefaultValue,
validateFilter,
encodeFiltersToURL,
decodeFiltersFromURL,
formatDateRange,
formatNumberRange,
getDatePresetValue
} from './filters';
import {
ActiveFilter,
DateRangeValue,
MultiSelectValue,
NumberRangeValue,
FilterAttribute
} from '@/types/filters';
describe('filters', () => {
describe('getDefaultValue', () => {
it('returns { from: null, to: null } for dateRange', () => {
expect(getDefaultValue('dateRange')).toEqual({ from: null, to: null });
});
it('returns { codes: [] } for multiSelect', () => {
expect(getDefaultValue('multiSelect')).toEqual({ codes: [] });
});
it('returns { value: null } for number', () => {
expect(getDefaultValue('number')).toEqual({ value: null });
});
it('returns { min: null, max: null } for numberRange', () => {
expect(getDefaultValue('numberRange')).toEqual({ min: null, max: null });
});
it('returns { status: "all" } for radio', () => {
expect(getDefaultValue('radio')).toEqual({ status: 'all' });
});
it('returns { codes: [] } for tags', () => {
expect(getDefaultValue('tags')).toEqual({ codes: [] });
});
it('returns { value: "" } for text', () => {
expect(getDefaultValue('text')).toEqual({ value: '' });
});
it('throws error for unknown type', () => {
expect(() => getDefaultValue('unknown' as any)).toThrow('Unknown filter type: unknown');
});
});
describe('validateFilter', () => {
const createFilter = (type: FilterAttribute['type'], value: any, config: any = {}): ActiveFilter => ({
attribute: {
id: 'test',
type,
label: 'Test',
config,
},
value,
isValid: true,
});
describe('dateRange', () => {
it('returns error when from is missing', () => {
const filter = createFilter('dateRange', { from: null, to: new Date() });
expect(validateFilter(filter)).toBe('Both dates are required');
});
it('returns error when to is missing', () => {
const filter = createFilter('dateRange', { from: new Date(), to: null });
expect(validateFilter(filter)).toBe('Both dates are required');
});
it('returns error when to is before from', () => {
const from = new Date('2024-01-15');
const to = new Date('2024-01-10');
const filter = createFilter('dateRange', { from, to });
expect(validateFilter(filter)).toBe('End date must be after start date');
});
it('returns error when range exceeds maxRangeDays', () => {
const from = new Date('2024-01-01');
const to = new Date('2024-02-01');
const filter = createFilter('dateRange', { from, to }, { maxRangeDays: 30 });
expect(validateFilter(filter)).toBe('Date range cannot exceed 30 days');
});
it('returns null for valid date range', () => {
const from = new Date('2024-01-01');
const to = new Date('2024-01-15');
const filter = createFilter('dateRange', { from, to });
expect(validateFilter(filter)).toBeNull();
});
});
describe('multiSelect', () => {
it('returns error when no codes selected', () => {
const filter = createFilter('multiSelect', { codes: [] });
expect(validateFilter(filter)).toBe('At least one option must be selected');
});
it('returns error when exceeds maxSelections', () => {
const filter = createFilter('multiSelect', { codes: ['a', 'b', 'c'] }, { maxSelections: 2 });
expect(validateFilter(filter)).toBe('Cannot select more than 2 options');
});
it('returns null for valid multiSelect', () => {
const filter = createFilter('multiSelect', { codes: ['a', 'b'] });
expect(validateFilter(filter)).toBeNull();
});
});
describe('numberRange', () => {
it('returns error when min is greater than max', () => {
const filter = createFilter('numberRange', { min: 100, max: 50 });
expect(validateFilter(filter)).toBe('Minimum must be less than maximum');
});
it('returns null for valid numberRange', () => {
const filter = createFilter('numberRange', { min: 10, max: 100 });
expect(validateFilter(filter)).toBeNull();
});
it('returns error when min and max are null', () => {
const filter = createFilter('numberRange', { min: null, max: null });
expect(validateFilter(filter)).toBe('Both values are required');
});
});
describe('number', () => {
it('returns error when value is below min', () => {
const filter = createFilter('number', { value: 5 }, { min: 10, max: 100 });
expect(validateFilter(filter)).toBe('Value cannot be less than 10');
});
it('returns error when value is above max', () => {
const filter = createFilter('number', { value: 150 }, { min: 10, max: 100 });
expect(validateFilter(filter)).toBe('Value cannot be greater than 100');
});
it('returns null for valid number', () => {
const filter = createFilter('number', { value: 50 }, { min: 10, max: 100 });
expect(validateFilter(filter)).toBeNull();
});
it('returns error when value is null', () => {
const filter = createFilter('number', { value: null });
expect(validateFilter(filter)).toBe('A value is required');
});
});
describe('radio', () => {
it('returns null for valid radio', () => {
const filter = createFilter('radio', { status: 'completed' });
expect(validateFilter(filter)).toBeNull();
});
});
describe('text', () => {
it('returns error when text is empty', () => {
const filter = createFilter('text', { value: '' });
expect(validateFilter(filter)).toBe('Text value is required');
});
it('returns error when text is whitespace only', () => {
const filter = createFilter('text', { value: ' ' });
expect(validateFilter(filter)).toBe('Text value is required');
});
it('returns null for valid text', () => {
const filter = createFilter('text', { value: 'hello' });
expect(validateFilter(filter)).toBeNull();
});
});
});
describe('encodeFiltersToURL', () => {
it('returns empty string for empty filters', () => {
expect(encodeFiltersToURL([])).toBe('');
});
it('encodes filters to URL params', () => {
const filters: ActiveFilter[] = [
{
attribute: { id: 'date', type: 'dateRange', label: 'Date', config: {} },
value: { from: new Date('2024-01-01'), to: new Date('2024-01-31') },
isValid: true,
},
];
const result = encodeFiltersToURL(filters);
expect(result).toContain('filters=');
});
it('encodes multiple filters', () => {
const filters: ActiveFilter[] = [
{
attribute: { id: 'status', type: 'multiSelect', label: 'Status', config: { options: [] } },
value: { codes: ['completed', 'failed'] },
isValid: true,
},
{
attribute: { id: 'text', type: 'text', label: 'Text', config: {} },
value: { value: 'hello' },
isValid: true,
},
];
const result = encodeFiltersToURL(filters);
expect(result).toContain('filters=');
// Should contain encoded JSON
expect(decodeURIComponent(result)).toContain('status');
expect(decodeURIComponent(result)).toContain('hello');
});
});
describe('decodeFiltersFromURL', () => {
it('returns empty array for empty params', () => {
const params = new URLSearchParams();
const availableAttributes: FilterAttribute[] = [];
expect(decodeFiltersFromURL(params, availableAttributes)).toEqual([]);
});
it('decodes filters from URL', () => {
const params = new URLSearchParams();
params.set('filters', JSON.stringify([{ id: 'test', value: { value: 'hello' } }]));
const availableAttributes: FilterAttribute[] = [
{ id: 'test', type: 'text', label: 'Test', config: {} }
];
const result = decodeFiltersFromURL(params, availableAttributes);
expect(result.length).toBe(1);
expect(result[0].attribute.id).toBe('test');
});
});
describe('formatDateRange', () => {
it('returns "No date range selected" for null values', () => {
expect(formatDateRange({ from: null, to: null })).toBe('No date range selected');
});
it('formats date range correctly', () => {
const value: DateRangeValue = {
from: new Date('2024-01-15'),
to: new Date('2024-01-20')
};
const result = formatDateRange(value);
expect(result).toContain('2024');
});
});
describe('formatNumberRange', () => {
it('returns "No range selected" for null values', () => {
expect(formatNumberRange({ min: null, max: null })).toBe('No range selected');
});
it('formats number range with unit', () => {
const value: NumberRangeValue = { min: 10, max: 100 };
const result = formatNumberRange(value, 'seconds');
expect(result).toContain('10');
expect(result).toContain('100');
expect(result).toContain('seconds');
});
});
describe('getDatePresetValue', () => {
it('returns valid date range for "today"', () => {
const result = getDatePresetValue('today');
expect(result.from).toBeInstanceOf(Date);
expect(result.to).toBeInstanceOf(Date);
});
it('returns valid date range for "yesterday"', () => {
const result = getDatePresetValue('yesterday');
expect(result.from).toBeInstanceOf(Date);
expect(result.to).toBeInstanceOf(Date);
});
it('returns valid date range for "last7days"', () => {
const result = getDatePresetValue('last7days');
expect(result.from).toBeInstanceOf(Date);
expect(result.to).toBeInstanceOf(Date);
});
it('returns valid date range for "last30days"', () => {
const result = getDatePresetValue('last30days');
expect(result.from).toBeInstanceOf(Date);
expect(result.to).toBeInstanceOf(Date);
});
});
});

81
ui/src/lib/utils.test.ts Normal file
View file

@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest';
import { cn, getRandomId, getNextNodeId, debounce } from './utils';
describe('cn', () => {
it('merges class names with tailwind', () => {
expect(cn('px-2', 'py-1')).toBe('px-2 py-1');
});
it('handles conditional classes', () => {
expect(cn('base', false && 'hidden', true && 'visible')).toBe('base visible');
});
it('merges conflicting tailwind classes', () => {
expect(cn('px-2', 'px-4')).toBe('px-4');
});
it('handles empty inputs', () => {
expect(cn()).toBe('');
});
it('handles undefined and null values', () => {
expect(cn('base', undefined, null, 'extra')).toBe('base extra');
});
});
describe('getRandomId', () => {
it('returns a number between 0 and 9999', () => {
const id = getRandomId();
expect(id).toBeGreaterThanOrEqual(0);
expect(id).toBeLessThan(10000);
});
it('returns different values on multiple calls', () => {
const ids = new Set(Array.from({ length: 20 }, getRandomId));
expect(ids.size).toBeGreaterThan(1);
});
});
describe('getNextNodeId', () => {
it('returns "1" for empty array', () => {
expect(getNextNodeId([])).toBe('1');
});
it('returns next id after max existing', () => {
expect(getNextNodeId([{ id: '1' }, { id: '3' }])).toBe('4');
});
it('ignores non-numeric ids', () => {
expect(getNextNodeId([{ id: 'abc' }, { id: '5' }])).toBe('6');
});
});
describe('debounce', () => {
it('delays function execution', () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('cancels previous call on rapid invocations', () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});

View file

@ -0,0 +1,60 @@
import { http, HttpResponse } from 'msw';
export const handlers = [
// Auth
http.get('/api/auth/session', () => {
return HttpResponse.json({ user: { id: '1', email: 'test@example.com' } });
}),
// App config
http.get('/api/config/auth', () => {
return HttpResponse.json({ provider: 'local' });
}),
http.get('/api/config/version', () => {
return HttpResponse.json({ version: '1.31.0' });
}),
// User config
http.get('/api/v1/user/configurations/user', () => {
return HttpResponse.json({
llm: { provider: 'openai', model: 'gpt-4.1', base_url: 'https://api.openai.com/v1' },
tts: { provider: 'openai', model: 'gpt-4o-mini-tts' },
stt: { provider: 'deepgram', model: 'nova-2' },
embeddings: { provider: 'openai', model: 'text-embedding-3-small' },
});
}),
http.put('/api/v1/user/configurations/user', async () => {
return HttpResponse.json({ success: true });
}),
// Default configurations
http.get('/api/v1/user/configurations/defaults', () => {
return HttpResponse.json({
llm: {
openai: {
properties: {
provider: { const: 'openai', type: 'string' },
api_key: { type: 'string' },
model: { type: 'string', default: 'gpt-4.1' },
base_url: { type: 'string', default: 'https://api.openai.com/v1' },
},
},
},
tts: {},
stt: {},
embeddings: {},
default_providers: { llm: 'openai', tts: 'openai', stt: 'deepgram', embeddings: 'openai' },
});
}),
// Workflows
http.get('/api/v1/workflows', () => {
return HttpResponse.json({ items: [], total: 0 });
}),
http.get('/api/v1/workflow/count', () => {
return HttpResponse.json({ count: 0 });
}),
];

View file

@ -0,0 +1,4 @@
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

21
ui/src/test/setup.ts Normal file
View file

@ -0,0 +1,21 @@
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
import { server } from './mocks/server';
// Mock ResizeObserver for Radix UI components in jsdom
global.ResizeObserver = vi.fn().mockImplementation(function () {
return {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
};
});
// Start MSW before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Close MSW after all tests
afterAll(() => server.close());

View file

@ -0,0 +1,14 @@
import { render as rtlRender, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
function AllProviders({ children }: { children: ReactNode }) {
// Minimal wrapper — tests that need full providers will wrap explicitly
return <>{children}</>;
}
export function render(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return rtlRender(ui, { wrapper: AllProviders, ...options });
}
export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';

28
ui/vitest.config.ts Normal file
View file

@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'src/client/',
'**/*.d.ts',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});