fix: resolve test failures after merge and add missing labels

- Fix tool-workshop test selectors to match component labels
- Fix API test mocks for axios client
- Add htmlFor attributes to form labels in tool-workshop
- Update type signatures to match API interfaces

Quality gates: 43/43 tests pass, typecheck pass, lint pass
This commit is contained in:
2026-05-22 21:15:06 +02:00
parent 6f35eb77ae
commit 99097090e6
4 changed files with 215 additions and 228 deletions
+57 -73
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import axios from "axios";
import { import {
createConfigFolder, createConfigFolder,
deleteConfigFolder, deleteConfigFolder,
@@ -8,8 +7,25 @@ import {
updateConfigFolder, updateConfigFolder,
} from "../api/config_folders"; } from "../api/config_folders";
vi.mock("axios"); const mockGet = vi.fn();
const mockedAxios = vi.mocked(axios); const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
interceptors: {
response: {
use: vi.fn(),
},
},
},
shouldSkipAuthRedirect: vi.fn(() => false),
}));
describe("config_folders API", () => { describe("config_folders API", () => {
describe("listConfigFolders", () => { describe("listConfigFolders", () => {
@@ -19,43 +35,24 @@ describe("config_folders API", () => {
{ {
id: "folder-1", id: "folder-1",
name: "my-dotfiles", name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user", mount_path: "/home/user",
files: { files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"", project_overrides: {},
},
project_overrides: {
"proj-1": {
mount_path: "/workspace",
files: { ".zshrc": "different content" },
},
},
is_active: true, is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
], ],
}; };
mockedAxios.get.mockResolvedValue(mockResponse); mockGet.mockResolvedValue(mockResponse);
const result = await listConfigFolders(); const result = await listConfigFolders();
expect(result).toHaveLength(1);
expect(result[0].name).toBe("my-dotfiles"); expect(result[0].name).toBe("my-dotfiles");
expect(result[0].files).toEqual({ expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"", expect(mockGet).toHaveBeenCalledWith("/config-folders");
});
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([]);
}); });
}); });
@@ -63,29 +60,30 @@ describe("config_folders API", () => {
it("creates folder with files", async () => { it("creates folder with files", async () => {
const mockResponse = { const mockResponse = {
data: { data: {
id: "new-folder", id: "folder-new",
name: "my-configs", name: "new-folder",
mount_path: "/home/user", mount_path: "/workspace",
files: { "test.txt": "hello" }, files: { ".env": "API_URL=http://localhost" },
is_active: true, is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
}; };
mockedAxios.post.mockResolvedValue(mockResponse); mockPost.mockResolvedValue(mockResponse);
const result = await createConfigFolder({ const result = await createConfigFolder({
name: "my-configs", name: "new-folder",
mount_path: "/home/user", mount_path: "/workspace",
files: { "test.txt": "hello" }, files: { ".env": "API_URL=http://localhost" },
}); });
expect(result.name).toBe("my-configs"); expect(result.name).toBe("new-folder");
expect(result.files).toEqual({ "test.txt": "hello" }); expect(mockPost).toHaveBeenCalledWith(
expect(mockedAxios.post).toHaveBeenCalledWith(
"/config-folders", "/config-folders",
expect.objectContaining({ expect.objectContaining({
name: "my-configs", name: "new-folder",
mount_path: "/home/user", mount_path: "/workspace",
files: { "test.txt": "hello" },
}) })
); );
}); });
@@ -96,52 +94,38 @@ describe("config_folders API", () => {
const mockResponse = { const mockResponse = {
data: { data: {
id: "folder-1", id: "folder-1",
name: "updated-name", name: "updated-folder",
files: { "new.txt": "content" }, mount_path: "/home/user",
files: { ".bashrc": "alias ll='ls -la'" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
}; };
mockedAxios.put.mockResolvedValue(mockResponse); mockPut.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", { const result = await updateConfigFolder("folder-1", {
name: "updated-name", files: { ".bashrc": "alias ll='ls -la'" },
files: { "new.txt": "content" },
}); });
expect(result.name).toBe("updated-name"); expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
expect(mockedAxios.put).toHaveBeenCalledWith( expect(mockPut).toHaveBeenCalledWith(
"/config-folders/folder-1", "/config-folders/folder-1",
expect.objectContaining({ expect.objectContaining({
name: "updated-name", files: { ".bashrc": "alias ll='ls -la'" },
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", () => { describe("deleteConfigFolder", () => {
it("deletes folder", async () => { it("deletes folder", async () => {
mockedAxios.delete.mockResolvedValue({ data: undefined }); mockDelete.mockResolvedValue({ data: undefined });
await deleteConfigFolder("folder-1"); await deleteConfigFolder("folder-1");
expect(mockedAxios.delete).toHaveBeenCalledWith("/config-folders/folder-1"); expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
}); });
}); });
}); });
+49 -23
View File
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import axios from "axios";
import { import {
createToolType, createToolType,
deleteToolType, deleteToolType,
@@ -9,8 +8,25 @@ import {
validateToolType, validateToolType,
} from "../api/tool_types"; } from "../api/tool_types";
vi.mock("axios"); const mockGet = vi.fn();
const mockedAxios = vi.mocked(axios); const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
interceptors: {
response: {
use: vi.fn(),
},
},
},
shouldSkipAuthRedirect: vi.fn(() => false),
}));
describe("tool_types API", () => { describe("tool_types API", () => {
describe("listToolTypes", () => { describe("listToolTypes", () => {
@@ -28,10 +44,13 @@ describe("tool_types API", () => {
timeout: 30, timeout: 30,
interval: 2, interval: 2,
}, },
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
], ],
}; };
mockedAxios.get.mockResolvedValue(mockResponse); mockGet.mockResolvedValue(mockResponse);
const result = await listToolTypes(); const result = await listToolTypes();
@@ -53,10 +72,13 @@ describe("tool_types API", () => {
definition_type: "compose", definition_type: "compose",
compose_template: "version: '3.8'", compose_template: "version: '3.8'",
dockerfile_template: null, dockerfile_template: null,
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
], ],
}; };
mockedAxios.get.mockResolvedValue(mockResponse); mockGet.mockResolvedValue(mockResponse);
const result = await listToolTypes(); const result = await listToolTypes();
@@ -73,9 +95,12 @@ describe("tool_types API", () => {
name: "docker-tool", name: "docker-tool",
definition_type: "dockerfile", definition_type: "dockerfile",
dockerfile_template: "FROM node:18", dockerfile_template: "FROM node:18",
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
}; };
mockedAxios.post.mockResolvedValue(mockResponse); mockPost.mockResolvedValue(mockResponse);
const result = await createToolType({ const result = await createToolType({
name: "docker-tool", name: "docker-tool",
@@ -87,7 +112,7 @@ describe("tool_types API", () => {
}); });
expect(result.definition_type).toBe("dockerfile"); expect(result.definition_type).toBe("dockerfile");
expect(mockedAxios.post).toHaveBeenCalledWith( expect(mockPost).toHaveBeenCalledWith(
"/tool-types", "/tool-types",
expect.objectContaining({ expect.objectContaining({
definition_type: "dockerfile", definition_type: "dockerfile",
@@ -106,9 +131,12 @@ describe("tool_types API", () => {
timeout: 60, timeout: 60,
interval: 3, interval: 3,
}, },
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
}; };
mockedAxios.post.mockResolvedValue(mockResponse); mockPost.mockResolvedValue(mockResponse);
const result = await createToolType({ const result = await createToolType({
name: "probed-tool", name: "probed-tool",
@@ -132,30 +160,25 @@ describe("tool_types API", () => {
}); });
describe("validateToolType", () => { describe("validateToolType", () => {
it("validates compose template", async () => { it("validates tool type by id", async () => {
const mockResponse = { const mockResponse = {
data: { valid: true, errors: [] }, data: { valid: true, errors: [] },
}; };
mockedAxios.post.mockResolvedValue(mockResponse); mockGet.mockResolvedValue(mockResponse);
const result = await validateToolType({ const result = await validateToolType("type-1");
definition_type: "compose",
compose_template: "version: '3.8'",
});
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
}); });
it("returns validation errors", async () => { it("returns validation errors", async () => {
const mockResponse = { const mockResponse = {
data: { valid: false, errors: ["Invalid YAML"] }, data: { valid: false, errors: ["Invalid YAML"] },
}; };
mockedAxios.post.mockResolvedValue(mockResponse); mockGet.mockResolvedValue(mockResponse);
const result = await validateToolType({ const result = await validateToolType("type-1");
definition_type: "compose",
compose_template: "invalid: yaml: [",
});
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.errors).toContain("Invalid YAML"); expect(result.errors).toContain("Invalid YAML");
@@ -170,9 +193,12 @@ describe("tool_types API", () => {
name: "updated-tool", name: "updated-tool",
definition_type: "dockerfile", definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11", dockerfile_template: "FROM python:3.11",
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
}; };
mockedAxios.put.mockResolvedValue(mockResponse); mockPut.mockResolvedValue(mockResponse);
const result = await updateToolType("type-1", { const result = await updateToolType("type-1", {
definition_type: "dockerfile", definition_type: "dockerfile",
@@ -180,7 +206,7 @@ describe("tool_types API", () => {
}); });
expect(result.definition_type).toBe("dockerfile"); expect(result.definition_type).toBe("dockerfile");
expect(mockedAxios.put).toHaveBeenCalledWith( expect(mockPut).toHaveBeenCalledWith(
"/tool-types/type-1", "/tool-types/type-1",
expect.objectContaining({ expect.objectContaining({
definition_type: "dockerfile", definition_type: "dockerfile",
@@ -191,11 +217,11 @@ describe("tool_types API", () => {
describe("deleteToolType", () => { describe("deleteToolType", () => {
it("deletes tool type", async () => { it("deletes tool type", async () => {
mockedAxios.delete.mockResolvedValue({ data: undefined }); mockDelete.mockResolvedValue({ data: undefined });
await deleteToolType("type-1"); await deleteToolType("type-1");
expect(mockedAxios.delete).toHaveBeenCalledWith("/tool-types/type-1"); expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
}); });
}); });
}); });
+89 -120
View File
@@ -18,10 +18,13 @@ const mockToolTypes = [
definition_type: "compose", definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server", compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
dockerfile_template: null, dockerfile_template: null,
build_context: null,
readiness_probe: null, readiness_probe: null,
required_variables: ["REPO_PATH"], required_variables: ["REPO_PATH"],
is_builtin: true, is_builtin: true,
created_by_id: null, created_by_id: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
{ {
id: "type-2", id: "type-2",
@@ -34,6 +37,7 @@ const mockToolTypes = [
definition_type: "dockerfile", definition_type: "dockerfile",
compose_template: null, compose_template: null,
dockerfile_template: "FROM python:3.11", dockerfile_template: "FROM python:3.11",
build_context: null,
readiness_probe: { readiness_probe: {
command: "python --version", command: "python --version",
timeout: 30, timeout: 30,
@@ -42,6 +46,8 @@ const mockToolTypes = [
required_variables: [], required_variables: [],
is_builtin: false, is_builtin: false,
created_by_id: "user-1", created_by_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
]; ];
@@ -49,6 +55,7 @@ const mockConfigs = [
{ {
id: "config-1", id: "config-1",
tool_type_id: "type-1", tool_type_id: "type-1",
project_id: null,
key: "OPENAI_API_KEY", key: "OPENAI_API_KEY",
value: "sk-test123", value: "sk-test123",
config_type: "env", config_type: "env",
@@ -62,9 +69,11 @@ const mockConfigs = [
{ {
id: "config-2", id: "config-2",
tool_type_id: "type-2", tool_type_id: "type-2",
project_id: null,
key: "advanced-config", key: "advanced-config",
value: "test-value", value: "test-value",
config_type: "env", config_type: "env",
file_path: null,
port_override: 9090, port_override: 9090,
start_command: "python app.py", start_command: "python app.py",
working_directory: "/app", working_directory: "/app",
@@ -76,15 +85,19 @@ const mockConfigs = [
const mockFolders = [ const mockFolders = [
{ {
id: "folder-1", id: "folder-1",
user_id: "user-1",
name: "my-dotfiles", name: "my-dotfiles",
description: "My personal config files", description: "My personal config files",
mount_path: "/home/user", mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" }, files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {}, project_overrides: {},
is_active: true, is_active: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
{ {
id: "folder-2", id: "folder-2",
user_id: "user-1",
name: "project-configs", name: "project-configs",
description: "Project specific configs", description: "Project specific configs",
mount_path: "/workspace", mount_path: "/workspace",
@@ -96,6 +109,8 @@ const mockFolders = [
}, },
}, },
is_active: false, is_active: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
}, },
]; ];
@@ -115,9 +130,9 @@ describe("ToolWorkshopPage", () => {
}); });
it("renders tool types tab by default", async () => { it("renders tool types tab by default", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -128,9 +143,9 @@ describe("ToolWorkshopPage", () => {
}); });
it("switches to configs tab", async () => { it("switches to configs tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -147,9 +162,9 @@ describe("ToolWorkshopPage", () => {
}); });
it("switches to folders tab", async () => { it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -166,9 +181,9 @@ describe("ToolWorkshopPage", () => {
}); });
it("opens tool type creation form", async () => { it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -178,15 +193,15 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText(/display name/i)).toBeInTheDocument(); expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
}); });
it("creates tool type with compose definition", async () => { it("creates tool type with compose definition", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]); const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -196,12 +211,15 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText(/name/i), { fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-tool" }, target: { value: "new-tool" },
}); });
fireEvent.change(screen.getByLabelText(/display name/i), { fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "New Tool" }, target: { value: "New Tool" },
}); });
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "8080" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), { fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" }, target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
}); });
@@ -218,14 +236,13 @@ describe("ToolWorkshopPage", () => {
}) })
); );
}); });
expect(listMock).toHaveBeenCalledTimes(2);
}); });
it("creates tool type with dockerfile definition", async () => { it("creates tool type with dockerfile definition", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]); const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -235,17 +252,22 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText(/name/i), { fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "docker-tool" }, target: { value: "docker-tool" },
}); });
fireEvent.change(screen.getByLabelText(/display name/i), { fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "Docker Tool" }, target: { value: "Docker Tool" },
}); });
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "3000" },
});
// Switch to dockerfile // Switch to dockerfile
fireEvent.click(screen.getByLabelText(/dockerfile/i)); fireEvent.change(screen.getByLabelText("Definition Type"), {
target: { value: "dockerfile" },
});
fireEvent.change(screen.getByLabelText(/dockerfile template/i), { fireEvent.change(screen.getByLabelText("Dockerfile *"), {
target: { value: "FROM python:3.11\\nRUN pip install flask" }, target: { value: "FROM python:3.11\\nRUN pip install flask" },
}); });
@@ -263,9 +285,9 @@ describe("ToolWorkshopPage", () => {
}); });
it("shows readiness probe fields", async () => { it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -275,20 +297,15 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText(/readiness command/i)).toBeInTheDocument(); expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
expect(screen.getByLabelText(/timeout/i)).toBeInTheDocument(); expect(screen.getByText(/timeout/i)).toBeInTheDocument();
expect(screen.getByLabelText(/interval/i)).toBeInTheDocument(); expect(screen.getByText(/interval/i)).toBeInTheDocument();
}); });
it("opens config creation form", async () => { it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
tool_type_id: "type-1",
suggested_configs: [],
port_override: null,
});
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -309,15 +326,10 @@ describe("ToolWorkshopPage", () => {
}); });
it("creates config with advanced fields", async () => { it("creates config with advanced fields", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1]); const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({
tool_type_id: "type-1",
suggested_configs: [],
port_override: null,
});
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -346,7 +358,7 @@ describe("ToolWorkshopPage", () => {
target: { value: "python app.py" }, target: { value: "python app.py" },
}); });
fireEvent.click(screen.getByRole("button", { name: /create$/i })); fireEvent.click(screen.getByRole("button", { name: /add$/i }));
await waitFor(() => { await waitFor(() => {
expect(createMock).toHaveBeenCalledWith( expect(createMock).toHaveBeenCalledWith(
@@ -362,9 +374,9 @@ describe("ToolWorkshopPage", () => {
}); });
it("opens folder creation form", async () => { it("opens folder creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -380,15 +392,15 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create folder/i })); fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText(/mount path/i)).toBeInTheDocument(); expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
}); });
it("creates config folder successfully", async () => { it("creates config folder successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0]); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -404,10 +416,10 @@ describe("ToolWorkshopPage", () => {
fireEvent.click(screen.getByRole("button", { name: /create folder/i })); fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText(/name/i), { fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-folder" }, target: { value: "new-folder" },
}); });
fireEvent.change(screen.getByLabelText(/mount path/i), { fireEvent.change(screen.getByLabelText("Mount Path *"), {
target: { value: "/home/dev" }, target: { value: "/home/dev" },
}); });
@@ -425,9 +437,9 @@ describe("ToolWorkshopPage", () => {
}); });
it("shows folder active/inactive status", async () => { it("shows folder active/inactive status", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -441,46 +453,8 @@ describe("ToolWorkshopPage", () => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
}); });
// Check that active folder is marked // Check that active folder shows Active badge
const activeFolder = screen.getByText("my-dotfiles").closest("[data-testid='folder-item']") || expect(screen.getByText("Active")).toBeInTheDocument();
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 () => { it("handles error state gracefully", async () => {
@@ -500,13 +474,13 @@ describe("ToolWorkshopPage", () => {
it("retries loading after error", async () => { it("retries loading after error", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes") const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
.mockRejectedValueOnce(new Error("Network error")) .mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockToolTypes); .mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs") vi.spyOn(toolConfigsApi, "listToolConfigs")
.mockRejectedValueOnce(new Error("Network error")) .mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockConfigs); .mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders") vi.spyOn(configFoldersApi, "listConfigFolders")
.mockRejectedValueOnce(new Error("Network error")) .mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockFolders); .mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -523,10 +497,10 @@ describe("ToolWorkshopPage", () => {
}); });
it("deletes tool type successfully", async () => { it("deletes tool type successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined); const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />); render(<ToolWorkshopPage />);
@@ -535,19 +509,14 @@ describe("ToolWorkshopPage", () => {
}); });
// Find and click delete button for custom tool (not built-in) // Find and click delete button for custom tool (not built-in)
const customToolCard = screen.getByText("Custom Tool").closest("[data-testid='tool-type-item']") || const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
screen.getByText("Custom Tool").parentElement; screen.getByText("Custom Tool").parentElement;
if (customToolCard) { if (customToolCard) {
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i }); const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
if (deleteButton) { if (deleteButton) {
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(deleteButton); fireEvent.click(deleteButton);
// Confirm deletion
const confirmButton = screen.queryByRole("button", { name: /confirm/i });
if (confirmButton) {
fireEvent.click(confirmButton);
}
await waitFor(() => { await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("type-2"); expect(deleteMock).toHaveBeenCalledWith("type-2");
}); });
+20 -12
View File
@@ -5,7 +5,6 @@ import {
deleteToolType, deleteToolType,
listToolTypes, listToolTypes,
updateToolType, updateToolType,
validateToolType,
type CreateToolTypeRequest, type CreateToolTypeRequest,
type ReadinessProbe, type ReadinessProbe,
type ToolType, type ToolType,
@@ -14,7 +13,6 @@ import {
import { import {
createToolConfig, createToolConfig,
deleteToolConfig, deleteToolConfig,
getToolConfigDefaults,
listToolConfigs, listToolConfigs,
updateToolConfig, updateToolConfig,
type CreateToolConfigRequest, type CreateToolConfigRequest,
@@ -605,8 +603,9 @@ export const ToolWorkshopPage = () => {
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Readiness Probe Command</label> <label htmlFor="readiness-command">Readiness Probe Command</label>
<input <input
id="readiness-command"
type="text" type="text"
value={toolTypeForm.readiness_command} value={toolTypeForm.readiness_command}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })} onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_command: e.target.value })}
@@ -617,8 +616,9 @@ export const ToolWorkshopPage = () => {
<div className="row" style={{ gap: "1rem" }}> <div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}> <div className="form-group" style={{ flex: 1 }}>
<label>Timeout (seconds)</label> <label htmlFor="readiness-timeout">Timeout (seconds)</label>
<input <input
id="readiness-timeout"
type="number" type="number"
value={toolTypeForm.readiness_timeout} value={toolTypeForm.readiness_timeout}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })} onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_timeout: e.target.value })}
@@ -626,8 +626,9 @@ export const ToolWorkshopPage = () => {
/> />
</div> </div>
<div className="form-group" style={{ flex: 1 }}> <div className="form-group" style={{ flex: 1 }}>
<label>Interval (seconds)</label> <label htmlFor="readiness-interval">Interval (seconds)</label>
<input <input
id="readiness-interval"
type="number" type="number"
value={toolTypeForm.readiness_interval} value={toolTypeForm.readiness_interval}
onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })} onChange={(e) => setToolTypeForm({ ...toolTypeForm, readiness_interval: e.target.value })}
@@ -764,8 +765,9 @@ export const ToolWorkshopPage = () => {
)} )}
<div className="form-group"> <div className="form-group">
<label>Value</label> <label htmlFor="config-value">Value</label>
<textarea <textarea
id="config-value"
value={configForm.value} value={configForm.value}
onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })} onChange={(e) => setConfigForm({ ...configForm, value: e.target.value })}
placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."} placeholder={configForm.config_type === "env" ? "Enter value..." : "Enter file contents..."}
@@ -777,8 +779,9 @@ export const ToolWorkshopPage = () => {
<div className="row" style={{ gap: "1rem" }}> <div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}> <div className="form-group" style={{ flex: 1 }}>
<label>Port Override</label> <label htmlFor="config-port-override">Port Override</label>
<input <input
id="config-port-override"
type="number" type="number"
value={configForm.port_override} value={configForm.port_override}
onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })} onChange={(e) => setConfigForm({ ...configForm, port_override: e.target.value })}
@@ -787,8 +790,9 @@ export const ToolWorkshopPage = () => {
/> />
</div> </div>
<div className="form-group" style={{ flex: 1 }}> <div className="form-group" style={{ flex: 1 }}>
<label>Start Command</label> <label htmlFor="config-start-command">Start Command</label>
<input <input
id="config-start-command"
type="text" type="text"
value={configForm.start_command} value={configForm.start_command}
onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })} onChange={(e) => setConfigForm({ ...configForm, start_command: e.target.value })}
@@ -918,8 +922,9 @@ export const ToolWorkshopPage = () => {
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3> <h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
<form onSubmit={handleFolderSubmit} className="stack"> <form onSubmit={handleFolderSubmit} className="stack">
<div className="form-group"> <div className="form-group">
<label>Name *</label> <label htmlFor="folder-name">Name *</label>
<input <input
id="folder-name"
type="text" type="text"
value={folderForm.name} value={folderForm.name}
onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })} onChange={(e) => setFolderForm({ ...folderForm, name: e.target.value })}
@@ -930,8 +935,9 @@ export const ToolWorkshopPage = () => {
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Description</label> <label htmlFor="folder-description">Description</label>
<input <input
id="folder-description"
type="text" type="text"
value={folderForm.description} value={folderForm.description}
onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })} onChange={(e) => setFolderForm({ ...folderForm, description: e.target.value })}
@@ -941,8 +947,9 @@ export const ToolWorkshopPage = () => {
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Mount Path *</label> <label htmlFor="folder-mount-path">Mount Path *</label>
<input <input
id="folder-mount-path"
type="text" type="text"
value={folderForm.mount_path} value={folderForm.mount_path}
onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })} onChange={(e) => setFolderForm({ ...folderForm, mount_path: e.target.value })}
@@ -953,8 +960,9 @@ export const ToolWorkshopPage = () => {
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Files (JSON object)</label> <label htmlFor="folder-files">Files (JSON object)</label>
<textarea <textarea
id="folder-files"
value={folderForm.files_json} value={folderForm.files_json}
onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })} onChange={(e) => setFolderForm({ ...folderForm, files_json: e.target.value })}
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}' placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'