Merge branch 'main' into patch-1

This commit is contained in:
Shinya Fujino
2025-03-28 21:01:22 +09:00
committed by GitHub
28 changed files with 5000 additions and 218 deletions

View File

@@ -1,26 +1,36 @@
import { useState } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaType = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object";
type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
};
type JsonObject = { [key: string]: JsonValue };
interface DynamicJsonFormProps {
schema: JsonSchemaType;
value: JsonValue;
@@ -28,13 +38,6 @@ interface DynamicJsonFormProps {
maxDepth?: number;
}
const formatFieldLabel = (key: string): string => {
return key
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
};
const DynamicJsonForm = ({
schema,
value,
@@ -43,29 +46,65 @@ const DynamicJsonForm = ({
}: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false);
const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
const [rawJsonValue, setRawJsonValue] = useState<string>(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => {
switch (propSchema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object": {
const obj: JsonObject = {};
if (propSchema.properties) {
Object.entries(propSchema.properties).forEach(([key, prop]) => {
obj[key] = generateDefaultValue(prop);
});
}
return obj;
// Use a ref to manage debouncing timeouts to avoid parsing JSON
// on every keystroke which would be inefficient and error-prone
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Debounce JSON parsing and parent updates to handle typing gracefully
const debouncedUpdateParent = useCallback(
(jsonString: string) => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
default:
return null;
// Set a new timeout
timeoutRef.current = setTimeout(() => {
try {
const parsed = JSON.parse(jsonString);
onChange(parsed);
setJsonError(undefined);
} catch {
// Don't set error during normal typing
}
}, 300);
},
[onChange, setJsonError],
);
// Update rawJsonValue when value prop changes
useEffect(() => {
if (!isJsonMode) {
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
}
}, [value, schema, isJsonMode]);
const handleSwitchToFormMode = () => {
if (isJsonMode) {
// When switching to Form mode, ensure we have valid JSON
try {
const parsed = JSON.parse(rawJsonValue);
// Update the parent component's state with the parsed value
onChange(parsed);
// Switch to form mode
setIsJsonMode(false);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
} else {
// Update raw JSON value when switching to JSON mode
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
setIsJsonMode(true);
}
};
@@ -103,21 +142,68 @@ const DynamicJsonForm = ({
switch (propSchema.type) {
case "string":
return (
<Input
type="text"
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "number":
return (
<Input
type="number"
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
if (!isNaN(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "integer":
return (
<Input
type={propSchema.type === "string" ? "text" : "number"}
value={(currentValue as string | number) ?? ""}
onChange={(e) =>
handleFieldChange(
path,
propSchema.type === "string"
? e.target.value
: Number(e.target.value),
)
}
type="number"
step="1"
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required integer fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
// Only update if it's a valid integer
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "boolean":
@@ -127,25 +213,53 @@ const DynamicJsonForm = ({
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={propSchema.required}
/>
);
case "object":
if (!propSchema.properties) return null;
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
{renderFormFields(
prop,
(currentValue as JsonObject)?.[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
case "object": {
// Handle case where we have a value but no schema properties
const objectValue = (currentValue as JsonObject) || {};
// If we have schema properties, use them to render fields
if (propSchema.properties) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
{renderFormFields(
prop,
objectValue[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
@@ -187,9 +301,12 @@ const DynamicJsonForm = ({
variant="outline"
size="sm"
onClick={() => {
const defaultValue = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [
...arrayValue,
generateDefaultValue(propSchema.items as JsonSchemaType),
defaultValue ?? null,
]);
}}
title={
@@ -215,139 +332,65 @@ const DynamicJsonForm = ({
return;
}
const updateArray = (
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] => {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
// Validate array index
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
// Check array bounds
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
const newArray = [...array];
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
// Ensure index position exists
if (arrayIndex >= array.length) {
console.warn(`Extending array to index ${arrayIndex}`);
newArray.length = arrayIndex + 1;
newArray.fill(null, array.length, arrayIndex);
}
newArray[arrayIndex] = updateValue(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
};
const updateObject = (
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject => {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
console.warn(`Creating new key in object: ${key}`);
newObj[key] = {};
}
newObj[key] = updateValue(newObj[key], restPath, value);
}
return newObj;
};
const updateValue = (
current: JsonValue,
path: string[],
value: JsonValue,
): JsonValue => {
if (path.length === 0) return value;
try {
if (!current) {
current = !isNaN(Number(path[0])) ? [] : {};
}
// Type checking
if (Array.isArray(current)) {
return updateArray(current, path, value);
} else if (typeof current === "object" && current !== null) {
return updateObject(current, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
current,
);
return current;
}
} catch (error) {
console.error(`Error updating value at path ${path.join(".")}:`, error);
return current;
}
};
try {
const newValue = updateValue(value, path, fieldValue);
const newValue = updateValueAtPath(value, path, fieldValue);
onChange(newValue);
} catch (error) {
console.error("Failed to update form value:", error);
// Keep the original value unchanged
onChange(value);
}
};
const shouldUseJsonMode =
schema.type === "object" &&
(!schema.properties || Object.keys(schema.properties).length === 0);
useEffect(() => {
if (shouldUseJsonMode && !isJsonMode) {
setIsJsonMode(true);
}
}, [shouldUseJsonMode, isJsonMode]);
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setIsJsonMode(!isJsonMode)}
>
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
</div>
{isJsonMode ? (
<JsonEditor
value={JSON.stringify(value ?? generateDefaultValue(schema), null, 2)}
value={rawJsonValue}
onChange={(newValue) => {
try {
onChange(JSON.parse(newValue));
setJsonError(undefined);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
// Always update local state
setRawJsonValue(newValue);
// Use the debounced function to attempt parsing and updating parent
debouncedUpdateParent(newValue);
}}
error={jsonError}
/>
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
// render a simple representation of the JSON data
schema.type === "object" &&
(typeof value !== "object" ||
value === null ||
Object.keys(value).length === 0) &&
rawJsonValue &&
rawJsonValue !== "{}" ? (
<div className="space-y-4 border rounded-md p-4">
<p className="text-sm text-gray-500">
Form view not available for this JSON structure. Using simplified
view:
</p>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
{rawJsonValue}
</pre>
<p className="text-sm text-gray-500">
Use JSON mode for full editing capabilities.
</p>
</div>
) : (
renderFormFields(schema, value)
)}

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from "react";
import Editor from "react-simple-code-editor";
import Prism from "prismjs";
import "prismjs/components/prism-json";
@@ -10,7 +11,20 @@ interface JsonEditorProps {
error?: string;
}
const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
const JsonEditor = ({
value,
onChange,
error: externalError,
}: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value);
const [internalError, setInternalError] = useState<string | undefined>(
undefined,
);
useEffect(() => {
setEditorContent(value);
}, [value]);
const formatJson = (json: string): string => {
try {
return JSON.stringify(JSON.parse(json), null, 2);
@@ -19,25 +33,42 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
}
};
const handleEditorChange = (newContent: string) => {
setEditorContent(newContent);
setInternalError(undefined);
onChange(newContent);
};
const handleFormatJson = () => {
try {
const formatted = formatJson(editorContent);
setEditorContent(formatted);
onChange(formatted);
setInternalError(undefined);
} catch (err) {
setInternalError(err instanceof Error ? err.message : "Invalid JSON");
}
};
const displayError = internalError || externalError;
return (
<div className="relative space-y-2">
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => onChange(formatJson(value))}
>
<Button variant="outline" size="sm" onClick={handleFormatJson}>
Format JSON
</Button>
</div>
<div
className={`border rounded-md ${
error ? "border-red-500" : "border-gray-200 dark:border-gray-800"
displayError
? "border-red-500"
: "border-gray-200 dark:border-gray-800"
}`}
>
<Editor
value={value}
onValueChange={onChange}
value={editorContent}
onValueChange={handleEditorChange}
highlight={(code) =>
Prism.highlight(code, Prism.languages.json, "json")
}
@@ -51,7 +82,9 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
className="w-full"
/>
</div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
{displayError && (
<p className="text-sm text-red-500 mt-1">{displayError}</p>
)}
</div>
);
};

View File

@@ -19,6 +19,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes";
import {
LoggingLevel,
LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js";
import useTheme from "../lib/useTheme";
import { version } from "../../../package.json";
@@ -39,6 +43,9 @@ interface SidebarProps {
setBearerToken: (token: string) => void;
onConnect: () => void;
stdErrNotifications: StdErrNotification[];
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
}
const Sidebar = ({
@@ -57,6 +64,9 @@ const Sidebar = ({
setBearerToken,
onConnect,
stdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
@@ -177,9 +187,17 @@ const Sidebar = ({
value={key}
onChange={(e) => {
const newKey = e.target.value;
const newEnv = { ...env };
delete newEnv[key];
newEnv[newKey] = value;
const newEnv = Object.entries(env).reduce(
(acc, [k, v]) => {
if (k === key) {
acc[newKey] = value;
} else {
acc[k] = v;
}
return acc;
},
{} as Record<string, string>,
);
setEnv(newEnv);
setShownEnvVars((prev) => {
const next = new Set(prev);
@@ -290,6 +308,28 @@ const Sidebar = ({
: "Disconnected"}
</span>
</div>
{loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2">
<label className="text-sm font-medium">Logging Level</label>
<Select
value={logLevel}
onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select logging level" />
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem value={level}>{level}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{stdErrNotifications.length > 0 && (
<>
<div className="mt-4 border-t border-gray-200 pt-4">

View File

@@ -6,16 +6,17 @@ import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
CallToolResultSchema,
CompatibilityCallToolResult,
ListToolsResult,
Tool,
CallToolResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, Send } from "lucide-react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { escapeUnicode } from "@/utils/escapeUnicode";
const ToolsTab = ({
tools,
@@ -53,7 +54,7 @@ const ToolsTab = ({
<>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(toolResult, null, 2)}
{escapeUnicode(toolResult)}
</pre>
<h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => (
@@ -61,7 +62,7 @@ const ToolsTab = ({
key={idx}
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
>
{JSON.stringify(error, null, 2)}
{escapeUnicode(error)}
</pre>
))}
</>
@@ -100,7 +101,7 @@ const ToolsTab = ({
</audio>
) : (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(item.resource, null, 2)}
{escapeUnicode(item.resource)}
</pre>
))}
</div>
@@ -112,7 +113,7 @@ const ToolsTab = ({
<>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(toolResult.toolResult, null, 2)}
{escapeUnicode(toolResult.toolResult)}
</pre>
</>
);
@@ -214,7 +215,10 @@ const ToolsTab = ({
description: prop.description,
items: prop.items,
}}
value={(params[key] as JsonValue) ?? {}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
@@ -229,6 +233,7 @@ const ToolsTab = ({
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,

View File

@@ -0,0 +1,95 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "../DynamicJsonForm";
describe("DynamicJsonForm String Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "string" as const,
description: "Test string field",
} satisfies JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Type Validation", () => {
it("should handle numeric input as string type", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "123321" } });
expect(onChange).toHaveBeenCalledWith("123321");
// Verify the value is a string, not a number
expect(typeof onChange.mock.calls[0][0]).toBe("string");
});
it("should render as text input, not number input", () => {
renderForm();
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "text");
});
});
});
describe("DynamicJsonForm Integer Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "integer" as const,
description: "Test integer field",
} satisfies JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Basic Operations", () => {
it("should render number input with step=1", () => {
renderForm();
const input = screen.getByRole("spinbutton");
expect(input).toHaveProperty("type", "number");
expect(input).toHaveProperty("step", "1");
});
it("should pass integer values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "42" } });
expect(onChange).toHaveBeenCalledWith(42);
// Verify the value is a number, not a string
expect(typeof onChange.mock.calls[0][0]).toBe("number");
});
it("should not pass string values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "abc" } });
expect(onChange).not.toHaveBeenCalled();
});
});
describe("Edge Cases", () => {
it("should handle non-numeric input by not calling onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "abc" } });
expect(onChange).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,307 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, beforeEach, jest } from "@jest/globals";
import Sidebar from "../Sidebar";
// Mock theme hook
jest.mock("../../lib/useTheme", () => ({
__esModule: true,
default: () => ["light", jest.fn()],
}));
describe("Sidebar Environment Variables", () => {
const defaultProps = {
connectionStatus: "disconnected" as const,
transportType: "stdio" as const,
setTransportType: jest.fn(),
command: "",
setCommand: jest.fn(),
args: "",
setArgs: jest.fn(),
sseUrl: "",
setSseUrl: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
onConnect: jest.fn(),
stdErrNotifications: [],
logLevel: "info" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: true,
};
const renderSidebar = (props = {}) => {
return render(<Sidebar {...defaultProps} {...props} />);
};
const openEnvVarsSection = () => {
const button = screen.getByText("Environment Variables");
fireEvent.click(button);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("Basic Operations", () => {
it("should add a new environment variable", () => {
const setEnv = jest.fn();
renderSidebar({ env: {}, setEnv });
openEnvVarsSection();
const addButton = screen.getByText("Add Environment Variable");
fireEvent.click(addButton);
expect(setEnv).toHaveBeenCalledWith({ "": "" });
});
it("should remove an environment variable", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const removeButton = screen.getByRole("button", { name: "×" });
fireEvent.click(removeButton);
expect(setEnv).toHaveBeenCalledWith({});
});
it("should update environment variable value", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const valueInput = screen.getByDisplayValue("test_value");
fireEvent.change(valueInput, { target: { value: "new_value" } });
expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" });
});
it("should toggle value visibility", () => {
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv });
openEnvVarsSection();
const valueInput = screen.getByDisplayValue("test_value");
expect(valueInput).toHaveProperty("type", "password");
const toggleButton = screen.getByRole("button", { name: /show value/i });
fireEvent.click(toggleButton);
expect(valueInput).toHaveProperty("type", "text");
});
});
describe("Key Editing", () => {
it("should maintain order when editing first key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
NEW_FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
});
});
it("should maintain order when editing middle key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const middleKeyInput = screen.getByDisplayValue("SECOND_KEY");
fireEvent.change(middleKeyInput, { target: { value: "NEW_SECOND_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
FIRST_KEY: "first_value",
NEW_SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
});
});
it("should maintain order when editing last key", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
THIRD_KEY: "third_value",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const lastKeyInput = screen.getByDisplayValue("THIRD_KEY");
fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } });
expect(setEnv).toHaveBeenCalledWith({
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
NEW_THIRD_KEY: "third_value",
});
});
it("should maintain order during key editing", () => {
const setEnv = jest.fn();
const initialEnv = {
KEY1: "value1",
KEY2: "value2",
};
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
// Type "NEW_" one character at a time
const key1Input = screen.getByDisplayValue("KEY1");
"NEW_".split("").forEach((char) => {
fireEvent.change(key1Input, {
target: { value: char + "KEY1".slice(1) },
});
});
// Verify the last setEnv call maintains the order
const lastCall = setEnv.mock.calls[
setEnv.mock.calls.length - 1
][0] as Record<string, string>;
const entries = Object.entries(lastCall);
// The values should stay with their original keys
expect(entries[0][1]).toBe("value1"); // First entry should still have value1
expect(entries[1][1]).toBe("value2"); // Second entry should still have value2
});
});
describe("Multiple Operations", () => {
it("should maintain state after multiple key edits", () => {
const setEnv = jest.fn();
const initialEnv = {
FIRST_KEY: "first_value",
SECOND_KEY: "second_value",
};
const { rerender } = renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
// First key edit
const firstKeyInput = screen.getByDisplayValue("FIRST_KEY");
fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } });
// Get the updated env from the first setEnv call
const updatedEnv = setEnv.mock.calls[0][0] as Record<string, string>;
// Rerender with the updated env
rerender(<Sidebar {...defaultProps} env={updatedEnv} setEnv={setEnv} />);
// Second key edit
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
fireEvent.change(secondKeyInput, { target: { value: "NEW_SECOND_KEY" } });
// Verify the final state matches what we expect
expect(setEnv).toHaveBeenLastCalledWith({
NEW_FIRST_KEY: "first_value",
NEW_SECOND_KEY: "second_value",
});
});
it("should maintain visibility state after key edit", () => {
const initialEnv = { TEST_KEY: "test_value" };
const { rerender } = renderSidebar({ env: initialEnv });
openEnvVarsSection();
// Show the value
const toggleButton = screen.getByRole("button", { name: /show value/i });
fireEvent.click(toggleButton);
const valueInput = screen.getByDisplayValue("test_value");
expect(valueInput).toHaveProperty("type", "text");
// Edit the key
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "NEW_KEY" } });
// Rerender with updated env
rerender(<Sidebar {...defaultProps} env={{ NEW_KEY: "test_value" }} />);
// Value should still be visible
const updatedValueInput = screen.getByDisplayValue("test_value");
expect(updatedValueInput).toHaveProperty("type", "text");
});
});
describe("Edge Cases", () => {
it("should handle empty key", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "" } });
expect(setEnv).toHaveBeenCalledWith({ "": "test_value" });
});
it("should handle special characters in key", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } });
expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" });
});
it("should handle unicode characters", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
fireEvent.change(keyInput, { target: { value: "TEST_🔑" } });
expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" });
});
it("should handle very long key names", () => {
const setEnv = jest.fn();
const initialEnv = { TEST_KEY: "test_value" };
renderSidebar({ env: initialEnv, setEnv });
openEnvVarsSection();
const keyInput = screen.getByDisplayValue("TEST_KEY");
const longKey = "A".repeat(100);
fireEvent.change(keyInput, { target: { value: longKey } });
expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" });
});
});
});

View File

@@ -0,0 +1,72 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import ToolsTab from "../ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
describe("ToolsTab", () => {
const mockTools: Tool[] = [
{
name: "tool1",
description: "First tool",
inputSchema: {
type: "object" as const,
properties: {
num: { type: "number" as const },
},
},
},
{
name: "tool2",
description: "Second tool",
inputSchema: {
type: "object" as const,
properties: {
num: { type: "number" as const },
},
},
},
];
const defaultProps = {
tools: mockTools,
listTools: jest.fn(),
clearTools: jest.fn(),
callTool: jest.fn(),
selectedTool: null,
setSelectedTool: jest.fn(),
toolResult: null,
nextCursor: "",
error: null,
};
const renderToolsTab = (props = {}) => {
return render(
<Tabs defaultValue="tools">
<ToolsTab {...defaultProps} {...props} />
</Tabs>,
);
};
it("should reset input values when switching tools", () => {
const { rerender } = renderToolsTab({
selectedTool: mockTools[0],
});
// Enter a value in the first tool's input
const input = screen.getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(input, { target: { value: "42" } });
expect(input.value).toBe("42");
// Switch to second tool
rerender(
<Tabs defaultValue="tools">
<ToolsTab {...defaultProps} selectedTool={mockTools[1]} />
</Tabs>,
);
// Verify input is reset
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
expect(newInput.value).toBe("");
});
});