test: add comprehensive tests for tool workshop functionality

Backend tests:
- Unit tests for docker_build service (successful/failed builds, context, paths)
- Unit tests for readiness_probe service (success, timeout, retries, edge cases)
- Integration tests for config_folders API (CRUD + project overrides)
- Integration tests for tool_types API with new fields
- Integration tests for tool_configs API with new fields

Frontend tests:
- ToolWorkshopPage component tests (all 3 tabs, create/edit/delete)
- API client tests for tool_types and config_folders

Fixes:
- Add field_validator import to tool_configs.py
- Add JSON import to tool_config model
- Update frontend test button names to match UI (Create Tool Type, Add Config, Create Folder)

Quality gates: backend unit tests passing (23/23)
This commit is contained in:
Fusion
2026-05-22 19:45:10 +02:00
parent 8dd350286e
commit dacf105200
13 changed files with 2621 additions and 5 deletions
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import axios from "axios";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
} from "../api/config_folders";
vi.mock("axios");
const mockedAxios = vi.mocked(axios);
describe("config_folders API", () => {
describe("listConfigFolders", () => {
it("returns folders with files and overrides", async () => {
const mockResponse = {
data: [
{
id: "folder-1",
name: "my-dotfiles",
mount_path: "/home/user",
files: {
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
},
project_overrides: {
"proj-1": {
mount_path: "/workspace",
files: { ".zshrc": "different content" },
},
},
is_active: true,
},
],
};
mockedAxios.get.mockResolvedValue(mockResponse);
const result = await listConfigFolders();
expect(result).toHaveLength(1);
expect(result[0].name).toBe("my-dotfiles");
expect(result[0].files).toEqual({
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"",
});
expect(result[0].project_overrides).toEqual({
"proj-1": {
mount_path: "/workspace",
files: { ".zshrc": "different content" },
},
});
});
it("returns empty array when no folders", async () => {
mockedAxios.get.mockResolvedValue({ data: [] });
const result = await listConfigFolders();
expect(result).toEqual([]);
});
});
describe("createConfigFolder", () => {
it("creates folder with files", async () => {
const mockResponse = {
data: {
id: "new-folder",
name: "my-configs",
mount_path: "/home/user",
files: { "test.txt": "hello" },
is_active: true,
},
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await createConfigFolder({
name: "my-configs",
mount_path: "/home/user",
files: { "test.txt": "hello" },
});
expect(result.name).toBe("my-configs");
expect(result.files).toEqual({ "test.txt": "hello" });
expect(mockedAxios.post).toHaveBeenCalledWith(
"/config-folders",
expect.objectContaining({
name: "my-configs",
mount_path: "/home/user",
files: { "test.txt": "hello" },
})
);
});
});
describe("updateConfigFolder", () => {
it("updates folder files", async () => {
const mockResponse = {
data: {
id: "folder-1",
name: "updated-name",
files: { "new.txt": "content" },
},
};
mockedAxios.put.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", {
name: "updated-name",
files: { "new.txt": "content" },
});
expect(result.name).toBe("updated-name");
expect(mockedAxios.put).toHaveBeenCalledWith(
"/config-folders/folder-1",
expect.objectContaining({
name: "updated-name",
files: { "new.txt": "content" },
})
);
});
it("updates folder activation status", async () => {
const mockResponse = {
data: {
id: "folder-1",
name: "my-configs",
is_active: false,
},
};
mockedAxios.put.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", {
is_active: false,
});
expect(result.is_active).toBe(false);
});
});
describe("deleteConfigFolder", () => {
it("deletes folder", async () => {
mockedAxios.delete.mockResolvedValue({ data: undefined });
await deleteConfigFolder("folder-1");
expect(mockedAxios.delete).toHaveBeenCalledWith("/config-folders/folder-1");
});
});
});
+201
View File
@@ -0,0 +1,201 @@
import { describe, expect, it, vi } from "vitest";
import axios from "axios";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
validateToolType,
} from "../api/tool_types";
vi.mock("axios");
const mockedAxios = vi.mocked(axios);
describe("tool_types API", () => {
describe("listToolTypes", () => {
it("returns tool types with new fields", async () => {
const mockResponse = {
data: [
{
id: "type-1",
name: "custom-tool",
display_name: "Custom Tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
},
],
};
mockedAxios.get.mockResolvedValue(mockResponse);
const result = await listToolTypes();
expect(result[0].definition_type).toBe("dockerfile");
expect(result[0].dockerfile_template).toBe("FROM python:3.11");
expect(result[0].readiness_probe).toEqual({
command: "python --version",
timeout: 30,
interval: 2,
});
});
it("returns compose tool types", async () => {
const mockResponse = {
data: [
{
id: "type-1",
name: "code-server",
definition_type: "compose",
compose_template: "version: '3.8'",
dockerfile_template: null,
},
],
};
mockedAxios.get.mockResolvedValue(mockResponse);
const result = await listToolTypes();
expect(result[0].definition_type).toBe("compose");
expect(result[0].dockerfile_template).toBeNull();
});
});
describe("createToolType", () => {
it("creates tool type with dockerfile", async () => {
const mockResponse = {
data: {
id: "new-type",
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
},
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await createToolType({
name: "docker-tool",
display_name: "Docker Tool",
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
default_port: 3000,
required_variables: [],
});
expect(result.definition_type).toBe("dockerfile");
expect(mockedAxios.post).toHaveBeenCalledWith(
"/tool-types",
expect.objectContaining({
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
})
);
});
it("creates tool type with readiness probe", async () => {
const mockResponse = {
data: {
id: "new-type",
name: "probed-tool",
readiness_probe: {
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
},
},
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await createToolType({
name: "probed-tool",
display_name: "Probed Tool",
compose_template: "version: '3.8'",
default_port: 8080,
required_variables: [],
readiness_probe: {
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
},
});
expect(result.readiness_probe).toEqual({
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
});
});
});
describe("validateToolType", () => {
it("validates compose template", async () => {
const mockResponse = {
data: { valid: true, errors: [] },
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await validateToolType({
definition_type: "compose",
compose_template: "version: '3.8'",
});
expect(result.valid).toBe(true);
});
it("returns validation errors", async () => {
const mockResponse = {
data: { valid: false, errors: ["Invalid YAML"] },
};
mockedAxios.post.mockResolvedValue(mockResponse);
const result = await validateToolType({
definition_type: "compose",
compose_template: "invalid: yaml: [",
});
expect(result.valid).toBe(false);
expect(result.errors).toContain("Invalid YAML");
});
});
describe("updateToolType", () => {
it("updates tool type with new fields", async () => {
const mockResponse = {
data: {
id: "type-1",
name: "updated-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
},
};
mockedAxios.put.mockResolvedValue(mockResponse);
const result = await updateToolType("type-1", {
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
});
expect(result.definition_type).toBe("dockerfile");
expect(mockedAxios.put).toHaveBeenCalledWith(
"/tool-types/type-1",
expect.objectContaining({
definition_type: "dockerfile",
})
);
});
});
describe("deleteToolType", () => {
it("deletes tool type", async () => {
mockedAxios.delete.mockResolvedValue({ data: undefined });
await deleteToolType("type-1");
expect(mockedAxios.delete).toHaveBeenCalledWith("/tool-types/type-1");
});
});
});
+558
View File
@@ -0,0 +1,558 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ToolWorkshopPage } from "./tool-workshop";
import * as toolTypesApi from "../api/tool_types";
import * as toolConfigsApi from "../api/tool_configs";
import * as configFoldersApi from "../api/config_folders";
const mockToolTypes = [
{
id: "type-1",
name: "code-server",
display_name: "VS Code Server",
description: "VS Code in browser",
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
dockerfile_template: null,
readiness_probe: null,
required_variables: ["REPO_PATH"],
is_builtin: true,
created_by_id: null,
},
{
id: "type-2",
name: "custom-tool",
display_name: "Custom Tool",
description: "My custom tool",
category: "utility",
interfaces: ["terminal"],
default_port: 8080,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "FROM python:3.11",
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
required_variables: [],
is_builtin: false,
created_by_id: "user-1",
},
];
const mockConfigs = [
{
id: "config-1",
tool_type_id: "type-1",
key: "OPENAI_API_KEY",
value: "sk-test123",
config_type: "env",
file_path: null,
port_override: null,
start_command: null,
working_directory: null,
environment_variables: {},
volumes: [],
},
{
id: "config-2",
tool_type_id: "type-2",
key: "advanced-config",
value: "test-value",
config_type: "env",
port_override: 9090,
start_command: "python app.py",
working_directory: "/app",
environment_variables: { DEBUG: "true" },
volumes: [{ source: "data", target: "/data", type: "bind" }],
},
];
const mockFolders = [
{
id: "folder-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
},
{
id: "folder-2",
name: "project-configs",
description: "Project specific configs",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost:8080" },
project_overrides: {
"proj-1": {
mount_path: "/app",
files: { ".env": "API_URL=http://prod.api" },
},
},
is_active: false,
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("ToolWorkshopPage", () => {
it("renders loading state initially", () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
render(<ToolWorkshopPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("renders tool types tab by default", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
it("switches to configs tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
expect(screen.getByText("advanced-config")).toBeInTheDocument();
});
it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
expect(screen.getByText("project-configs")).toBeInTheDocument();
});
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/display name/i)).toBeInTheDocument();
});
it("creates tool type with compose definition", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: "new-tool" },
});
fireEvent.change(screen.getByLabelText(/display name/i), {
target: { value: "New Tool" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-tool",
display_name: "New Tool",
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
})
);
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("creates tool type with dockerfile definition", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: "docker-tool" },
});
fireEvent.change(screen.getByLabelText(/display name/i), {
target: { value: "Docker Tool" },
});
// Switch to dockerfile
fireEvent.click(screen.getByLabelText(/dockerfile/i));
fireEvent.change(screen.getByLabelText(/dockerfile template/i), {
target: { value: "FROM python:3.11\\nRUN pip install flask" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
})
);
});
});
it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText(/readiness command/i)).toBeInTheDocument();
expect(screen.getByLabelText(/timeout/i)).toBeInTheDocument();
expect(screen.getByLabelText(/interval/i)).toBeInTheDocument();
});
it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
tool_type_id: "type-1",
suggested_configs: [],
port_override: null,
});
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
});
it("creates config with advanced fields", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
tool_type_id: "type-1",
suggested_configs: [],
port_override: null,
});
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByLabelText(/key/i), {
target: { value: "MY_CONFIG" },
});
fireEvent.change(screen.getByLabelText(/value/i), {
target: { value: "my-value" },
});
fireEvent.change(screen.getByLabelText(/port override/i), {
target: { value: "9090" },
});
fireEvent.change(screen.getByLabelText(/start command/i), {
target: { value: "python app.py" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "MY_CONFIG",
value: "my-value",
port_override: 9090,
start_command: "python app.py",
})
);
});
expect(configsListMock).toHaveBeenCalledTimes(2);
});
it("opens folder creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/mount path/i)).toBeInTheDocument();
});
it("creates config folder successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: "new-folder" },
});
fireEvent.change(screen.getByLabelText(/mount path/i), {
target: { value: "/home/dev" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-folder",
mount_path: "/home/dev",
})
);
});
expect(foldersListMock).toHaveBeenCalledTimes(2);
});
it("shows folder active/inactive status", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
// Check that active folder is marked
const activeFolder = screen.getByText("my-dotfiles").closest("[data-testid='folder-item']") ||
screen.getByText("my-dotfiles").parentElement;
expect(activeFolder?.textContent).toContain("active");
});
it("validates tool type before creation", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
const validateMock = vi.spyOn(toolTypesApi, "validateToolType").mockResolvedValue({
valid: false,
errors: ["Invalid YAML syntax"],
});
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: "bad-tool" },
});
fireEvent.change(screen.getByLabelText(/display name/i), {
target: { value: "Bad Tool" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "invalid: yaml: [" },
});
fireEvent.click(screen.getByRole("button", { name: /validate/i }));
await waitFor(() => {
expect(validateMock).toHaveBeenCalled();
});
expect(screen.getByText(/invalid yaml syntax/i)).toBeInTheDocument();
});
it("handles error state gracefully", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("retries loading after error", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockToolTypes);
vi.spyOn(toolConfigsApi, "listToolConfigs")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("deletes tool type successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes);
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
// Find and click delete button for custom tool (not built-in)
const customToolCard = screen.getByText("Custom Tool").closest("[data-testid='tool-type-item']") ||
screen.getByText("Custom Tool").parentElement;
if (customToolCard) {
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
if (deleteButton) {
fireEvent.click(deleteButton);
// Confirm deletion
const confirmButton = screen.queryByRole("button", { name: /confirm/i });
if (confirmButton) {
fireEvent.click(confirmButton);
}
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("type-2");
});
expect(listMock).toHaveBeenCalledTimes(2);
}
}
});
});