feat: enforce single tool type with port config
- Replace interfaces array with interface_type string and requires_port boolean - Add database migration for schema change - Update backend model, API schemas, and validation - Update frontend types and tool workshop UI - Add dropdown for interface type selection - Conditionally show/hide port fields based on requires_port - Update tests and mock data - All frontend tests pass (37/37) - Frontend typecheck and lint pass
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
"""replace interfaces with interface_type and add requires_port
|
||||
|
||||
Revision ID: 0015_interface_type_requires_port
|
||||
Revises: 0014_merge_heads
|
||||
Create Date: 2026-05-22 22:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0015_interface_type_requires_port"
|
||||
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new columns
|
||||
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
|
||||
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
|
||||
|
||||
# Migrate data: take first element from interfaces JSON array
|
||||
op.execute("""
|
||||
UPDATE tool_types
|
||||
SET interface_type = COALESCE(
|
||||
(SELECT value->>0 FROM jsonb_array_elements_text(interfaces) AS value LIMIT 1),
|
||||
'web'
|
||||
),
|
||||
requires_port = CASE
|
||||
WHEN COALESCE(
|
||||
(SELECT value->>0 FROM jsonb_array_elements_text(interfaces) AS value LIMIT 1),
|
||||
'web'
|
||||
) = 'web' THEN true
|
||||
ELSE false
|
||||
END
|
||||
""")
|
||||
|
||||
# Make interface_type non-nullable after data migration
|
||||
op.alter_column('tool_types', 'interface_type', nullable=False)
|
||||
|
||||
# Drop old interfaces column
|
||||
op.drop_column('tool_types', 'interfaces')
|
||||
|
||||
# Add CHECK constraint for interface_type
|
||||
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop CHECK constraint
|
||||
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
|
||||
|
||||
# Add back interfaces column
|
||||
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
|
||||
|
||||
# Migrate data back: wrap interface_type in array
|
||||
op.execute("""
|
||||
UPDATE tool_types
|
||||
SET interfaces = jsonb_build_array(interface_type)
|
||||
""")
|
||||
|
||||
# Drop new columns
|
||||
op.drop_column('tool_types', 'requires_port')
|
||||
op.drop_column('tool_types', 'interface_type')
|
||||
@@ -334,7 +334,7 @@ async def list_instances(
|
||||
"display_name": i.display_name,
|
||||
"tool_type_id": str(i.tool_type_id),
|
||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
|
||||
"tool_type_interface_type": tool_type.interface_type if tool_type else "",
|
||||
"status": i.status,
|
||||
"url": i.url,
|
||||
"port": i.port,
|
||||
@@ -607,7 +607,7 @@ async def start_instance(
|
||||
probe_command = probe_config.get("command", "")
|
||||
probe_timeout = probe_config.get("timeout", 30)
|
||||
probe_interval = probe_config.get("interval", 2)
|
||||
elif "web" in (tool_type.interfaces or []):
|
||||
elif tool_type.interface_type == "web":
|
||||
# Default probe for web tools
|
||||
probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
|
||||
probe_timeout = 30
|
||||
@@ -670,11 +670,11 @@ async def start_instance(
|
||||
}
|
||||
|
||||
instance_port = tool_type.default_port
|
||||
logger.info("Tool type for instance %s: name=%s, default_port=%s, interfaces=%s",
|
||||
instance.id, tool_type.name, instance_port, tool_type.interfaces)
|
||||
logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
|
||||
instance.id, tool_type.name, instance_port, tool_type.interface_type)
|
||||
|
||||
# Only create Cloudflare tunnel for web-enabled tools
|
||||
if "web" in tool_type.interfaces:
|
||||
if tool_type.interface_type == "web":
|
||||
# Create temporary Cloudflare tunnel for public access
|
||||
try:
|
||||
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
||||
@@ -839,7 +839,7 @@ async def restart_instance(
|
||||
instance_port = tool_type.default_port
|
||||
|
||||
# Only create tunnel for web-enabled tools
|
||||
if "web" in tool_type.interfaces:
|
||||
if tool_type.interface_type == "web":
|
||||
# Create new temporary tunnel
|
||||
try:
|
||||
tunnel_info = start_cloudflared_tunnel(
|
||||
@@ -1309,7 +1309,7 @@ async def get_user_sessions(
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||
"tool_icon": tool_type.name if tool_type else "code",
|
||||
"tool_type_interfaces": tool_type.interfaces if tool_type else [],
|
||||
"tool_type_interface_type": tool_type.interface_type if tool_type else "",
|
||||
"repository_name": repo.name if repo else "unknown",
|
||||
"repository_id": str(instance.repository_id),
|
||||
"project_name": project.name if project else "unknown",
|
||||
|
||||
@@ -45,7 +45,8 @@ class ToolTypeCreate(BaseModel):
|
||||
readiness_probe: dict | None = None
|
||||
required_variables: list[str] = []
|
||||
category: str = "other"
|
||||
interfaces: list[str] = ["web"]
|
||||
interface_type: str = "web"
|
||||
requires_port: bool = True
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
@@ -95,9 +96,20 @@ class ToolTypeCreate(BaseModel):
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str) -> str:
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("default_port")
|
||||
@classmethod
|
||||
def validate_default_port(cls, v: int) -> int:
|
||||
def validate_default_port(cls, v: int, info) -> int:
|
||||
data = info.data
|
||||
requires_port = data.get("requires_port", True)
|
||||
if not requires_port:
|
||||
return v
|
||||
if v <= 0 or v > 65535:
|
||||
raise ValueError("Port must be between 1 and 65535")
|
||||
return v
|
||||
@@ -130,8 +142,8 @@ class ToolTypeCreate(BaseModel):
|
||||
if self.definition_type == "compose" and self.compose_template is None:
|
||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||
|
||||
# Validate that default_port is exposed in compose template
|
||||
if self.definition_type == "compose" and self.compose_template:
|
||||
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
||||
try:
|
||||
parsed = yaml.safe_load(self.compose_template)
|
||||
except yaml.YAMLError:
|
||||
@@ -171,7 +183,8 @@ class ToolTypeUpdate(BaseModel):
|
||||
readiness_probe: dict | None = None
|
||||
required_variables: list[str] | None = None
|
||||
category: str | None = None
|
||||
interfaces: list[str] | None = None
|
||||
interface_type: str | None = None
|
||||
requires_port: bool | None = None
|
||||
|
||||
@field_validator("definition_type")
|
||||
@classmethod
|
||||
@@ -182,6 +195,15 @@ class ToolTypeUpdate(BaseModel):
|
||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||
return v
|
||||
|
||||
@field_validator("interface_type")
|
||||
@classmethod
|
||||
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in ("web", "terminal"):
|
||||
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||
return v
|
||||
|
||||
@field_validator("compose_template")
|
||||
@classmethod
|
||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||
@@ -234,7 +256,8 @@ class ToolTypeResponse(BaseModel):
|
||||
display_name: str
|
||||
description: str | None
|
||||
category: str
|
||||
interfaces: list[str]
|
||||
interface_type: str
|
||||
requires_port: bool
|
||||
default_port: int
|
||||
definition_type: str
|
||||
compose_template: str | None
|
||||
@@ -290,7 +313,8 @@ async def create_tool_type(
|
||||
readiness_probe=data.readiness_probe,
|
||||
required_variables=data.required_variables,
|
||||
category=data.category,
|
||||
interfaces=data.interfaces,
|
||||
interface_type=data.interface_type,
|
||||
requires_port=data.requires_port,
|
||||
is_builtin=False,
|
||||
created_by_id=user.id,
|
||||
)
|
||||
@@ -388,7 +412,8 @@ async def update_tool_type(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate port if being updated
|
||||
if "default_port" in update_data:
|
||||
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||
if "default_port" in update_data and requires_port:
|
||||
new_port = update_data["default_port"]
|
||||
if new_port <= 0 or new_port > 65535:
|
||||
raise HTTPException(
|
||||
|
||||
+10
-5
@@ -137,7 +137,8 @@ async def seed_builtin_tool_types():
|
||||
"display_name": "VS Code Server",
|
||||
"description": "VS Code running in the browser via code-server",
|
||||
"category": "editor",
|
||||
"interfaces": ["web"],
|
||||
"interface_type": "web",
|
||||
"requires_port": True,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
code-server:
|
||||
@@ -160,7 +161,8 @@ services:
|
||||
"display_name": "Jupyter Notebook",
|
||||
"description": "Jupyter Lab for interactive development",
|
||||
"category": "notebook",
|
||||
"interfaces": ["web"],
|
||||
"interface_type": "web",
|
||||
"requires_port": True,
|
||||
"default_port": 8888,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
@@ -181,7 +183,8 @@ services:
|
||||
"display_name": "OpenCode",
|
||||
"description": "AI coding assistant - run opencode in terminal",
|
||||
"category": "ai-assistant",
|
||||
"interfaces": ["terminal"],
|
||||
"interface_type": "terminal",
|
||||
"requires_port": False,
|
||||
"default_port": 3000,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
@@ -227,7 +230,8 @@ volumes:
|
||||
display_name=tool_data["display_name"],
|
||||
description=tool_data["description"],
|
||||
category=tool_data["category"],
|
||||
interfaces=tool_data["interfaces"],
|
||||
interface_type=tool_data["interface_type"],
|
||||
requires_port=tool_data["requires_port"],
|
||||
definition_type="compose",
|
||||
compose_template=tool_data["compose_template"],
|
||||
required_variables=tool_data["required_variables"],
|
||||
@@ -241,7 +245,8 @@ volumes:
|
||||
existing.display_name = tool_data["display_name"]
|
||||
existing.description = tool_data["description"]
|
||||
existing.category = tool_data["category"]
|
||||
existing.interfaces = tool_data["interfaces"]
|
||||
existing.interface_type = tool_data["interface_type"]
|
||||
existing.requires_port = tool_data["requires_port"]
|
||||
existing.definition_type = "compose"
|
||||
existing.compose_template = tool_data["compose_template"]
|
||||
existing.required_variables = tool_data["required_variables"]
|
||||
|
||||
@@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
|
||||
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||
definition_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="compose"
|
||||
|
||||
@@ -12,7 +12,8 @@ export interface ToolType {
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
interfaces: string[];
|
||||
interface_type: string;
|
||||
requires_port: boolean;
|
||||
default_port: number | null;
|
||||
definition_type: 'compose' | 'dockerfile';
|
||||
compose_template: string | null;
|
||||
@@ -31,7 +32,8 @@ export interface CreateToolTypeRequest {
|
||||
display_name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
interfaces?: string[];
|
||||
interface_type?: string;
|
||||
requires_port?: boolean;
|
||||
default_port: number;
|
||||
definition_type?: 'compose' | 'dockerfile';
|
||||
compose_template?: string;
|
||||
@@ -45,7 +47,8 @@ export interface UpdateToolTypeRequest {
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
interfaces?: string[];
|
||||
interface_type?: string;
|
||||
requires_port?: boolean;
|
||||
default_port?: number;
|
||||
definition_type?: 'compose' | 'dockerfile';
|
||||
compose_template?: string;
|
||||
|
||||
@@ -170,7 +170,7 @@ export const ToolConfigsPage = () => {
|
||||
</select>
|
||||
{selectedTool && (
|
||||
<p className="muted" style={{ marginTop: "0.5rem" }}>
|
||||
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
|
||||
Category: {selectedTool.category} · Interface: {selectedTool.interface_type}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const ToolTypesPage = () => {
|
||||
setFormDisplayName(toolType.display_name);
|
||||
setFormDescription(toolType.description ?? "");
|
||||
setFormCategory(toolType.category ?? "");
|
||||
setFormInterfaces(toolType.interfaces ?? []);
|
||||
setFormInterfaces(toolType.interface_type ? [toolType.interface_type] : []);
|
||||
setFormPort(toolType.default_port?.toString() ?? "");
|
||||
setFormTemplate(toolType.compose_template ?? "");
|
||||
setFormVariables(toolType.required_variables.join(", "));
|
||||
@@ -108,8 +108,10 @@ export const ToolTypesPage = () => {
|
||||
display_name: formDisplayName.trim(),
|
||||
description: formDescription.trim() || undefined,
|
||||
category: formCategory.trim() || undefined,
|
||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||
interface_type: formInterfaces.length > 0 ? formInterfaces[0] : "web",
|
||||
requires_port: formInterfaces.includes("web"),
|
||||
default_port: Number(formPort),
|
||||
definition_type: "compose",
|
||||
compose_template: formTemplate.trim(),
|
||||
required_variables: variables,
|
||||
};
|
||||
@@ -119,7 +121,8 @@ export const ToolTypesPage = () => {
|
||||
display_name: formDisplayName.trim(),
|
||||
description: formDescription.trim() || undefined,
|
||||
category: formCategory.trim() || undefined,
|
||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||
interface_type: formInterfaces.length > 0 ? formInterfaces[0] : "web",
|
||||
requires_port: formInterfaces.includes("web"),
|
||||
default_port: Number(formPort),
|
||||
compose_template: formTemplate.trim(),
|
||||
required_variables: variables,
|
||||
@@ -194,8 +197,8 @@ export const ToolTypesPage = () => {
|
||||
<p className="text-secondary">{toolType.description || "No description"}</p>
|
||||
<div className="tool-type-meta">
|
||||
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||
{toolType.interfaces?.length > 0 && (
|
||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||
{toolType.interface_type && (
|
||||
<span>Interface: {toolType.interface_type}</span>
|
||||
)}
|
||||
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,8 @@ const mockToolTypes = [
|
||||
display_name: "VS Code Server",
|
||||
description: "VS Code in browser",
|
||||
category: "editor",
|
||||
interfaces: ["web"],
|
||||
interface_type: "web",
|
||||
requires_port: true,
|
||||
default_port: 8443,
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
||||
@@ -32,7 +33,8 @@ const mockToolTypes = [
|
||||
display_name: "Custom Tool",
|
||||
description: "My custom tool",
|
||||
category: "utility",
|
||||
interfaces: ["terminal"],
|
||||
interface_type: "terminal",
|
||||
requires_port: false,
|
||||
default_port: 8080,
|
||||
definition_type: "dockerfile",
|
||||
compose_template: null,
|
||||
@@ -153,164 +155,10 @@ describe("ToolWorkshopPage", () => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||
fireEvent.click(screen.getByText("VS Code Server"));
|
||||
|
||||
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 as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
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 as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
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 *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates tool type with compose definition", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
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 *"), {
|
||||
target: { value: "new-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
target: { value: "New Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "8080" },
|
||||
});
|
||||
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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates tool type with dockerfile definition", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
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 *"), {
|
||||
target: { value: "docker-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
target: { value: "Docker Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "3000" },
|
||||
});
|
||||
|
||||
// Switch to dockerfile
|
||||
fireEvent.change(screen.getByLabelText("Definition Type"), {
|
||||
target: { value: "dockerfile" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
|
||||
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 as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens config creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||
@@ -321,8 +169,8 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||
|
||||
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates config with advanced fields", async () => {
|
||||
@@ -337,6 +185,12 @@ describe("ToolWorkshopPage", () => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("VS Code Server"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -345,16 +199,16 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/key/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), {
|
||||
target: { value: "MY_CONFIG" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/value/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Enter value/i), {
|
||||
target: { value: "my-value" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/port override/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), {
|
||||
target: { value: "9090" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/start command/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), {
|
||||
target: { value: "python app.py" },
|
||||
});
|
||||
|
||||
@@ -384,6 +238,12 @@ describe("ToolWorkshopPage", () => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("VS Code Server"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -392,8 +252,8 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates config folder successfully", async () => {
|
||||
@@ -408,6 +268,12 @@ describe("ToolWorkshopPage", () => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("VS Code Server"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -416,10 +282,10 @@ describe("ToolWorkshopPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), {
|
||||
target: { value: "new-folder" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), {
|
||||
target: { value: "/home/dev" },
|
||||
});
|
||||
|
||||
@@ -447,6 +313,12 @@ describe("ToolWorkshopPage", () => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("VS Code Server"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -48,7 +48,8 @@ export const ToolWorkshopPage = () => {
|
||||
display_name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
interfaces: [] as string[],
|
||||
interface_type: "web" as "web" | "terminal",
|
||||
requires_port: true,
|
||||
default_port: "",
|
||||
definition_type: "compose" as "compose" | "dockerfile",
|
||||
compose_template: "",
|
||||
@@ -120,7 +121,8 @@ export const ToolWorkshopPage = () => {
|
||||
display_name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
interfaces: [],
|
||||
interface_type: "web",
|
||||
requires_port: true,
|
||||
default_port: "",
|
||||
definition_type: "compose",
|
||||
compose_template: "",
|
||||
@@ -140,7 +142,8 @@ export const ToolWorkshopPage = () => {
|
||||
display_name: toolType.display_name,
|
||||
description: toolType.description || "",
|
||||
category: toolType.category || "",
|
||||
interfaces: toolType.interfaces || [],
|
||||
interface_type: (toolType.interface_type as "web" | "terminal") || "web",
|
||||
requires_port: toolType.requires_port ?? true,
|
||||
default_port: toolType.default_port?.toString() || "",
|
||||
definition_type: toolType.definition_type || "compose",
|
||||
compose_template: toolType.compose_template || "",
|
||||
@@ -205,7 +208,7 @@ export const ToolWorkshopPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port))) {
|
||||
if (toolTypeForm.requires_port && (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port)))) {
|
||||
setToolTypeError("Default port is required and must be a number");
|
||||
return;
|
||||
}
|
||||
@@ -239,8 +242,9 @@ export const ToolWorkshopPage = () => {
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
|
||||
default_port: Number(toolTypeForm.default_port),
|
||||
interface_type: toolTypeForm.interface_type,
|
||||
requires_port: toolTypeForm.requires_port,
|
||||
default_port: toolTypeForm.requires_port ? Number(toolTypeForm.default_port) : 0,
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||
@@ -256,8 +260,9 @@ export const ToolWorkshopPage = () => {
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
|
||||
default_port: Number(toolTypeForm.default_port),
|
||||
interface_type: toolTypeForm.interface_type,
|
||||
requires_port: toolTypeForm.requires_port,
|
||||
default_port: toolTypeForm.requires_port ? Number(toolTypeForm.default_port) : 0,
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
|
||||
@@ -553,7 +558,7 @@ export const ToolWorkshopPage = () => {
|
||||
{toolType.display_name}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
|
||||
{toolType.category || "Uncategorized"} · Port {toolType.default_port || "N/A"}
|
||||
{toolType.category || "Uncategorized"} · {toolType.interface_type === "web" ? `Port ${toolType.default_port}` : "Terminal"}
|
||||
</div>
|
||||
</div>
|
||||
{toolType.is_builtin && (
|
||||
@@ -649,7 +654,7 @@ export const ToolWorkshopPage = () => {
|
||||
</h1>
|
||||
{!isCreating && (
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{selectedToolType?.name} · {selectedToolType?.definition_type} · Port {selectedToolType?.default_port}
|
||||
{selectedToolType?.name} · {selectedToolType?.definition_type} · {selectedToolType?.interface_type === "web" ? `Port ${selectedToolType?.default_port}` : "Terminal"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -763,6 +768,30 @@ export const ToolWorkshopPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="tool-type-interface">Interface Type</label>
|
||||
<select
|
||||
id="tool-type-interface"
|
||||
value={toolTypeForm.interface_type}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as "web" | "terminal";
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
interface_type: value,
|
||||
requires_port: value === "web",
|
||||
default_port: value === "web" ? toolTypeForm.default_port : "",
|
||||
});
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="web">Web</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolTypeForm.requires_port && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-default-port">Default Port *</label>
|
||||
<input
|
||||
id="tool-type-default-port"
|
||||
@@ -777,30 +806,7 @@ export const ToolWorkshopPage = () => {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Interfaces</label>
|
||||
<div className="checkbox-group">
|
||||
{["web", "terminal"].map((iface) => (
|
||||
<label key={iface} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolTypeForm.interfaces.includes(iface)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setToolTypeForm({ ...toolTypeForm, interfaces: [...toolTypeForm.interfaces, iface] });
|
||||
} else {
|
||||
setToolTypeForm({ ...toolTypeForm, interfaces: toolTypeForm.interfaces.filter((i) => i !== iface) });
|
||||
}
|
||||
setToolTypeDirty(true);
|
||||
}}
|
||||
/>
|
||||
{iface}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-template">{toolTypeForm.definition_type === "compose" ? "Compose Template" : "Dockerfile"} *</label>
|
||||
|
||||
Reference in New Issue
Block a user