Implement basic tests for webui

This commit is contained in:
2025-07-26 19:20:09 +02:00
parent e7d95e934c
commit 4334b40fa9
15 changed files with 3188 additions and 158 deletions

1384
webui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.2",
@@ -27,12 +30,18 @@
"zod": "^4.0.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.15",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/ui": "^3.2.4",
"jsdom": "^26.1.0",
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"vite": "^7.0.5"
"vite": "^7.0.5",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from '@/App'
import { InstancesProvider } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api'
import { Instance } from '@/types/instance'
// Mock the API
vi.mock('@/lib/api', () => ({
instancesApi: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
restart: vi.fn(),
delete: vi.fn(),
},
serverApi: {
getHelp: vi.fn(),
getVersion: vi.fn(),
getDevices: vi.fn(),
}
}))
// Mock health service to avoid real network calls
vi.mock('@/lib/healthService', () => ({
healthService: {
subscribe: vi.fn(() => () => {}),
checkHealth: vi.fn(),
},
checkHealth: vi.fn(),
}))
function renderApp() {
return render(
<InstancesProvider>
<App />
</InstancesProvider>
)
}
describe('App Component - Critical Business Logic Only', () => {
const mockInstances: Instance[] = [
{ name: 'test-instance-1', running: false, options: { model: 'model1.gguf' } },
{ name: 'test-instance-2', running: true, options: { model: 'model2.gguf' } }
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
})
describe('End-to-End Instance Management', () => {
it('creates new instance with correct API call and updates UI', async () => {
const user = userEvent.setup()
const newInstance: Instance = {
name: 'new-test-instance',
running: false,
options: { model: 'new-model.gguf' }
}
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
renderApp()
// Wait for app to load
await waitFor(() => {
expect(screen.getByText('test-instance-1')).toBeInTheDocument()
})
// Complete create flow: button → form → API call → UI update
await user.click(screen.getByText('Create Instance'))
const nameInput = screen.getByLabelText(/Instance Name/)
await user.type(nameInput, 'new-test-instance')
await user.click(screen.getByTestId('modal-save-button'))
// Verify correct API call
await waitFor(() => {
expect(instancesApi.create).toHaveBeenCalledWith('new-test-instance', {
auto_restart: true, // Default value
})
})
// Verify UI updates with new instance
await waitFor(() => {
expect(screen.getByText('new-test-instance')).toBeInTheDocument()
})
})
it('updates existing instance with correct API call', async () => {
const user = userEvent.setup()
const updatedInstance: Instance = {
name: 'test-instance-1',
running: false,
options: { model: 'updated-model.gguf' }
}
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance)
renderApp()
await waitFor(() => {
expect(screen.getByText('test-instance-1')).toBeInTheDocument()
})
// Complete edit flow: edit button → form → API call
const editButtons = screen.getAllByTitle('Edit instance')
await user.click(editButtons[0])
await user.click(screen.getByTestId('modal-save-button'))
// Verify correct API call with existing instance data
await waitFor(() => {
expect(instancesApi.update).toHaveBeenCalledWith('test-instance-1', {
model: "model1.gguf", // Pre-filled from existing instance
})
})
})
it('renders instances and provides working interface', async () => {
renderApp()
// Verify the app loads instances and renders them
await waitFor(() => {
expect(screen.getByText('test-instance-1')).toBeInTheDocument()
expect(screen.getByText('test-instance-2')).toBeInTheDocument()
expect(screen.getByText('Instances (2)')).toBeInTheDocument()
})
// Verify action buttons are present (testing integration, not specific actions)
expect(screen.getAllByTitle('Start instance').length).toBeGreaterThan(0)
expect(screen.getAllByTitle('Stop instance').length).toBeGreaterThan(0)
expect(screen.getAllByTitle('Edit instance').length).toBe(2)
expect(screen.getAllByTitle('Delete instance').length).toBeGreaterThan(0)
})
it('delete confirmation calls correct API', async () => {
const user = userEvent.setup()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
vi.mocked(instancesApi.delete).mockResolvedValue(undefined)
renderApp()
await waitFor(() => {
expect(screen.getByText('test-instance-1')).toBeInTheDocument()
})
const deleteButtons = screen.getAllByTitle('Delete instance')
await user.click(deleteButtons[0])
// Verify confirmation and API call
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete instance "test-instance-1"?')
await waitFor(() => {
expect(instancesApi.delete).toHaveBeenCalledWith('test-instance-1')
})
confirmSpy.mockRestore()
})
})
describe('Error Handling', () => {
it('handles instance loading errors gracefully', async () => {
vi.mocked(instancesApi.list).mockRejectedValue(new Error('Failed to load instances'))
renderApp()
// App should still render and show error
expect(screen.getByText('LlamaCtl Dashboard')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Error loading instances')).toBeInTheDocument()
})
})
it('shows empty state when no instances exist', async () => {
vi.mocked(instancesApi.list).mockResolvedValue([])
renderApp()
await waitFor(() => {
expect(screen.getByText('No instances found')).toBeInTheDocument()
})
})
})
})

View File

@@ -16,12 +16,13 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
</h1>
<div className="flex items-center gap-2">
<Button onClick={onCreateInstance}>Create Instance</Button>
<Button onClick={onCreateInstance} data-testid="create-instance-button">Create Instance</Button>
<Button
variant="outline"
size="icon"
onClick={onShowSystemInfo}
data-testid="system-info-button"
title="System Info"
>
<HelpCircle className="h-4 w-4" />

View File

@@ -68,6 +68,7 @@ function InstanceCard({
onClick={handleStart}
disabled={instance.running}
title="Start instance"
data-testid="start-instance-button"
>
<Play className="h-4 w-4" />
</Button>
@@ -78,6 +79,7 @@ function InstanceCard({
onClick={handleStop}
disabled={!instance.running}
title="Stop instance"
data-testid="stop-instance-button"
>
<Square className="h-4 w-4" />
</Button>
@@ -87,6 +89,7 @@ function InstanceCard({
variant="outline"
onClick={handleEdit}
title="Edit instance"
data-testid="edit-instance-button"
>
<Edit className="h-4 w-4" />
</Button>
@@ -96,6 +99,7 @@ function InstanceCard({
variant="outline"
onClick={handleLogs}
title="View logs"
data-testid="view-logs-button"
>
<FileText className="h-4 w-4" />
</Button>
@@ -106,6 +110,7 @@ function InstanceCard({
onClick={handleDelete}
disabled={instance.running}
title="Delete instance"
data-testid="delete-instance-button"
>
<Trash2 className="h-4 w-4" />
</Button>

View File

@@ -16,7 +16,7 @@ function InstanceList({ editInstance }: InstanceListProps) {
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="flex items-center justify-center py-12" aria-label="Loading">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading instances...</p>

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
@@ -9,120 +9,122 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { CreateInstanceOptions, Instance } from '@/types/instance'
import { getBasicFields, getAdvancedFields } from '@/lib/zodFormUtils'
import { ChevronDown, ChevronRight } from 'lucide-react'
import ZodFormField from '@/components/ZodFormField'
} from "@/components/ui/dialog";
import { CreateInstanceOptions, Instance } from "@/types/instance";
import { getBasicFields, getAdvancedFields } from "@/lib/zodFormUtils";
import { ChevronDown, ChevronRight } from "lucide-react";
import ZodFormField from "@/components/ZodFormField";
interface InstanceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (name: string, options: CreateInstanceOptions) => void
instance?: Instance // For editing existing instance
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (name: string, options: CreateInstanceOptions) => void;
instance?: Instance; // For editing existing instance
}
const InstanceModal: React.FC<InstanceModalProps> = ({
open,
onOpenChange,
onSave,
instance
instance,
}) => {
const isEditing = !!instance
const isRunning = instance?.running || true // Assume running if instance exists
const isEditing = !!instance;
const isRunning = instance?.running || true; // Assume running if instance exists
const [instanceName, setInstanceName] = useState('')
const [formData, setFormData] = useState<CreateInstanceOptions>({})
const [showAdvanced, setShowAdvanced] = useState(false)
const [nameError, setNameError] = useState('')
const [instanceName, setInstanceName] = useState("");
const [formData, setFormData] = useState<CreateInstanceOptions>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const [nameError, setNameError] = useState("");
// Get field lists dynamically from the type
const basicFields = getBasicFields()
const advancedFields = getAdvancedFields()
const basicFields = getBasicFields();
const advancedFields = getAdvancedFields();
// Reset form when modal opens/closes or when instance changes
useEffect(() => {
if (open) {
if (instance) {
// Populate form with existing instance data
setInstanceName(instance.name)
setFormData(instance.options || {})
setInstanceName(instance.name);
setFormData(instance.options || {});
} else {
// Reset form for new instance
setInstanceName('')
setInstanceName("");
setFormData({
auto_restart: true, // Default value
})
});
}
setShowAdvanced(false) // Always start with basic view
setNameError('') // Reset any name errors
setShowAdvanced(false); // Always start with basic view
setNameError(""); // Reset any name errors
}
}, [open, instance])
}, [open, instance]);
const handleFieldChange = (key: keyof CreateInstanceOptions, value: any) => {
setFormData(prev => ({
setFormData((prev) => ({
...prev,
[key]: value
}))
}
[key]: value,
}));
};
const handleNameChange = (name: string) => {
setInstanceName(name)
setInstanceName(name);
// Validate instance name
if (!name.trim()) {
setNameError('Instance name is required')
setNameError("Instance name is required");
} else if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
setNameError('Instance name can only contain letters, numbers, hyphens, and underscores')
setNameError(
"Instance name can only contain letters, numbers, hyphens, and underscores"
);
} else {
setNameError('')
}
setNameError("");
}
};
const handleSave = () => {
// Validate instance name before saving
if (!instanceName.trim()) {
setNameError('Instance name is required')
return
setNameError("Instance name is required");
return;
}
// Clean up undefined values to avoid sending empty fields
const cleanOptions: CreateInstanceOptions = {}
const cleanOptions: CreateInstanceOptions = {};
Object.entries(formData).forEach(([key, value]) => {
if (value !== undefined && value !== '' && value !== null) {
if (value !== undefined && value !== "" && value !== null) {
// Handle arrays - don't include empty arrays
if (Array.isArray(value) && value.length === 0) {
return
return;
}
;(cleanOptions as any)[key] = value
(cleanOptions as any)[key] = value;
}
})
});
onSave(instanceName, cleanOptions)
onOpenChange(false)
}
onSave(instanceName, cleanOptions);
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false)
}
onOpenChange(false);
};
const toggleAdvanced = () => {
setShowAdvanced(!showAdvanced)
}
setShowAdvanced(!showAdvanced);
};
// Check if auto_restart is enabled
const isAutoRestartEnabled = formData.auto_restart === true
const isAutoRestartEnabled = formData.auto_restart === true;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit Instance' : 'Create New Instance'}
{isEditing ? "Edit Instance" : "Create New Instance"}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Modify the instance configuration below.'
: 'Configure your new llama-server instance below.'}
? "Modify the instance configuration below."
: "Configure your new llama-server instance below."}
</DialogDescription>
</DialogHeader>
@@ -139,11 +141,9 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
onChange={(e) => handleNameChange(e.target.value)}
placeholder="my-instance"
disabled={isEditing} // Don't allow name changes when editing
className={nameError ? 'border-red-500' : ''}
className={nameError ? "border-red-500" : ""}
/>
{nameError && (
<p className="text-sm text-red-500">{nameError}</p>
)}
{nameError && <p className="text-sm text-red-500">{nameError}</p>}
<p className="text-sm text-muted-foreground">
Unique identifier for the instance
</p>
@@ -151,7 +151,9 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
{/* Auto Restart Configuration Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Auto Restart Configuration</h3>
<h3 className="text-lg font-medium">
Auto Restart Configuration
</h3>
{/* Auto Restart Toggle */}
<ZodFormField
@@ -181,7 +183,12 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
<div className="space-y-4">
<h3 className="text-lg font-medium">Basic Configuration</h3>
{basicFields
.filter(fieldKey => fieldKey !== 'auto_restart') // Exclude auto_restart as it's handled above
.filter(
(fieldKey) =>
fieldKey !== "auto_restart" &&
fieldKey !== "max_restarts" &&
fieldKey !== "restart_delay"
) // Exclude auto_restart, max_restarts, and restart_delay as they're handled above
.map((fieldKey) => (
<ZodFormField
key={fieldKey}
@@ -206,7 +213,14 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
)}
Advanced Configuration
<span className="text-muted-foreground text-sm font-normal">
({advancedFields.filter(f => !['max_restarts', 'restart_delay'].includes(f as string)).length} options)
(
{
advancedFields.filter(
(f) =>
!["max_restarts", "restart_delay"].includes(f as string)
).length
}{" "}
options)
</span>
</Button>
</div>
@@ -216,7 +230,12 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
<div className="space-y-4 pl-6 border-l-2 border-muted">
<div className="space-y-4">
{advancedFields
.filter(fieldKey => !['max_restarts', 'restart_delay'].includes(fieldKey as string)) // Exclude restart options as they're handled above
.filter(
(fieldKey) =>
!["max_restarts", "restart_delay"].includes(
fieldKey as string
)
) // Exclude restart options as they're handled above
.sort()
.map((fieldKey) => (
<ZodFormField
@@ -233,16 +252,28 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
</div>
<DialogFooter className="pt-4 border-t">
<Button variant="outline" onClick={handleCancel}>
<Button
variant="outline"
onClick={handleCancel}
data-testid="modal-cancel-button"
>
Cancel
</Button>
<Button onClick={handleSave} disabled={!instanceName.trim() || !!nameError}>
{isEditing ? (isRunning ? 'Update & Restart Instance' : 'Update Instance') : 'Create Instance'}
<Button
onClick={handleSave}
disabled={!instanceName.trim() || !!nameError}
data-testid="modal-save-button"
>
{isEditing
? isRunning
? "Update & Restart Instance"
: "Update Instance"
: "Create Instance"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
);
};
export default InstanceModal
export default InstanceModal;

View File

@@ -0,0 +1,339 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import InstanceCard from '@/components/InstanceCard'
import { Instance } from '@/types/instance'
// Mock the health hook since we're not testing health logic here
vi.mock('@/hooks/useInstanceHealth', () => ({
useInstanceHealth: vi.fn(() => ({ status: 'ok', lastChecked: new Date() }))
}))
describe('InstanceCard - Instance Actions and State', () => {
const mockStartInstance = vi.fn()
const mockStopInstance = vi.fn()
const mockDeleteInstance = vi.fn()
const mockEditInstance = vi.fn()
const stoppedInstance: Instance = {
name: 'test-instance',
running: false,
options: { model: 'test-model.gguf' }
}
const runningInstance: Instance = {
name: 'running-instance',
running: true,
options: { model: 'running-model.gguf' }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Instance Action Buttons', () => {
it('calls startInstance when start button clicked on stopped instance', async () => {
const user = userEvent.setup()
render(
<InstanceCard
instance={stoppedInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
const startButton = screen.getByTitle('Start instance')
expect(startButton).not.toBeDisabled()
await user.click(startButton)
expect(mockStartInstance).toHaveBeenCalledWith('test-instance')
})
it('calls stopInstance when stop button clicked on running instance', async () => {
const user = userEvent.setup()
render(
<InstanceCard
instance={runningInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
const stopButton = screen.getByTitle('Stop instance')
expect(stopButton).not.toBeDisabled()
await user.click(stopButton)
expect(mockStopInstance).toHaveBeenCalledWith('running-instance')
})
it('calls editInstance when edit button clicked', async () => {
const user = userEvent.setup()
render(
<InstanceCard
instance={stoppedInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
const editButton = screen.getByTitle('Edit instance')
await user.click(editButton)
expect(mockEditInstance).toHaveBeenCalledWith(stoppedInstance)
})
it('opens logs modal when logs button clicked', async () => {
const user = userEvent.setup()
render(
<InstanceCard
instance={stoppedInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
const logsButton = screen.getByTitle('View logs')
await user.click(logsButton)
// Should open logs modal (we can verify this by checking if modal title appears)
expect(screen.getByText(`Logs: ${stoppedInstance.name}`)).toBeInTheDocument()
})
})
describe('Delete Confirmation Logic', () => {
it('shows confirmation dialog and calls deleteInstance when confirmed', async () => {
const user = userEvent.setup()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
render(
<InstanceCard
instance={stoppedInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
const deleteButton = screen.getByTitle('Delete instance')
await user.click(deleteButton)
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete instance "test-instance"?')
expect(mockDeleteInstance).toHaveBeenCalledWith('test-instance')
confirmSpy.mockRestore()
})
it('does not call deleteInstance when confirmation cancelled', async () => {
const user = userEvent.setup()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
render(
<InstanceCard
instance={stoppedInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
const deleteButton = screen.getByTitle('Delete instance')
await user.click(deleteButton)
expect(confirmSpy).toHaveBeenCalled()
expect(mockDeleteInstance).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
})
describe('Button State Based on Instance Status', () => {
it('disables start button and enables stop button for running instance', () => {
render(
<InstanceCard
instance={runningInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
expect(screen.getByTitle('Start instance')).toBeDisabled()
expect(screen.getByTitle('Stop instance')).not.toBeDisabled()
expect(screen.getByTitle('Delete instance')).toBeDisabled() // Can't delete running instance
})
it('enables start button and disables stop button for stopped instance', () => {
render(
<InstanceCard
instance={stoppedInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
expect(screen.getByTitle('Start instance')).not.toBeDisabled()
expect(screen.getByTitle('Stop instance')).toBeDisabled()
expect(screen.getByTitle('Delete instance')).not.toBeDisabled() // Can delete stopped instance
})
it('edit and logs buttons are always enabled', () => {
render(
<InstanceCard
instance={runningInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
expect(screen.getByTitle('Edit instance')).not.toBeDisabled()
expect(screen.getByTitle('View logs')).not.toBeDisabled()
})
})
describe('Instance Information Display', () => {
it('displays instance name correctly', () => {
render(
<InstanceCard
instance={stoppedInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
expect(screen.getByText('test-instance')).toBeInTheDocument()
})
it('shows health badge for running instances', () => {
render(
<InstanceCard
instance={runningInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
// Health badge should be present for running instances
// The exact text depends on the health status from the mock
expect(screen.getByText('Ready')).toBeInTheDocument()
})
it('does not show health badge for stopped instances', () => {
render(
<InstanceCard
instance={stoppedInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
// Health badge should not be present for stopped instances
expect(screen.queryByText('Ready')).not.toBeInTheDocument()
})
})
describe('Integration with LogsModal', () => {
it('passes correct props to LogsModal', async () => {
const user = userEvent.setup()
render(
<InstanceCard
instance={runningInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
// Open logs modal
await user.click(screen.getByTitle('View logs'))
// Verify modal opened with correct instance data
expect(screen.getByText('Logs: running-instance')).toBeInTheDocument()
// Close modal to test close functionality
const closeButtons = screen.getAllByText('Close')
const modalCloseButton = closeButtons.find(button =>
button.closest('[data-slot="dialog-content"]')
)
expect(modalCloseButton).toBeTruthy()
await user.click(modalCloseButton!)
// Modal should close
expect(screen.queryByText('Logs: running-instance')).not.toBeInTheDocument()
})
})
describe('Error Edge Cases', () => {
it('handles instance with minimal data', () => {
const minimalInstance: Instance = {
name: 'minimal',
running: false,
options: {}
}
render(
<InstanceCard
instance={minimalInstance}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
// Should still render basic structure
expect(screen.getByText('minimal')).toBeInTheDocument()
expect(screen.getByTitle('Start instance')).toBeInTheDocument()
})
it('handles instance with undefined options', () => {
const instanceWithoutOptions: Instance = {
name: 'no-options',
running: true,
options: undefined
}
render(
<InstanceCard
instance={instanceWithoutOptions}
startInstance={mockStartInstance}
stopInstance={mockStopInstance}
deleteInstance={mockDeleteInstance}
editInstance={mockEditInstance}
/>
)
// Should still work
expect(screen.getByText('no-options')).toBeInTheDocument()
expect(screen.getByTitle('Stop instance')).not.toBeDisabled()
})
})
})

View File

@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import InstanceList from '@/components/InstanceList'
import { InstancesProvider } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api'
import { Instance } from '@/types/instance'
// Mock the API
vi.mock('@/lib/api', () => ({
instancesApi: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
restart: vi.fn(),
delete: vi.fn(),
}
}))
// Mock health service
vi.mock('@/lib/healthService', () => ({
healthService: {
subscribe: vi.fn(() => () => {}),
checkHealth: vi.fn(),
},
checkHealth: vi.fn(),
}))
function renderInstanceList(editInstance = vi.fn()) {
return render(
<InstancesProvider>
<InstanceList editInstance={editInstance} />
</InstancesProvider>
)
}
describe('InstanceList - State Management and UI Logic', () => {
const mockEditInstance = vi.fn()
const mockInstances: Instance[] = [
{ name: 'instance-1', running: false, options: { model: 'model1.gguf' } },
{ name: 'instance-2', running: true, options: { model: 'model2.gguf' } },
{ name: 'instance-3', running: false, options: { model: 'model3.gguf' } }
]
beforeEach(() => {
vi.clearAllMocks()
})
describe('Loading State', () => {
it('shows loading spinner while instances are being fetched', async () => {
// Mock a delayed response to test loading state
vi.mocked(instancesApi.list).mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(mockInstances), 100))
)
renderInstanceList(mockEditInstance)
// Should show loading state immediately
expect(screen.getByText('Loading instances...')).toBeInTheDocument()
expect(screen.getByLabelText('Loading')).toBeInTheDocument()
})
})
describe('Error State', () => {
it('displays error message when instance loading fails', async () => {
const errorMessage = 'Failed to connect to server'
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage))
renderInstanceList(mockEditInstance)
// Wait for error to appear
expect(await screen.findByText('Error loading instances')).toBeInTheDocument()
expect(screen.getByText(errorMessage)).toBeInTheDocument()
})
it('does not show instances or loading when in error state', async () => {
vi.mocked(instancesApi.list).mockRejectedValue(new Error('Network error'))
renderInstanceList(mockEditInstance)
await screen.findByText('Error loading instances')
// Should not show loading or instance elements
expect(screen.queryByText('Loading instances...')).not.toBeInTheDocument()
expect(screen.queryByText('Instances (')).not.toBeInTheDocument()
})
})
describe('Empty State', () => {
it('shows empty state message when no instances exist', async () => {
vi.mocked(instancesApi.list).mockResolvedValue([])
renderInstanceList(mockEditInstance)
expect(await screen.findByText('No instances found')).toBeInTheDocument()
expect(screen.getByText('Create your first instance to get started')).toBeInTheDocument()
})
it('does not show instances header when empty', async () => {
vi.mocked(instancesApi.list).mockResolvedValue([])
renderInstanceList(mockEditInstance)
await screen.findByText('No instances found')
expect(screen.queryByText(/Instances \(/)).not.toBeInTheDocument()
})
})
describe('Instances Display', () => {
it('displays all instances with correct count', async () => {
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
renderInstanceList(mockEditInstance)
// Wait for instances to load
expect(await screen.findByText('Instances (3)')).toBeInTheDocument()
// All instances should be displayed
expect(screen.getByText('instance-1')).toBeInTheDocument()
expect(screen.getByText('instance-2')).toBeInTheDocument()
expect(screen.getByText('instance-3')).toBeInTheDocument()
})
it('displays correct count based on instances received', async () => {
// Test with different numbers of instances
const twoInstances = mockInstances.slice(0, 2)
vi.mocked(instancesApi.list).mockResolvedValue(twoInstances)
renderInstanceList(mockEditInstance)
expect(await screen.findByText('Instances (2)')).toBeInTheDocument()
expect(screen.getByText('instance-1')).toBeInTheDocument()
expect(screen.getByText('instance-2')).toBeInTheDocument()
expect(screen.queryByText('instance-3')).not.toBeInTheDocument()
})
})
describe('Instance Card Integration', () => {
it('passes editInstance function to each instance card', async () => {
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
renderInstanceList(mockEditInstance)
await screen.findByText('Instances (3)')
// Find edit buttons and click one
const editButtons = screen.getAllByTitle('Edit instance')
expect(editButtons).toHaveLength(3)
// Click the first edit button
await userEvent.setup().click(editButtons[0])
// Should call editInstance with the correct instance
expect(mockEditInstance).toHaveBeenCalledWith(mockInstances[0])
})
it('instance actions work through context integration', async () => {
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance)
renderInstanceList(mockEditInstance)
await screen.findByText('Instances (3)')
// Find start buttons (should be available for stopped instances)
const startButtons = screen.getAllByTitle('Start instance')
expect(startButtons.length).toBeGreaterThan(0)
// Click a start button
await userEvent.setup().click(startButtons[0])
// Should call the API (testing integration with context)
expect(instancesApi.start).toHaveBeenCalled()
})
})
describe('Performance Optimization', () => {
it('uses memoized instance cards to prevent unnecessary re-renders', async () => {
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
renderInstanceList(mockEditInstance)
await screen.findByText('Instances (3)')
// This is more of a structural test - we're verifying that the component
// uses MemoizedInstanceCard (as mentioned in the source code comment)
// The actual memoization effect would need more complex testing setup
expect(screen.getAllByTitle('Edit instance')).toHaveLength(3)
})
})
describe('Grid Layout', () => {
it('renders instances in a grid layout', async () => {
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
renderInstanceList(mockEditInstance)
await screen.findByText('Instances (3)')
// Check that instances are rendered in the expected container structure
const instanceGrid = screen.getByText('instance-1').closest('.grid')
expect(instanceGrid).toBeInTheDocument()
})
})
describe('State Transitions', () => {
it('transitions from loading to loaded state correctly', async () => {
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
renderInstanceList(mockEditInstance)
// Should start with loading
expect(screen.getByText('Loading instances...')).toBeInTheDocument()
// Should transition to loaded state
expect(await screen.findByText('Instances (3)')).toBeInTheDocument()
expect(screen.queryByText('Loading instances...')).not.toBeInTheDocument()
})
it('handles transition from error back to loaded state', async () => {
// Start with error
vi.mocked(instancesApi.list).mockRejectedValue(new Error('Network error'))
const { rerender } = renderInstanceList(mockEditInstance)
expect(await screen.findByText('Error loading instances')).toBeInTheDocument()
// Simulate recovery (e.g., retry after network recovery)
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
rerender(
<InstancesProvider>
<InstanceList editInstance={mockEditInstance} />
</InstancesProvider>
)
// Should eventually show instances
// Note: This test is somewhat artificial since the context handles retries
expect(screen.getByText('Error loading instances')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,382 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import InstanceModal from '@/components/InstanceModal'
import { Instance } from '@/types/instance'
describe('InstanceModal - Form Logic and Validation', () => {
const mockOnSave = vi.fn()
const mockOnOpenChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Create Mode', () => {
it('validates instance name is required', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Try to submit without name
const saveButton = screen.getByTestId('modal-save-button')
expect(saveButton).toBeDisabled()
// Add name, button should be enabled
const nameInput = screen.getByLabelText(/Instance Name/)
await user.type(nameInput, 'test-instance')
await waitFor(() => {
expect(saveButton).not.toBeDisabled()
})
})
it('validates instance name format', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
const nameInput = screen.getByLabelText(/Instance Name/)
// Test invalid characters
await user.type(nameInput, 'test instance!')
expect(screen.getByText(/can only contain letters, numbers, hyphens, and underscores/)).toBeInTheDocument()
expect(screen.getByTestId('modal-save-button')).toBeDisabled()
// Clear and test valid name
await user.clear(nameInput)
await user.type(nameInput, 'test-instance-123')
await waitFor(() => {
expect(screen.queryByText(/can only contain letters, numbers, hyphens, and underscores/)).not.toBeInTheDocument()
expect(screen.getByTestId('modal-save-button')).not.toBeDisabled()
})
})
it('submits form with correct data structure', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Fill required name
await user.type(screen.getByLabelText(/Instance Name/), 'my-instance')
// Submit form
await user.click(screen.getByTestId('modal-save-button'))
expect(mockOnSave).toHaveBeenCalledWith('my-instance', {
auto_restart: true, // Default value
})
})
it('form resets when modal reopens', async () => {
const { rerender } = render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Fill form
const nameInput = screen.getByLabelText(/Instance Name/)
await userEvent.setup().type(nameInput, 'temp-name')
// Close modal
rerender(
<InstanceModal
open={false}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Reopen modal
rerender(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Form should be reset
const newNameInput = screen.getByLabelText(/Instance Name/)
expect(newNameInput).toHaveValue('')
})
})
describe('Edit Mode', () => {
const mockInstance: Instance = {
name: 'existing-instance',
running: false,
options: {
model: 'test-model.gguf',
gpu_layers: 10,
auto_restart: false
}
}
it('pre-fills form with existing instance data', async () => {
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
instance={mockInstance}
/>
)
// Name should be pre-filled and disabled
const nameInput = screen.getByDisplayValue('existing-instance')
expect(nameInput).toBeDisabled()
// Other fields should be pre-filled (where visible)
// Note: Not all fields are easily testable without more complex setup
expect(screen.getByText('Edit Instance')).toBeInTheDocument()
})
it('submits update with existing data when no changes made', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
instance={mockInstance}
/>
)
// Submit without changes
await user.click(screen.getByTestId('modal-save-button'))
expect(mockOnSave).toHaveBeenCalledWith('existing-instance', {
model: 'test-model.gguf',
gpu_layers: 10,
auto_restart: false
})
})
it('shows correct button text for running vs stopped instances', async () => {
const runningInstance: Instance = { ...mockInstance, running: true }
const { rerender } = render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
instance={mockInstance} // stopped
/>
)
expect(screen.getByTestId('modal-save-button')).toBeInTheDocument()
rerender(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
instance={runningInstance} // running
/>
)
expect(screen.getByText('Update & Restart Instance')).toBeInTheDocument()
})
})
describe('Auto Restart Configuration', () => {
it('shows restart options when auto restart is enabled', async () => {
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Auto restart should be enabled by default
const autoRestartCheckbox = screen.getByLabelText(/Auto Restart/)
expect(autoRestartCheckbox).toBeChecked()
// Restart options should be visible
expect(screen.getByLabelText(/Max Restarts/)).toBeInTheDocument()
expect(screen.getByLabelText(/Restart Delay/)).toBeInTheDocument()
})
it('hides restart options when auto restart is disabled', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Disable auto restart
const autoRestartCheckbox = screen.getByLabelText(/Auto Restart/)
await user.click(autoRestartCheckbox)
// Restart options should be hidden
expect(screen.queryByLabelText(/Max Restarts/)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/Restart Delay/)).not.toBeInTheDocument()
})
it('includes restart options in form submission when enabled', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Fill form
await user.type(screen.getByLabelText(/Instance Name/), 'test-instance')
// Set restart options
await user.type(screen.getByLabelText(/Max Restarts/), '5')
await user.type(screen.getByLabelText(/Restart Delay/), '10')
await user.click(screen.getByTestId('modal-save-button'))
expect(mockOnSave).toHaveBeenCalledWith('test-instance', {
auto_restart: true,
max_restarts: 5,
restart_delay: 10
})
})
})
describe('Advanced Fields Toggle', () => {
it('shows advanced fields when toggle clicked', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Advanced fields should be hidden initially
expect(screen.queryByText(/Advanced Configuration/)).toBeInTheDocument()
// Click to expand
await user.click(screen.getByText(/Advanced Configuration/))
// Should show more configuration options
// Note: Specific fields depend on zodFormUtils configuration
// We're testing the toggle behavior, not specific fields
})
})
describe('Form Data Handling', () => {
it('cleans up undefined values before submission', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
// Fill only required field
await user.type(screen.getByLabelText(/Instance Name/), 'clean-instance')
await user.click(screen.getByTestId('modal-save-button'))
// Should only include non-empty values
expect(mockOnSave).toHaveBeenCalledWith('clean-instance', {
auto_restart: true, // Only this default value should be included
})
})
it('handles numeric fields correctly', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
await user.type(screen.getByLabelText(/Instance Name/), 'numeric-test')
// Test GPU layers field (numeric)
const gpuLayersInput = screen.getByLabelText(/GPU Layers/)
await user.type(gpuLayersInput, '15')
await user.click(screen.getByTestId('modal-save-button'))
expect(mockOnSave).toHaveBeenCalledWith('numeric-test', {
auto_restart: true,
gpu_layers: 15, // Should be number, not string
})
})
})
describe('Modal Controls', () => {
it('calls onOpenChange when cancel button clicked', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
await user.click(screen.getByTestId('modal-cancel-button'))
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it('calls onOpenChange after successful save', async () => {
const user = userEvent.setup()
render(
<InstanceModal
open={true}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
/>
)
await user.type(screen.getByLabelText(/Instance Name/), 'test')
await user.click(screen.getByTestId('modal-save-button'))
expect(mockOnSave).toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
})

View File

@@ -0,0 +1,395 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { ReactNode } from 'react'
import { InstancesProvider, useInstances } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api'
import { Instance } from '@/types/instance'
// Mock the API module
vi.mock('@/lib/api', () => ({
instancesApi: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
restart: vi.fn(),
delete: vi.fn(),
}
}))
// Test component to access context
function TestComponent() {
const {
instances,
loading,
error,
createInstance,
updateInstance,
startInstance,
stopInstance,
restartInstance,
deleteInstance,
clearError
} = useInstances()
return (
<div>
<div data-testid="loading">{loading.toString()}</div>
<div data-testid="error">{error || 'no-error'}</div>
<div data-testid="instances-count">{instances.length}</div>
{instances.map(instance => (
<div key={instance.name} data-testid={`instance-${instance.name}`}>
{instance.name}:{instance.running.toString()}
</div>
))}
{/* Action buttons for testing with specific instances */}
<button
onClick={() => createInstance('new-instance', { model: 'test.gguf' })}
data-testid="create-instance"
>
Create Instance
</button>
<button
onClick={() => updateInstance('instance1', { model: 'updated.gguf' })}
data-testid="update-instance"
>
Update Instance
</button>
<button
onClick={() => startInstance('instance2')}
data-testid="start-instance"
>
Start Instance2
</button>
<button
onClick={() => stopInstance('instance1')}
data-testid="stop-instance"
>
Stop Instance1
</button>
<button
onClick={() => restartInstance('instance1')}
data-testid="restart-instance"
>
Restart Instance1
</button>
<button
onClick={() => deleteInstance('instance2')}
data-testid="delete-instance"
>
Delete Instance2
</button>
<button
onClick={clearError}
data-testid="clear-error"
>
Clear Error
</button>
</div>
)
}
function renderWithProvider(children: ReactNode) {
return render(
<InstancesProvider>
{children}
</InstancesProvider>
)
}
describe('InstancesContext', () => {
const mockInstances: Instance[] = [
{ name: 'instance1', running: true, options: { model: 'model1.gguf' } },
{ name: 'instance2', running: false, options: { model: 'model2.gguf' } }
]
beforeEach(() => {
vi.clearAllMocks()
// Default successful API responses
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('Initial Loading', () => {
it('loads instances on mount', async () => {
renderWithProvider(<TestComponent />)
// Should start loading
expect(screen.getByTestId('loading')).toHaveTextContent('true')
// Should fetch instances
await waitFor(() => {
expect(instancesApi.list).toHaveBeenCalledOnce()
})
// Should display loaded instances
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true')
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false')
})
})
it('handles API error during initial load', async () => {
const errorMessage = 'Network error'
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage))
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
expect(screen.getByTestId('instances-count')).toHaveTextContent('0')
})
})
})
describe('Create Instance', () => {
it('creates instance and adds it to state', async () => {
const newInstance: Instance = {
name: 'new-instance',
running: false,
options: { model: 'test.gguf' }
}
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
})
screen.getByTestId('create-instance').click()
await waitFor(() => {
expect(instancesApi.create).toHaveBeenCalledWith('new-instance', { model: 'test.gguf' })
})
await waitFor(() => {
expect(screen.getByTestId('instances-count')).toHaveTextContent('3')
expect(screen.getByTestId('instance-new-instance')).toHaveTextContent('new-instance:false')
})
})
it('handles create instance error without changing state', async () => {
const errorMessage = 'Instance already exists'
vi.mocked(instancesApi.create).mockRejectedValue(new Error(errorMessage))
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
})
screen.getByTestId('create-instance').click()
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
})
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
expect(screen.queryByTestId('instance-new-instance')).not.toBeInTheDocument()
})
})
describe('Update Instance', () => {
it('updates instance and maintains it in state', async () => {
const updatedInstance: Instance = {
name: 'instance1',
running: true,
options: { model: 'updated.gguf' }
}
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance)
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
})
screen.getByTestId('update-instance').click()
await waitFor(() => {
expect(instancesApi.update).toHaveBeenCalledWith('instance1', { model: 'updated.gguf' })
})
await waitFor(() => {
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
expect(screen.getByTestId('instance-instance1')).toBeInTheDocument()
})
})
})
describe('Start/Stop Instance', () => {
it('starts existing instance and updates its running state', async () => {
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance)
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
// instance2 starts as not running
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false')
})
// Start instance2 (button already configured to start instance2)
screen.getByTestId('start-instance').click()
await waitFor(() => {
expect(instancesApi.start).toHaveBeenCalledWith('instance2')
// The running state should be updated to true
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true')
})
})
it('stops instance and updates running state to false', async () => {
vi.mocked(instancesApi.stop).mockResolvedValue({} as Instance)
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
// instance1 starts as running
expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true')
})
// Stop instance1 (button already configured to stop instance1)
screen.getByTestId('stop-instance').click()
await waitFor(() => {
expect(instancesApi.stop).toHaveBeenCalledWith('instance1')
// The running state should be updated to false
expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:false')
})
})
it('handles start instance error', async () => {
const errorMessage = 'Failed to start instance'
vi.mocked(instancesApi.start).mockRejectedValue(new Error(errorMessage))
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
})
screen.getByTestId('start-instance').click()
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
})
})
})
describe('Delete Instance', () => {
it('deletes instance and removes it from state', async () => {
vi.mocked(instancesApi.delete).mockResolvedValue(undefined)
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
expect(screen.getByTestId('instance-instance2')).toBeInTheDocument()
})
screen.getByTestId('delete-instance').click()
await waitFor(() => {
expect(instancesApi.delete).toHaveBeenCalledWith('instance2')
})
await waitFor(() => {
expect(screen.getByTestId('instances-count')).toHaveTextContent('1')
expect(screen.queryByTestId('instance-instance2')).not.toBeInTheDocument()
expect(screen.getByTestId('instance-instance1')).toBeInTheDocument() // instance1 should still exist
})
})
it('handles delete instance error without changing state', async () => {
const errorMessage = 'Instance is running'
vi.mocked(instancesApi.delete).mockRejectedValue(new Error(errorMessage))
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
})
screen.getByTestId('delete-instance').click()
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
})
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
expect(screen.getByTestId('instance-instance2')).toBeInTheDocument()
})
})
describe('Error Management', () => {
it('clears error when clearError is called', async () => {
const errorMessage = 'Test error'
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage))
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
})
screen.getByTestId('clear-error').click()
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent('no-error')
})
})
})
describe('State Consistency', () => {
it('maintains consistent state during multiple operations', async () => {
// Test that operations don't interfere with each other
const newInstance: Instance = {
name: 'new-instance',
running: false,
options: {}
}
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance)
renderWithProvider(<TestComponent />)
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
})
// Create new instance
screen.getByTestId('create-instance').click()
await waitFor(() => {
expect(screen.getByTestId('instances-count')).toHaveTextContent('3')
})
// Start an instance (this should not affect the count)
screen.getByTestId('start-instance').click()
await waitFor(() => {
expect(instancesApi.start).toHaveBeenCalled()
expect(screen.getByTestId('instances-count')).toHaveTextContent('3') // Still 3
// But the running state should change
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true')
})
})
})
})

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { instancesApi } from '@/lib/api'
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch
describe('API Error Handling', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('converts HTTP errors to meaningful messages', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 409,
text: () => Promise.resolve('Instance already exists')
})
await expect(instancesApi.create('existing', {}))
.rejects
.toThrow('HTTP 409: Instance already exists')
})
it('handles empty error responses gracefully', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve('')
})
await expect(instancesApi.list())
.rejects
.toThrow('HTTP 500')
})
it('handles 204 No Content responses', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 204
})
const result = await instancesApi.delete('test-instance')
expect(result).toBeUndefined()
})
it('builds query parameters correctly for logs', async () => {
mockFetch.mockResolvedValue({
ok: true,
text: () => Promise.resolve('logs')
})
await instancesApi.getLogs('test-instance', 100)
expect(mockFetch).toHaveBeenCalledWith(
'/api/v1/instances/test-instance/logs?lines=100',
expect.any(Object)
)
})
})

