mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-06 00:54:23 +00:00
Implement basic tests for webui
This commit is contained in:
1384
webui/package-lock.json
generated
1384
webui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
186
webui/src/__tests__/App.test.tsx
Normal file
186
webui/src/__tests__/App.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,123 +9,125 @@ 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 [instanceName, setInstanceName] = useState('')
|
||||
const [formData, setFormData] = useState<CreateInstanceOptions>({})
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [nameError, setNameError] = useState('')
|
||||
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("");
|
||||
|
||||
// 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.'}
|
||||
{isEditing
|
||||
? "Modify the instance configuration below."
|
||||
: "Configure your new llama-server instance below."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Instance Name - Special handling since it's not in CreateInstanceOptions */}
|
||||
@@ -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,8 +151,10 @@ 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
|
||||
fieldKey="auto_restart"
|
||||
@@ -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;
|
||||
|
||||
339
webui/src/components/__tests__/InstanceCard.test.tsx
Normal file
339
webui/src/components/__tests__/InstanceCard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
246
webui/src/components/__tests__/InstanceList.test.tsx
Normal file
246
webui/src/components/__tests__/InstanceList.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
382
webui/src/components/__tests__/InstanceModal.test.tsx
Normal file
382
webui/src/components/__tests__/InstanceModal.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
395
webui/src/contexts/__tests__/InstancesContext.test.tsx
Normal file
395
webui/src/contexts/__tests__/InstancesContext.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
60
webui/src/lib/__tests__/api.test.ts
Normal file
60
webui/src/lib/__tests__/api.test.ts
Normal 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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,146 +1,124 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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
|
||||
// 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()
|
||||
},
|
||||
|
||||
// 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()
|
||||
},
|
||||
|
||||
getHelp: () => apiCall<string>("/server/help", {}, "text"),
|
||||
|
||||
// GET /server/version
|
||||
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}`),
|
||||
|
||||
|
||||
// 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
10
webui/src/test/setup.ts
Normal 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()
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user