test: set up Vitest with 61 frontend tests

Add Vitest + testing-library/react + jsdom for frontend testing.
Tests cover: cn() utility (6), usePermissions hook (27), useTreeValidation
hook (22), and userPreferencesStore (6). CI updated to run frontend tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-08 18:18:38 -05:00
parent f703684e15
commit 791fd133c4
10 changed files with 1650 additions and 3 deletions

View File

@@ -89,5 +89,8 @@ jobs:
- name: Lint - name: Lint
run: cd frontend && npm run lint run: cd frontend && npm run lint
- name: Test
run: cd frontend && npm test
- name: Build - name: Build
run: cd frontend && npm run build run: cd frontend && npm run build

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"analyze": "vite-bundle-visualizer" "analyze": "vite-bundle-visualizer"
}, },
"dependencies": { "dependencies": {
@@ -35,6 +37,9 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.9", "@types/node": "^24.10.9",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -44,11 +49,13 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^28.0.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.4", "typescript-eslint": "^8.46.4",
"vite": "^7.2.4", "vite": "^7.2.4",
"vite-bundle-visualizer": "^1.2.1" "vite-bundle-visualizer": "^1.2.1",
"vitest": "^4.0.18"
} }
} }

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { usePermissions } from './usePermissions'
import { useAuthStore } from '@/store/authStore'
import type { User } from '@/types'
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
account_id: 'acct-1',
account_role: 'engineer',
created_at: '2026-01-01T00:00:00Z',
last_login: null,
...overrides,
}
}
describe('usePermissions', () => {
beforeEach(() => {
useAuthStore.setState({ user: null, token: null, isAuthenticated: false })
})
describe('when not authenticated', () => {
it('defaults to viewer role', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.effectiveRole).toBe('viewer')
expect(result.current.isViewer).toBe(true)
})
it('cannot create trees', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canCreateTrees).toBe(false)
})
it('cannot create steps', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canCreateSteps).toBe(false)
})
it('cannot delete trees', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canDeleteTree()).toBe(false)
})
})
describe('viewer role', () => {
beforeEach(() => {
useAuthStore.setState({ user: makeUser({ role: 'viewer', account_role: 'viewer' }) })
})
it('has viewer effective role', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.effectiveRole).toBe('viewer')
expect(result.current.isViewer).toBe(true)
})
it('cannot create trees', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canCreateTrees).toBe(false)
})
it('cannot edit any tree', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditTree({ author_id: 'user-1' })).toBe(false)
})
it('cannot manage categories', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canManageCategories).toBe(false)
})
})
describe('engineer role', () => {
beforeEach(() => {
useAuthStore.setState({ user: makeUser() })
})
it('has engineer effective role', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.effectiveRole).toBe('engineer')
expect(result.current.isEngineer).toBe(true)
})
it('can create trees', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canCreateTrees).toBe(true)
})
it('can edit own tree', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditTree({ author_id: 'user-1' })).toBe(true)
})
it('cannot edit other user tree', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditTree({ author_id: 'other-user' })).toBe(false)
})
it('cannot delete trees', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canDeleteTree()).toBe(false)
})
it('can edit own step', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditStep({ created_by: 'user-1' })).toBe(true)
})
it('cannot edit other step', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditStep({ created_by: 'other' })).toBe(false)
})
it('cannot manage categories', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canManageCategories).toBe(false)
})
})
describe('owner role', () => {
beforeEach(() => {
useAuthStore.setState({ user: makeUser({ account_role: 'owner' }) })
})
it('has owner effective role', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.effectiveRole).toBe('owner')
expect(result.current.isAccountOwner).toBe(true)
})
it('can edit account tree', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditTree({ author_id: 'other', account_id: 'acct-1' })).toBe(true)
})
it('cannot edit other account tree', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditTree({ author_id: 'other', account_id: 'other-acct' })).toBe(false)
})
it('can manage categories', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canManageCategories).toBe(true)
})
it('cannot manage global categories', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canManageGlobalCategories).toBe(false)
})
})
describe('super admin', () => {
beforeEach(() => {
useAuthStore.setState({ user: makeUser({ is_super_admin: true }) })
})
it('has super_admin effective role', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.effectiveRole).toBe('super_admin')
expect(result.current.isSuperAdmin).toBe(true)
})
it('can edit any tree', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditTree({ author_id: 'anyone', account_id: 'any' })).toBe(true)
})
it('can delete trees', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canDeleteTree()).toBe(true)
})
it('can edit any step', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canEditStep({ created_by: 'anyone' })).toBe(true)
})
it('can manage global categories', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canManageGlobalCategories).toBe(true)
})
it('can manage account', () => {
const { result } = renderHook(() => usePermissions())
expect(result.current.canManageAccount).toBe(true)
})
})
})