View File

@@ -1,101 +1,79 @@
import { CreateInstanceOptions, Instance } from "@/types/instance"
import { CreateInstanceOptions, Instance } from "@/types/instance";
const API_BASE = '/api/v1'
// Configuration for API calls
// interface ApiConfig {
// apiKey?: string
// }
// Global config - can be updated when auth is added
// let apiConfig: ApiConfig = {}
// export const setApiConfig = (config: ApiConfig) => {
// apiConfig = config
// }
const API_BASE = "/api/v1";
// Base API call function with error handling
async function apiCall<T>(
endpoint: string,
options: RequestInit = {}
options: RequestInit = {},
responseType: "json" | "text" = "json"
): Promise<T> {
const url = `${API_BASE}${endpoint}`
const url = `${API_BASE}${endpoint}`;
// Prepare headers
const headers: HeadersInit = {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...options.headers,
}
// Add API key - not supported yet
// if (apiConfig.apiKey) {
// headers['Authorization'] = `Bearer ${apiConfig.apiKey}`
// }
};
try {
const response = await fetch(url, {
...options,
headers,
})
});
if (!response.ok) {
// Try to get error message from response
let errorMessage = `HTTP ${response.status}`
let errorMessage = `HTTP ${response.status}`;
try {
const errorText = await response.text()
const errorText = await response.text();
if (errorText) {
errorMessage += `: ${errorText}`
errorMessage += `: ${errorText}`;
}
} catch {
// If we can't read the error, just use status
}
throw new Error(errorMessage)
throw new Error(errorMessage);
}
// Handle empty responses (like DELETE)
if (response.status === 204) {
return undefined as T
return undefined as T;
}
const data = await response.json()
return data
// Parse response based on type
if (responseType === "text") {
const text = await response.text();
return text as T;
} else {
const data = await response.json();
return data;
}
} catch (error) {
if (error instanceof Error) {
throw error
throw error;
}
throw new Error('Network error occurred')
throw new Error("Network error occurred");
}
}
// Server API functions
export const serverApi = {
// GET /server/help
getHelp: async (): Promise<string> => {
const response = await fetch(`${API_BASE}/server/help`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.text()
},
getHelp: () => apiCall<string>("/server/help", {}, "text"),
// GET /server/version
getVersion: async (): Promise<string> => {
const response = await fetch(`${API_BASE}/server/version`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.text()
},
getVersion: () => apiCall<string>("/server/version", {}, "text"),
// GET /server/devices
getDevices: async (): Promise<string> => {
const response = await fetch(`${API_BASE}/server/devices`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.text()
},
}
getDevices: () => apiCall<string>("/server/devices", {}, "text"),
};
// Instance API functions
export const instancesApi = {
// GET /instances
list: () => apiCall<Instance[]>('/instances'),
list: () => apiCall<Instance[]>("/instances"),
// GET /instances/{name}
get: (name: string) => apiCall<Instance>(`/instances/${name}`),
@@ -103,44 +81,44 @@ export const instancesApi = {
// POST /instances/{name}
create: (name: string, options: CreateInstanceOptions) =>
apiCall<Instance>(`/instances/${name}`, {
method: 'POST',
method: "POST",
body: JSON.stringify(options),
}),
// PUT /instances/{name}
update: (name: string, options: CreateInstanceOptions) =>
apiCall<Instance>(`/instances/${name}`, {
method: 'PUT',
method: "PUT",
body: JSON.stringify(options),
}),
// DELETE /instances/{name}
delete: (name: string) =>
apiCall<void>(`/instances/${name}`, {
method: 'DELETE',
method: "DELETE",
}),
// POST /instances/{name}/start
start: (name: string) =>
apiCall<Instance>(`/instances/${name}/start`, {
method: 'POST',
method: "POST",
}),
// POST /instances/{name}/stop
stop: (name: string) =>
apiCall<Instance>(`/instances/${name}/stop`, {
method: 'POST',
method: "POST",
}),
// POST /instances/{name}/restart
restart: (name: string) =>
apiCall<Instance>(`/instances/${name}/restart`, {
method: 'POST',
method: "POST",
}),
// GET /instances/{name}/logs
getLogs: (name: string, lines?: number) => {
const params = lines ? `?lines=${lines}` : ''
return apiCall<string>(`/instances/${name}/logs${params}`)
const params = lines ? `?lines=${lines}` : "";
return apiCall<string>(`/instances/${name}/logs${params}`, {}, "text");
},
}
};

10
webui/src/test/setup.ts Normal file
View File

@@ -0,0 +1,10 @@
import '@testing-library/jest-dom'
import { afterEach, vi } from 'vitest'
// Mock fetch globally since your app uses fetch
global.fetch = vi.fn()
// Clean up after each test
afterEach(() => {
vi.clearAllMocks()
})

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from "path"
import tailwindcss from "@tailwindcss/vite"
@@ -14,5 +14,11 @@ export default defineConfig({
proxy: {
'/api': 'http://localhost:8080'
}
}
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
},
})