Merge branch 'main' into main
This commit is contained in:
@@ -108,6 +108,21 @@ const DynamicJsonForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const jsonStr = rawJsonValue.trim();
|
||||
if (!jsonStr) {
|
||||
return;
|
||||
}
|
||||
const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2);
|
||||
setRawJsonValue(formatted);
|
||||
debouncedUpdateParent(formatted);
|
||||
setJsonError(undefined);
|
||||
} catch (err) {
|
||||
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormFields = (
|
||||
propSchema: JsonSchemaType,
|
||||
currentValue: JsonValue,
|
||||
@@ -353,7 +368,12 @@ const DynamicJsonForm = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end space-x-2">
|
||||
{isJsonMode && (
|
||||
<Button variant="outline" size="sm" onClick={formatJson}>
|
||||
Format JSON
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import JsonView from "./JsonView";
|
||||
|
||||
const HistoryAndNotifications = ({
|
||||
requestHistory,
|
||||
@@ -74,9 +75,9 @@ const HistoryAndNotifications = ({
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
|
||||
{JSON.stringify(JSON.parse(request.request), null, 2)}
|
||||
</pre>
|
||||
<div className="bg-background p-2 rounded">
|
||||
<JsonView data={request.request} />
|
||||
</div>
|
||||
</div>
|
||||
{request.response && (
|
||||
<div className="mt-2">
|
||||
@@ -91,13 +92,9 @@ const HistoryAndNotifications = ({
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
|
||||
{JSON.stringify(
|
||||
JSON.parse(request.response),
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
<div className="bg-background p-2 rounded">
|
||||
<JsonView data={request.response} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -146,9 +143,11 @@ const HistoryAndNotifications = ({
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
|
||||
{JSON.stringify(notification, null, 2)}
|
||||
</pre>
|
||||
<div className="bg-background p-2 rounded">
|
||||
<JsonView
|
||||
data={JSON.stringify(notification, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -3,7 +3,6 @@ import Editor from "react-simple-code-editor";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-json";
|
||||
import "prismjs/themes/prism.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
@@ -16,49 +15,25 @@ const JsonEditor = ({
|
||||
onChange,
|
||||
error: externalError,
|
||||
}: JsonEditorProps) => {
|
||||
const [editorContent, setEditorContent] = useState(value);
|
||||
const [editorContent, setEditorContent] = useState(value || "");
|
||||
const [internalError, setInternalError] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditorContent(value);
|
||||
setEditorContent(value || "");
|
||||
}, [value]);
|
||||
|
||||
const formatJson = (json: string): string => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(json), null, 2);
|
||||
} catch {
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
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={handleFormatJson}>
|
||||
Format JSON
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`border rounded-md ${
|
||||
displayError
|
||||
|
||||
228
client/src/components/JsonView.tsx
Normal file
228
client/src/components/JsonView.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState, memo } from "react";
|
||||
import { JsonValue } from "./DynamicJsonForm";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface JsonViewProps {
|
||||
data: unknown;
|
||||
name?: string;
|
||||
initialExpandDepth?: number;
|
||||
}
|
||||
|
||||
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
|
||||
const trimmed = str.trim();
|
||||
if (
|
||||
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
||||
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||
) {
|
||||
return { success: false, data: str };
|
||||
}
|
||||
try {
|
||||
return { success: true, data: JSON.parse(str) };
|
||||
} catch {
|
||||
return { success: false, data: str };
|
||||
}
|
||||
}
|
||||
|
||||
const JsonView = memo(
|
||||
({ data, name, initialExpandDepth = 3 }: JsonViewProps) => {
|
||||
const normalizedData =
|
||||
typeof data === "string"
|
||||
? tryParseJson(data).success
|
||||
? tryParseJson(data).data
|
||||
: data
|
||||
: data;
|
||||
|
||||
return (
|
||||
<div className="font-mono text-sm transition-all duration-300 ">
|
||||
<JsonNode
|
||||
data={normalizedData as JsonValue}
|
||||
name={name}
|
||||
depth={0}
|
||||
initialExpandDepth={initialExpandDepth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
JsonView.displayName = "JsonView";
|
||||
|
||||
interface JsonNodeProps {
|
||||
data: JsonValue;
|
||||
name?: string;
|
||||
depth: number;
|
||||
initialExpandDepth: number;
|
||||
}
|
||||
|
||||
const JsonNode = memo(
|
||||
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
||||
|
||||
const getDataType = (value: JsonValue): string => {
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (value === null) return "null";
|
||||
return typeof value;
|
||||
};
|
||||
|
||||
const dataType = getDataType(data);
|
||||
|
||||
const typeStyleMap: Record<string, string> = {
|
||||
number: "text-blue-600",
|
||||
boolean: "text-amber-600",
|
||||
null: "text-purple-600",
|
||||
undefined: "text-gray-600",
|
||||
string: "text-green-600 break-all whitespace-pre-wrap",
|
||||
default: "text-gray-700",
|
||||
};
|
||||
|
||||
const renderCollapsible = (isArray: boolean) => {
|
||||
const items = isArray
|
||||
? (data as JsonValue[])
|
||||
: Object.entries(data as Record<string, JsonValue>);
|
||||
const itemCount = items.length;
|
||||
const isEmpty = itemCount === 0;
|
||||
|
||||
const symbolMap = {
|
||||
open: isArray ? "[" : "{",
|
||||
close: isArray ? "]" : "}",
|
||||
collapsed: isArray ? "[ ... ]" : "{ ... }",
|
||||
empty: isArray ? "[]" : "{}",
|
||||
};
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{name && (
|
||||
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||
{name}:
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-500">{symbolMap.empty}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="flex items-center mr-1 rounded cursor-pointer group hover:bg-gray-800/10 dark:hover:bg-gray-800/20"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{name && (
|
||||
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||
{name}:
|
||||
</span>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<span className="text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||
{symbolMap.open}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-600 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||
{symbolMap.collapsed}
|
||||
</span>
|
||||
<span className="ml-1 text-gray-700 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||
{itemCount} {itemCount === 1 ? "item" : "items"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="pl-2 ml-4 border-l border-gray-200 dark:border-gray-800">
|
||||
{isArray
|
||||
? (items as JsonValue[]).map((item, index) => (
|
||||
<div key={index} className="my-1">
|
||||
<JsonNode
|
||||
data={item}
|
||||
name={`${index}`}
|
||||
depth={depth + 1}
|
||||
initialExpandDepth={initialExpandDepth}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: (items as [string, JsonValue][]).map(([key, value]) => (
|
||||
<div key={key} className="my-1">
|
||||
<JsonNode
|
||||
data={value}
|
||||
name={key}
|
||||
depth={depth + 1}
|
||||
initialExpandDepth={initialExpandDepth}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{symbolMap.close}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderString = (value: string) => {
|
||||
const maxLength = 100;
|
||||
const isTooLong = value.length > maxLength;
|
||||
|
||||
if (!isTooLong) {
|
||||
return (
|
||||
<div className="flex mr-1 rounded hover:bg-gray-800/20">
|
||||
{name && (
|
||||
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||
{name}:
|
||||
</span>
|
||||
)}
|
||||
<pre className={typeStyleMap.string}>"{value}"</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
|
||||
{name && (
|
||||
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
|
||||
{name}:
|
||||
</span>
|
||||
)}
|
||||
<pre
|
||||
className={clsx(
|
||||
typeStyleMap.string,
|
||||
"cursor-pointer group-hover:text-green-500",
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
title={isExpanded ? "Click to collapse" : "Click to expand"}
|
||||
>
|
||||
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
switch (dataType) {
|
||||
case "object":
|
||||
case "array":
|
||||
return renderCollapsible(dataType === "array");
|
||||
case "string":
|
||||
return renderString(data as string);
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
|
||||
{name && (
|
||||
<span className="mr-1 text-gray-600 dark:text-gray-400">
|
||||
{name}:
|
||||
</span>
|
||||
)}
|
||||
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
|
||||
{data === null ? "null" : String(data)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
JsonNode.displayName = "JsonNode";
|
||||
|
||||
export default JsonView;
|
||||
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import {
|
||||
ListPromptsResult,
|
||||
PromptReference,
|
||||
@@ -13,6 +13,7 @@ import { AlertCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ListPane from "./ListPane";
|
||||
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||
import JsonView from "./JsonView";
|
||||
|
||||
export type Prompt = {
|
||||
name: string;
|
||||
@@ -151,11 +152,9 @@ const PromptsTab = ({
|
||||
Get Prompt
|
||||
</Button>
|
||||
{promptContent && (
|
||||
<Textarea
|
||||
value={promptContent}
|
||||
readOnly
|
||||
className="h-64 font-mono"
|
||||
/>
|
||||
<div className="p-4 border rounded">
|
||||
<JsonView data={promptContent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
|
||||
import ListPane from "./ListPane";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCompletionState } from "@/lib/hooks/useCompletionState";
|
||||
import JsonView from "./JsonView";
|
||||
|
||||
const ResourcesTab = ({
|
||||
resources,
|
||||
@@ -214,9 +215,9 @@ const ResourcesTab = ({
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : selectedResource ? (
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words text-gray-900 dark:text-gray-100">
|
||||
{resourceContent}
|
||||
</pre>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100">
|
||||
<JsonView data={resourceContent} />
|
||||
</div>
|
||||
) : selectedTemplate ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import JsonView from "./JsonView";
|
||||
|
||||
export type PendingRequest = {
|
||||
id: number;
|
||||
@@ -43,9 +44,9 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||
{pendingRequests.map((request) => (
|
||||
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
||||
{JSON.stringify(request.request, null, 2)}
|
||||
</pre>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
||||
<JsonView data={JSON.stringify(request.request)} />
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
|
||||
<Button variant="outline" onClick={() => onReject(request.id)}>
|
||||
|
||||
@@ -457,36 +457,37 @@ const Sidebar = ({
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<a
|
||||
href="https://modelcontextprotocol.io/docs/tools/inspector"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" title="Inspector Documentation">
|
||||
<CircleHelp className="w-4 h-4 text-gray-800" />
|
||||
</Button>
|
||||
</a>
|
||||
<a
|
||||
href="https://modelcontextprotocol.io/docs/tools/debugging"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" title="Debugging Guide">
|
||||
<Bug className="w-4 h-4 text-gray-800" />
|
||||
</Button>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/modelcontextprotocol/inspector"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Report bugs or contribute on GitHub"
|
||||
<Button variant="ghost" title="Inspector Documentation" asChild>
|
||||
<a
|
||||
href="https://modelcontextprotocol.io/docs/tools/inspector"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Github className="w-4 h-4 text-gray-800" />
|
||||
</Button>
|
||||
</a>
|
||||
<CircleHelp className="w-4 h-4 text-foreground" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" title="Debugging Guide" asChild>
|
||||
<a
|
||||
href="https://modelcontextprotocol.io/docs/tools/debugging"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Bug className="w-4 h-4 text-foreground" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Report bugs or contribute on GitHub"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/modelcontextprotocol/inspector"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Github className="w-4 h-4 text-foreground" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { AlertCircle, Send } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ListPane from "./ListPane";
|
||||
import { escapeUnicode } from "@/utils/escapeUnicode";
|
||||
import JsonView from "./JsonView";
|
||||
|
||||
const ToolsTab = ({
|
||||
tools,
|
||||
@@ -53,17 +53,14 @@ const ToolsTab = ({
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{escapeUnicode(toolResult)}
|
||||
</pre>
|
||||
<div className="p-4 border rounded">
|
||||
<JsonView data={toolResult} />
|
||||
</div>
|
||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||
{parsedResult.error.errors.map((error, idx) => (
|
||||
<pre
|
||||
key={idx}
|
||||
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
|
||||
>
|
||||
{escapeUnicode(error)}
|
||||
</pre>
|
||||
<div key={idx} className="p-4 border rounded">
|
||||
<JsonView data={error} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -79,9 +76,9 @@ const ToolsTab = ({
|
||||
{structuredResult.content.map((item, index) => (
|
||||
<div key={index} className="mb-2">
|
||||
{item.type === "text" && (
|
||||
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
|
||||
{item.text}
|
||||
</pre>
|
||||
<div className="p-4 border rounded">
|
||||
<JsonView data={item.text} />
|
||||
</div>
|
||||
)}
|
||||
{item.type === "image" && (
|
||||
<img
|
||||
@@ -100,9 +97,9 @@ const ToolsTab = ({
|
||||
<p>Your browser does not support audio playback</p>
|
||||
</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">
|
||||
{escapeUnicode(item.resource)}
|
||||
</pre>
|
||||
<div className="p-4 border rounded">
|
||||
<JsonView data={item.resource} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -112,9 +109,9 @@ const ToolsTab = ({
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{escapeUnicode(toolResult.toolResult)}
|
||||
</pre>
|
||||
<div className="p-4 border rounded">
|
||||
<JsonView data={toolResult.toolResult} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
95
client/src/components/__tests__/DynamicJsonForm.test.tsx
Normal file
95
client/src/components/__tests__/DynamicJsonForm.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
307
client/src/components/__tests__/Sidebar.test.tsx
Normal file
307
client/src/components/__tests__/Sidebar.test.tsx
Normal 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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
72
client/src/components/__tests__/ToolsTab.test.tsx
Normal file
72
client/src/components/__tests__/ToolsTab.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user