View File

@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useTreeValidation } from './useTreeValidation'
import type { TreeStructure } from '@/types'
function makeDecisionNode(overrides: Partial<TreeStructure> = {}): TreeStructure {
return {
id: 'root',
type: 'decision',
question: 'Is it working?',
options: [
{ label: 'Yes', next_node_id: 'yes' },
{ label: 'No', next_node_id: 'no' },
],
children: [
{ id: 'yes', type: 'solution', title: 'Great', description: 'All good' },
{ id: 'no', type: 'action', title: 'Fix it', description: 'Try restarting' },
],
...overrides,
} as TreeStructure
}
function makeSolutionNode(overrides: Partial<TreeStructure> = {}): TreeStructure {
return {
id: 'root',
type: 'solution',
title: 'Done',
description: 'Problem solved',
...overrides,
} as TreeStructure
}
describe('useTreeValidation', () => {
describe('name validation', () => {
it('requires a name', () => {
const { result } = renderHook(() => useTreeValidation('', null, makeSolutionNode()))
expect(result.current.canPublish).toBe(false)
expect(result.current.errors.some(e => e.field === 'name')).toBe(true)
})
it('rejects whitespace-only name', () => {
const { result } = renderHook(() => useTreeValidation(' ', null, makeSolutionNode()))
expect(result.current.canPublish).toBe(false)
})
it('rejects name over 255 characters', () => {
const longName = 'a'.repeat(256)
const { result } = renderHook(() => useTreeValidation(longName, null, makeSolutionNode()))
expect(result.current.canPublish).toBe(false)
expect(result.current.errors.some(e => e.message.includes('255'))).toBe(true)
})
it('accepts valid name', () => {
const { result } = renderHook(() => useTreeValidation('My Tree', 'desc', makeSolutionNode()))
expect(result.current.canPublish).toBe(true)
})
})
describe('structure validation', () => {
it('requires tree structure', () => {
const { result } = renderHook(() => useTreeValidation('My Tree', null, null))
expect(result.current.canPublish).toBe(false)
expect(result.current.errors.some(e => e.field === 'tree_structure')).toBe(true)
})
it('requires root node type', () => {
const node = { id: 'root' } as TreeStructure
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
expect(result.current.errors.some(e => e.field.includes('type'))).toBe(true)
})
it('requires decision node question', () => {
const node = makeDecisionNode({ question: '' })
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
expect(result.current.errors.some(e => e.message.includes('question'))).toBe(true)
})
it('requires decision node options', () => {
const node = makeDecisionNode({ options: [] })
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
})
it('validates option labels', () => {
const node = makeDecisionNode({
options: [{ label: '', next_node_id: 'n1' }],
})
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
expect(result.current.errors.some(e => e.field.includes('options'))).toBe(true)
})
it('requires action node title', () => {
const node = { id: 'root', type: 'action', title: '', description: 'desc' } as TreeStructure
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
})
it('requires action node description', () => {
const node = { id: 'root', type: 'action', title: 'Title', description: '' } as TreeStructure
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
})
it('requires solution node title', () => {
const node = makeSolutionNode({ title: '' })
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
})
it('requires solution node description', () => {
const node = makeSolutionNode({ description: '' })
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
})
it('validates child node types', () => {
const node = makeDecisionNode({
children: [{ id: 'c1' } as TreeStructure],
})
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
})
it('validates child decision nodes need question', () => {
const node = makeDecisionNode({
children: [
{ id: 'c1', type: 'decision', question: '' } as TreeStructure,
{ id: 'c2', type: 'solution', title: 'Done', description: 'Done' } as TreeStructure,
],
})
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
})
it('validates child action/solution need title', () => {
const node = makeDecisionNode({
children: [
{ id: 'c1', type: 'action', title: '' } as TreeStructure,
{ id: 'c2', type: 'solution', title: 'Done', description: 'Done' } as TreeStructure,
],
})
const { result } = renderHook(() => useTreeValidation('Tree', null, node))
expect(result.current.canPublish).toBe(false)
})
})
describe('warnings', () => {
it('warns when description is empty', () => {
const { result } = renderHook(() => useTreeValidation('Tree', '', makeSolutionNode()))
expect(result.current.warnings.some(w => w.field === 'description')).toBe(true)
})
it('warns when description is null', () => {
const { result } = renderHook(() => useTreeValidation('Tree', null, makeSolutionNode()))
expect(result.current.warnings.some(w => w.field === 'description')).toBe(true)
})
it('no description warning when provided', () => {
const { result } = renderHook(() => useTreeValidation('Tree', 'A description', makeSolutionNode()))
expect(result.current.warnings.some(w => w.field === 'description')).toBe(false)
})
it('warns when decision has fewer than 2 children', () => {
const node = makeDecisionNode({
children: [{ id: 'c1', type: 'solution', title: 'X', description: 'Y' } as TreeStructure],
})
const { result } = renderHook(() => useTreeValidation('Tree', 'desc', node))
expect(result.current.warnings.some(w => w.field === 'tree_structure')).toBe(true)
})
})
describe('valid trees', () => {
it('accepts complete decision tree', () => {
const { result } = renderHook(() => useTreeValidation('My Tree', 'A tree', makeDecisionNode()))
expect(result.current.canPublish).toBe(true)
expect(result.current.errors).toHaveLength(0)
})
it('accepts complete solution tree', () => {
const { result } = renderHook(() => useTreeValidation('My Tree', 'desc', makeSolutionNode()))
expect(result.current.canPublish).toBe(true)
expect(result.current.errors).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { cn } from './utils'
describe('cn', () => {
it('merges class names', () => {
expect(cn('foo', 'bar')).toBe('foo bar')
})
it('handles conditional classes', () => {
expect(cn('base', false && 'hidden', 'visible')).toBe('base visible')
})
it('deduplicates tailwind classes', () => {
expect(cn('p-4', 'p-2')).toBe('p-2')
})
it('handles undefined and null', () => {
expect(cn('foo', undefined, null, 'bar')).toBe('foo bar')
})
it('handles empty inputs', () => {
expect(cn()).toBe('')
})
it('merges conflicting tailwind utilities', () => {
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
})
})

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useUserPreferencesStore } from './userPreferencesStore'
describe('userPreferencesStore', () => {
beforeEach(() => {
// Reset store to defaults
useUserPreferencesStore.setState({
defaultExportFormat: 'markdown',
treeLibraryView: 'grid',
treeLibrarySortBy: 'usage_count',
})
})
it('has correct defaults', () => {
const state = useUserPreferencesStore.getState()
expect(state.defaultExportFormat).toBe('markdown')
expect(state.treeLibraryView).toBe('grid')
expect(state.treeLibrarySortBy).toBe('usage_count')
})
it('sets export format', () => {
useUserPreferencesStore.getState().setDefaultExportFormat('html')
expect(useUserPreferencesStore.getState().defaultExportFormat).toBe('html')
})
it('sets tree library view', () => {
useUserPreferencesStore.getState().setTreeLibraryView('list')
expect(useUserPreferencesStore.getState().treeLibraryView).toBe('list')
})
it('sets tree library view to table', () => {
useUserPreferencesStore.getState().setTreeLibraryView('table')
expect(useUserPreferencesStore.getState().treeLibraryView).toBe('table')
})
it('sets sort by', () => {
useUserPreferencesStore.getState().setTreeLibrarySortBy('name')
expect(useUserPreferencesStore.getState().treeLibrarySortBy).toBe('name')
})
it('sets sort by to created_at', () => {
useUserPreferencesStore.getState().setTreeLibrarySortBy('created_at')
expect(useUserPreferencesStore.getState().treeLibrarySortBy).toBe('created_at')
})
})

View File

@@ -0,0 +1,16 @@
import '@testing-library/jest-dom/vitest'
// Mock localStorage for zustand persist middleware
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (index: number) => Object.keys(store)[index] ?? null,
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })

View File

@@ -29,5 +29,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx", "src/test"]
} }

View File

@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
@@ -10,6 +11,12 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
}, },
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {