Resolve merge conflicts in DynamicJsonForm.tsx and ToolsTab.tsx
- Merged JSON validation functionality from PR branch with enhanced form features from main - Preserved validateJson method and DynamicJsonFormRef interface for tool validation - Added support for oneOf schemas, enum dropdowns, and enhanced string field types - Integrated meta display functionality and resource content props - Maintained JSON validation before tool execution to prevent invalid data submission
This commit is contained in:
commit
8ce1bbd346
14 changed files with 3238 additions and 582 deletions
|
|
@ -28,10 +28,10 @@ export interface DynamicJsonFormRef {
|
|||
|
||||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
if (supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type && supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type === "object") {
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
return Object.values(schema.properties ?? {}).every(
|
||||
(prop) => prop.type && supportedTypes.includes(prop.type),
|
||||
);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
|
|
@ -236,10 +236,89 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
parentSchema?.required?.includes(propertyName || "") ?? false;
|
||||
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
case "string": {
|
||||
if (
|
||||
propSchema.oneOf &&
|
||||
propSchema.oneOf.every(
|
||||
(option) =>
|
||||
typeof option.const === "string" &&
|
||||
typeof option.title === "string",
|
||||
)
|
||||
) {
|
||||
return (
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={isRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{propSchema.oneOf.map((option) => (
|
||||
<option
|
||||
key={option.const as string}
|
||||
value={option.const as string}
|
||||
>
|
||||
{option.title as string}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (propSchema.enum) {
|
||||
return (
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={isRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{propSchema.enum.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
let inputType = "text";
|
||||
switch (propSchema.format) {
|
||||
case "email":
|
||||
inputType = "email";
|
||||
break;
|
||||
case "uri":
|
||||
inputType = "url";
|
||||
break;
|
||||
case "date":
|
||||
inputType = "date";
|
||||
break;
|
||||
case "date-time":
|
||||
inputType = "datetime-local";
|
||||
break;
|
||||
default:
|
||||
inputType = "text";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
type={inputType}
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
|
|
@ -248,8 +327,13 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
minLength={propSchema.minLength}
|
||||
maxLength={propSchema.maxLength}
|
||||
pattern={propSchema.pattern}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
|
|
@ -268,8 +352,11 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
min={propSchema.minimum}
|
||||
max={propSchema.maximum}
|
||||
/>
|
||||
);
|
||||
|
||||
case "integer":
|
||||
return (
|
||||
<Input
|
||||
|
|
@ -282,7 +369,6 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
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);
|
||||
}
|
||||
|
|
@ -290,8 +376,11 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
min={propSchema.minimum}
|
||||
max={propSchema.maximum}
|
||||
/>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<Input
|
||||
|
|
|
|||
173
client/src/components/ElicitationRequest.tsx
Normal file
173
client/src/components/ElicitationRequest.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DynamicJsonForm from "./DynamicJsonForm";
|
||||
import JsonView from "./JsonView";
|
||||
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import {
|
||||
PendingElicitationRequest,
|
||||
ElicitationResponse,
|
||||
} from "./ElicitationTab";
|
||||
import Ajv from "ajv";
|
||||
|
||||
export type ElicitationRequestProps = {
|
||||
request: PendingElicitationRequest;
|
||||
onResolve: (id: number, response: ElicitationResponse) => void;
|
||||
};
|
||||
|
||||
const ElicitationRequest = ({
|
||||
request,
|
||||
onResolve,
|
||||
}: ElicitationRequestProps) => {
|
||||
const [formData, setFormData] = useState<JsonValue>({});
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultValue = generateDefaultValue(request.request.requestedSchema);
|
||||
setFormData(defaultValue);
|
||||
setValidationError(null);
|
||||
}, [request.request.requestedSchema]);
|
||||
|
||||
const validateEmailFormat = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const validateFormData = (
|
||||
data: JsonValue,
|
||||
schema: JsonSchemaType,
|
||||
): boolean => {
|
||||
if (
|
||||
schema.type === "object" &&
|
||||
schema.properties &&
|
||||
typeof data === "object" &&
|
||||
data !== null
|
||||
) {
|
||||
const dataObj = data as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray(schema.required)) {
|
||||
for (const field of schema.required) {
|
||||
const value = dataObj[field];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
setValidationError(`Required field missing: ${field}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fieldName, fieldValue] of Object.entries(dataObj)) {
|
||||
const fieldSchema = schema.properties[fieldName];
|
||||
if (
|
||||
fieldSchema &&
|
||||
fieldSchema.format === "email" &&
|
||||
typeof fieldValue === "string"
|
||||
) {
|
||||
if (!validateEmailFormat(fieldValue)) {
|
||||
setValidationError(`Invalid email format: ${fieldName}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
try {
|
||||
if (!validateFormData(formData, request.request.requestedSchema)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ajv = new Ajv();
|
||||
const validate = ajv.compile(request.request.requestedSchema);
|
||||
const isValid = validate(formData);
|
||||
|
||||
if (!isValid) {
|
||||
const errorMessage = ajv.errorsText(validate.errors);
|
||||
setValidationError(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
onResolve(request.id, {
|
||||
action: "accept",
|
||||
content: formData as Record<string, unknown>,
|
||||
});
|
||||
} catch (error) {
|
||||
setValidationError(
|
||||
error instanceof Error ? error.message : "Validation failed",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
onResolve(request.id, { action: "decline" });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onResolve(request.id, { action: "cancel" });
|
||||
};
|
||||
|
||||
const schemaTitle =
|
||||
request.request.requestedSchema.title || "Information Request";
|
||||
const schemaDescription = request.request.requestedSchema.description;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="elicitation-request"
|
||||
className="flex gap-4 p-4 border rounded-lg space-y-4"
|
||||
>
|
||||
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">{schemaTitle}</h4>
|
||||
<p className="text-sm">{request.request.message}</p>
|
||||
{schemaDescription && (
|
||||
<p className="text-xs text-muted-foreground">{schemaDescription}</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
|
||||
<JsonView
|
||||
data={JSON.stringify(request.request.requestedSchema, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Response Form</h4>
|
||||
<DynamicJsonForm
|
||||
schema={request.request.requestedSchema}
|
||||
value={formData}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setFormData(newValue);
|
||||
setValidationError(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{validationError && (
|
||||
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
<strong>Validation Error:</strong> {validationError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mt-1">
|
||||
<Button type="button" onClick={handleAccept}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleDecline}>
|
||||
Decline
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElicitationRequest;
|
||||
56
client/src/components/ElicitationTab.tsx
Normal file
56
client/src/components/ElicitationTab.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import ElicitationRequest from "./ElicitationRequest";
|
||||
|
||||
export interface ElicitationRequestData {
|
||||
id: number;
|
||||
message: string;
|
||||
requestedSchema: JsonSchemaType;
|
||||
}
|
||||
|
||||
export interface ElicitationResponse {
|
||||
action: "accept" | "decline" | "cancel";
|
||||
content?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PendingElicitationRequest = {
|
||||
id: number;
|
||||
request: ElicitationRequestData;
|
||||
originatingTab?: string;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
pendingRequests: PendingElicitationRequest[];
|
||||
onResolve: (id: number, response: ElicitationResponse) => void;
|
||||
};
|
||||
|
||||
const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
|
||||
return (
|
||||
<TabsContent value="elicitations">
|
||||
<div className="h-96">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
When the server requests information from the user, requests will
|
||||
appear here for response.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||
{pendingRequests.map((request) => (
|
||||
<ElicitationRequest
|
||||
key={request.id}
|
||||
request={request}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
{pendingRequests.length === 0 && (
|
||||
<p className="text-gray-500">No pending requests</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElicitationTab;
|
||||
|
|
@ -48,6 +48,7 @@ const PromptsTab = ({
|
|||
ref: PromptReference | ResourceReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
context?: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported: boolean;
|
||||
promptContent: string;
|
||||
|
|
@ -73,6 +74,7 @@ const PromptsTab = ({
|
|||
},
|
||||
argName,
|
||||
value,
|
||||
promptArgs,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
112
client/src/components/ResourceLinkView.tsx
Normal file
112
client/src/components/ResourceLinkView.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { useState, useCallback, useMemo, memo } from "react";
|
||||
import JsonView from "./JsonView";
|
||||
|
||||
interface ResourceLinkViewProps {
|
||||
uri: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
resourceContent: string;
|
||||
onReadResource?: (uri: string) => void;
|
||||
}
|
||||
|
||||
const ResourceLinkView = memo(
|
||||
({
|
||||
uri,
|
||||
name,
|
||||
description,
|
||||
mimeType,
|
||||
resourceContent,
|
||||
onReadResource,
|
||||
}: ResourceLinkViewProps) => {
|
||||
const [{ expanded, loading }, setState] = useState({
|
||||
expanded: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const expandedContent = useMemo(
|
||||
() =>
|
||||
expanded && resourceContent ? (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-semibold text-green-600">Resource:</span>
|
||||
</div>
|
||||
<JsonView data={resourceContent} className="bg-background" />
|
||||
</div>
|
||||
) : null,
|
||||
[expanded, resourceContent],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!onReadResource) return;
|
||||
if (!expanded) {
|
||||
setState((prev) => ({ ...prev, expanded: true, loading: true }));
|
||||
onReadResource(uri);
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, expanded: false }));
|
||||
}
|
||||
}, [expanded, onReadResource, uri]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && onReadResource) {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
},
|
||||
[handleClick, onReadResource],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="text-sm text-foreground bg-secondary py-2 px-3 rounded">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded"
|
||||
onClick={onReadResource ? handleClick : undefined}
|
||||
onKeyDown={onReadResource ? handleKeyDown : undefined}
|
||||
tabIndex={onReadResource ? 0 : -1}
|
||||
role="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<span className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline px-1 py-0.5 break-all font-mono flex-1 min-w-0">
|
||||
{uri}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{mimeType && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{mimeType}
|
||||
</span>
|
||||
)}
|
||||
{onReadResource && (
|
||||
<span className="ml-2 flex-shrink-0" aria-hidden="true">
|
||||
{loading ? (
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<span>{expanded ? "▼" : "▶"}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{name && (
|
||||
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100 mb-1">
|
||||
{name}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expandedContent}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ResourceLinkView;
|
||||
|
|
@ -52,6 +52,7 @@ const ResourcesTab = ({
|
|||
ref: ResourceReference | PromptReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
context?: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported: boolean;
|
||||
resourceContent: string;
|
||||
|
|
@ -94,6 +95,7 @@ const ResourcesTab = ({
|
|||
},
|
||||
key,
|
||||
value,
|
||||
templateValues,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ interface SidebarProps {
|
|||
setBearerToken: (token: string) => void;
|
||||
headerName?: string;
|
||||
setHeaderName?: (name: string) => void;
|
||||
oauthClientId: string;
|
||||
setOauthClientId: (id: string) => void;
|
||||
oauthScope: string;
|
||||
setOauthScope: (scope: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
stdErrNotifications: StdErrNotification[];
|
||||
|
|
@ -83,6 +87,10 @@ const Sidebar = ({
|
|||
setBearerToken,
|
||||
headerName,
|
||||
setHeaderName,
|
||||
oauthClientId,
|
||||
setOauthClientId,
|
||||
oauthScope,
|
||||
setOauthScope,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
stdErrNotifications,
|
||||
|
|
@ -95,7 +103,7 @@ const Sidebar = ({
|
|||
}: SidebarProps) => {
|
||||
const [theme, setTheme] = useTheme();
|
||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||
const [showBearerToken, setShowBearerToken] = useState(false);
|
||||
const [showAuthConfig, setShowAuthConfig] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
||||
|
|
@ -308,51 +316,6 @@ const Sidebar = ({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="auth-button"
|
||||
aria-expanded={showBearerToken}
|
||||
>
|
||||
{showBearerToken ? (
|
||||
<ChevronDown className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Authentication
|
||||
</Button>
|
||||
{showBearerToken && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Header Name</label>
|
||||
<Input
|
||||
placeholder="Authorization"
|
||||
onChange={(e) =>
|
||||
setHeaderName && setHeaderName(e.target.value)
|
||||
}
|
||||
data-testid="header-input"
|
||||
className="font-mono"
|
||||
value={headerName}
|
||||
/>
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="bearer-token-input"
|
||||
>
|
||||
Bearer Token
|
||||
</label>
|
||||
<Input
|
||||
id="bearer-token-input"
|
||||
placeholder="Bearer Token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
data-testid="bearer-token-input"
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -521,6 +484,94 @@ const Sidebar = ({
|
|||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAuthConfig(!showAuthConfig)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="auth-button"
|
||||
aria-expanded={showAuthConfig}
|
||||
>
|
||||
{showAuthConfig ? (
|
||||
<ChevronDown className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Authentication
|
||||
</Button>
|
||||
{showAuthConfig && (
|
||||
<>
|
||||
{/* Bearer Token Section */}
|
||||
<div className="space-y-2 p-3 rounded border">
|
||||
<h4 className="text-sm font-semibold flex items-center">
|
||||
API Token Authentication
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Header Name</label>
|
||||
<Input
|
||||
placeholder="Authorization"
|
||||
onChange={(e) =>
|
||||
setHeaderName && setHeaderName(e.target.value)
|
||||
}
|
||||
data-testid="header-input"
|
||||
className="font-mono"
|
||||
value={headerName}
|
||||
/>
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="bearer-token-input"
|
||||
>
|
||||
Bearer Token
|
||||
</label>
|
||||
<Input
|
||||
id="bearer-token-input"
|
||||
placeholder="Bearer Token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
data-testid="bearer-token-input"
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{transportType !== "stdio" && (
|
||||
// OAuth Configuration
|
||||
<div className="space-y-2 p-3 rounded border">
|
||||
<h4 className="text-sm font-semibold flex items-center">
|
||||
OAuth 2.0 Flow
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Client ID</label>
|
||||
<Input
|
||||
placeholder="Client ID"
|
||||
onChange={(e) => setOauthClientId(e.target.value)}
|
||||
value={oauthClientId}
|
||||
data-testid="oauth-client-id-input"
|
||||
className="font-mono"
|
||||
/>
|
||||
<label className="text-sm font-medium">
|
||||
Redirect URL
|
||||
</label>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="Redirect URL"
|
||||
value={window.location.origin + "/oauth/callback"}
|
||||
className="font-mono"
|
||||
/>
|
||||
<label className="text-sm font-medium">Scope</label>
|
||||
<Input
|
||||
placeholder="Scope (space-separated)"
|
||||
onChange={(e) => setOauthScope(e.target.value)}
|
||||
value={oauthScope}
|
||||
data-testid="oauth-scope-input"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Configuration */}
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import JsonView from "./JsonView";
|
||||
import ResourceLinkView from "./ResourceLinkView";
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
CompatibilityCallToolResult,
|
||||
|
|
@ -9,6 +10,8 @@ import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
|
|||
interface ToolResultsProps {
|
||||
toolResult: CompatibilityCallToolResult | null;
|
||||
selectedTool: Tool | null;
|
||||
resourceContent: Record<string, string>;
|
||||
onReadResource?: (uri: string) => void;
|
||||
}
|
||||
|
||||
const checkContentCompatibility = (
|
||||
|
|
@ -19,49 +22,54 @@ const checkContentCompatibility = (
|
|||
[key: string]: unknown;
|
||||
}>,
|
||||
): { isCompatible: boolean; message: string } => {
|
||||
if (
|
||||
unstructuredContent.length !== 1 ||
|
||||
unstructuredContent[0].type !== "text"
|
||||
) {
|
||||
// Look for at least one text content block that matches the structured content
|
||||
const textBlocks = unstructuredContent.filter(
|
||||
(block) => block.type === "text",
|
||||
);
|
||||
|
||||
if (textBlocks.length === 0) {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Unstructured content is not a single text block",
|
||||
message: "No text blocks to match structured content",
|
||||
};
|
||||
}
|
||||
|
||||
const textContent = unstructuredContent[0].text;
|
||||
if (!textContent) {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Text content is empty",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedContent = JSON.parse(textContent);
|
||||
const isEqual =
|
||||
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
|
||||
|
||||
if (isEqual) {
|
||||
return {
|
||||
isCompatible: true,
|
||||
message: "Unstructured content matches structured content",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Parsed JSON does not match structured content",
|
||||
};
|
||||
// Check if any text block contains JSON that matches the structured content
|
||||
for (const textBlock of textBlocks) {
|
||||
const textContent = textBlock.text;
|
||||
if (!textContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedContent = JSON.parse(textContent);
|
||||
const isEqual =
|
||||
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
|
||||
|
||||
if (isEqual) {
|
||||
return {
|
||||
isCompatible: true,
|
||||
message: `Structured content matches text block${textBlocks.length > 1 ? " (multiple blocks)" : ""}${unstructuredContent.length > textBlocks.length ? " + other content" : ""}`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Continue to next text block if this one doesn't parse as JSON
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Unstructured content is not valid JSON",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "No text block matches structured content",
|
||||
};
|
||||
};
|
||||
|
||||
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
|
||||
const ToolResults = ({
|
||||
toolResult,
|
||||
selectedTool,
|
||||
resourceContent,
|
||||
onReadResource,
|
||||
}: ToolResultsProps) => {
|
||||
if (!toolResult) return null;
|
||||
|
||||
if ("content" in toolResult) {
|
||||
|
|
@ -146,6 +154,14 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{structuredResult._meta && (
|
||||
<div className="mb-4">
|
||||
<h5 className="font-semibold mb-2 text-sm">Meta:</h5>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||
<JsonView data={structuredResult._meta} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!structuredResult.structuredContent &&
|
||||
validationResult &&
|
||||
!validationResult.isValid && (
|
||||
|
|
@ -200,6 +216,16 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
|
|||
) : (
|
||||
<JsonView data={item.resource} />
|
||||
))}
|
||||
{item.type === "resource_link" && (
|
||||
<ResourceLinkView
|
||||
uri={item.uri}
|
||||
name={item.name}
|
||||
description={item.description}
|
||||
mimeType={item.mimeType}
|
||||
resourceContent={resourceContent[item.uri] || ""}
|
||||
onReadResource={onReadResource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import ListPane from "./ListPane";
|
|||
import JsonView from "./JsonView";
|
||||
import ToolResults from "./ToolResults";
|
||||
|
||||
// Type guard to safely detect the optional _meta field without using `any`
|
||||
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
|
||||
typeof (tool as { _meta?: unknown })._meta !== "undefined";
|
||||
|
||||
const ToolsTab = ({
|
||||
tools,
|
||||
listTools,
|
||||
|
|
@ -35,6 +39,8 @@ const ToolsTab = ({
|
|||
toolResult,
|
||||
nextCursor,
|
||||
error,
|
||||
resourceContent,
|
||||
onReadResource,
|
||||
}: {
|
||||
tools: Tool[];
|
||||
listTools: () => void;
|
||||
|
|
@ -45,10 +51,13 @@ const ToolsTab = ({
|
|||
toolResult: CompatibilityCallToolResult | null;
|
||||
nextCursor: ListToolsResult["nextCursor"];
|
||||
error: string | null;
|
||||
resourceContent: Record<string, string>;
|
||||
onReadResource?: (uri: string) => void;
|
||||
}) => {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
|
||||
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -257,6 +266,40 @@ const ToolsTab = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedTool &&
|
||||
hasMeta(selectedTool) &&
|
||||
selectedTool._meta && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold">Meta:</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsMetaExpanded(!isMetaExpanded)}
|
||||
className="h-6 px-2"
|
||||
>
|
||||
{isMetaExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
Expand
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all ${
|
||||
isMetaExpanded ? "" : "max-h-[8rem] overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
<JsonView data={selectedTool._meta} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
// Validate JSON inputs before calling tool
|
||||
|
|
@ -289,6 +332,8 @@ const ToolsTab = ({
|
|||
<ToolResults
|
||||
toolResult={toolResult}
|
||||
selectedTool={selectedTool}
|
||||
resourceContent={resourceContent}
|
||||
onReadResource={onReadResource}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import DynamicJsonForm from "../DynamicJsonForm";
|
||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||
|
|
@ -35,6 +36,188 @@ describe("DynamicJsonForm String Fields", () => {
|
|||
expect(input).toHaveProperty("type", "text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Format Support", () => {
|
||||
it("should render email input for email format", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
format: "email",
|
||||
description: "Email address",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "email");
|
||||
});
|
||||
|
||||
it("should render url input for uri format", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
format: "uri",
|
||||
description: "Website URL",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "url");
|
||||
});
|
||||
|
||||
it("should render date input for date format", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
format: "date",
|
||||
description: "Birth date",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue("");
|
||||
expect(input).toHaveProperty("type", "date");
|
||||
});
|
||||
|
||||
it("should render datetime-local input for date-time format", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
description: "Event datetime",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByDisplayValue("");
|
||||
expect(input).toHaveProperty("type", "datetime-local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enum Support", () => {
|
||||
it("should render select dropdown for enum fields", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
enum: ["option1", "option2", "option3"],
|
||||
description: "Select an option",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select.tagName).toBe("SELECT");
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("should use oneOf with const and title for labeled options", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{ const: "val1", title: "Label 1" },
|
||||
{ const: "val2", title: "Label 2" },
|
||||
],
|
||||
description: "Select with labels",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[1]).toHaveProperty("textContent", "Label 1");
|
||||
expect(options[2]).toHaveProperty("textContent", "Label 2");
|
||||
});
|
||||
|
||||
it("should call onChange with selected oneOf value", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{ const: "option1", title: "Option 1" },
|
||||
{ const: "option2", title: "Option 2" },
|
||||
],
|
||||
description: "Select an option",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "option1" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("option1");
|
||||
});
|
||||
|
||||
it("should call onChange with selected enum value", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
enum: ["option1", "option2"],
|
||||
description: "Select an option",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "option1" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("option1");
|
||||
});
|
||||
|
||||
it("should render JSON Schema spec compliant oneOf with const for labeled enums", () => {
|
||||
// Example from JSON Schema spec: labeled enums using oneOf with const
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
title: "Traffic Light",
|
||||
description: "Select a traffic light color",
|
||||
oneOf: [
|
||||
{ const: "red", title: "Stop" },
|
||||
{ const: "amber", title: "Caution" },
|
||||
{ const: "green", title: "Go" },
|
||||
],
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||
|
||||
// Should render as a select dropdown
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select.tagName).toBe("SELECT");
|
||||
|
||||
// Should have options with proper labels
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options).toHaveLength(4); // 3 options + 1 default "Select an option..."
|
||||
|
||||
expect(options[0]).toHaveProperty("textContent", "Select an option...");
|
||||
expect(options[1]).toHaveProperty("textContent", "Stop");
|
||||
expect(options[2]).toHaveProperty("textContent", "Caution");
|
||||
expect(options[3]).toHaveProperty("textContent", "Go");
|
||||
|
||||
// Should have proper values
|
||||
expect(options[1]).toHaveProperty("value", "red");
|
||||
expect(options[2]).toHaveProperty("value", "amber");
|
||||
expect(options[3]).toHaveProperty("value", "green");
|
||||
|
||||
// Test onChange behavior
|
||||
fireEvent.change(select, { target: { value: "amber" } });
|
||||
expect(onChange).toHaveBeenCalledWith("amber");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation Attributes", () => {
|
||||
it("should apply minLength and maxLength", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
minLength: 3,
|
||||
maxLength: 10,
|
||||
description: "Username",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("minLength", 3);
|
||||
expect(input).toHaveProperty("maxLength", 10);
|
||||
});
|
||||
|
||||
it("should apply pattern validation", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "string",
|
||||
pattern: "^[A-Za-z]+$",
|
||||
description: "Letters only",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("pattern", "^[A-Za-z]+$");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Integer Fields", () => {
|
||||
|
|
@ -81,6 +264,38 @@ describe("DynamicJsonForm Integer Fields", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should apply min and max constraints", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
description: "Age",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
expect(input).toHaveProperty("min", "0");
|
||||
expect(input).toHaveProperty("max", "100");
|
||||
});
|
||||
|
||||
it("should only accept integer values", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "integer",
|
||||
description: "Count",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "3.14" } });
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle non-numeric input by not calling onChange", () => {
|
||||
const onChange = jest.fn();
|
||||
|
|
@ -94,6 +309,233 @@ describe("DynamicJsonForm Integer Fields", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Number Fields", () => {
|
||||
describe("Validation", () => {
|
||||
it("should apply min and max constraints", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "number",
|
||||
minimum: 0.5,
|
||||
maximum: 99.9,
|
||||
description: "Score",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
expect(input).toHaveProperty("min", "0.5");
|
||||
expect(input).toHaveProperty("max", "99.9");
|
||||
});
|
||||
|
||||
it("should accept decimal values", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "number",
|
||||
description: "Temperature",
|
||||
};
|
||||
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "98.6" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(98.6);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Boolean Fields", () => {
|
||||
describe("Basic Operations", () => {
|
||||
it("should render checkbox for boolean type", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "boolean",
|
||||
description: "Enable notifications",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={false} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toHaveProperty("type", "checkbox");
|
||||
});
|
||||
|
||||
it("should call onChange with boolean value", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "boolean",
|
||||
description: "Accept terms",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={false} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should render boolean field with description", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "boolean",
|
||||
description: "Enable dark mode",
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={false} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toHaveProperty("checked", false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Object Fields", () => {
|
||||
describe("Property Rendering", () => {
|
||||
it("should render input fields for object properties", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Full Name",
|
||||
description: "Your full name",
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
title: "Age",
|
||||
description: "Your age in years",
|
||||
minimum: 18,
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
format: "email",
|
||||
title: "Email",
|
||||
description: "Your email address",
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const textInputs = screen.getAllByRole("textbox");
|
||||
const numberInput = screen.getByRole("spinbutton");
|
||||
|
||||
expect(textInputs).toHaveLength(2);
|
||||
expect(textInputs[0]).toHaveProperty("type", "text");
|
||||
expect(textInputs[1]).toHaveProperty("type", "email");
|
||||
expect(numberInput).toHaveProperty("type", "number");
|
||||
expect(numberInput).toHaveProperty("min", "18");
|
||||
});
|
||||
|
||||
it("should handle object field changes correctly", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
username: {
|
||||
type: "string",
|
||||
description: "Your username",
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={{}} onChange={onChange} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "testuser" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ username: "testuser" });
|
||||
});
|
||||
|
||||
it("should handle nested object values correctly", () => {
|
||||
const onChange = jest.fn();
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
},
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm
|
||||
schema={schema}
|
||||
value={{ name: "John" }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue("John");
|
||||
fireEvent.change(input, { target: { value: "Jane" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ name: "Jane" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Required Fields", () => {
|
||||
it("should mark required fields with required attribute", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
title: "Email",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
const nameInput = inputs[0];
|
||||
const emailInput = inputs[1];
|
||||
|
||||
expect(nameInput).toHaveProperty("required", true);
|
||||
expect(emailInput).toHaveProperty("required", false);
|
||||
});
|
||||
|
||||
it("should mark required fields with required attribute", () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
optional: {
|
||||
type: "string",
|
||||
title: "Optional",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
render(
|
||||
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
const nameLabel = screen.getByText("name");
|
||||
const optionalLabel = screen.getByText("optional");
|
||||
|
||||
const nameInput = nameLabel.closest("div")?.querySelector("input");
|
||||
const optionalInput = optionalLabel
|
||||
.closest("div")
|
||||
?.querySelector("input");
|
||||
|
||||
expect(nameInput).toHaveProperty("required", true);
|
||||
expect(optionalInput).toHaveProperty("required", false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Complex Fields", () => {
|
||||
const renderForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
|
|
|
|||
202
client/src/components/__tests__/ElicitationRequest.test.tsx
Normal file
202
client/src/components/__tests__/ElicitationRequest.test.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||
import ElicitationRequest from "../ElicitationRequest";
|
||||
import { PendingElicitationRequest } from "../ElicitationTab";
|
||||
|
||||
jest.mock("../DynamicJsonForm", () => {
|
||||
return function MockDynamicJsonForm({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="dynamic-json-form">
|
||||
<input
|
||||
data-testid="form-input"
|
||||
value={
|
||||
typeof value === "object" && value !== null
|
||||
? JSON.stringify(value)
|
||||
: String(value || "")
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
onChange(parsed);
|
||||
} catch {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
describe("ElicitationRequest", () => {
|
||||
const mockOnResolve = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockRequest = (
|
||||
overrides: Partial<PendingElicitationRequest> = {},
|
||||
): PendingElicitationRequest => ({
|
||||
id: 1,
|
||||
request: {
|
||||
id: 1,
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Your name" },
|
||||
email: { type: "string", format: "email", description: "Your email" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderElicitationRequest = (
|
||||
request: PendingElicitationRequest = createMockRequest(),
|
||||
) => {
|
||||
return render(
|
||||
<ElicitationRequest request={request} onResolve={mockOnResolve} />,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the component", () => {
|
||||
renderElicitationRequest();
|
||||
expect(screen.getByTestId("elicitation-request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display request message", () => {
|
||||
const message = "Please provide your GitHub username";
|
||||
renderElicitationRequest(
|
||||
createMockRequest({
|
||||
request: {
|
||||
id: 1,
|
||||
message,
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all three action buttons", () => {
|
||||
renderElicitationRequest();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /cancel/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /decline/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /submit/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render DynamicJsonForm component", () => {
|
||||
renderElicitationRequest();
|
||||
expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
it("should call onResolve with accept action when Submit button is clicked", async () => {
|
||||
renderElicitationRequest();
|
||||
|
||||
const input = screen.getByTestId("form-input");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
value: '{"name": "John Doe", "email": "john@example.com"}',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
||||
});
|
||||
|
||||
expect(mockOnResolve).toHaveBeenCalledWith(1, {
|
||||
action: "accept",
|
||||
content: { name: "John Doe", email: "john@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onResolve with decline action when Decline button is clicked", async () => {
|
||||
renderElicitationRequest();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /decline/i }));
|
||||
});
|
||||
|
||||
expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "decline" });
|
||||
});
|
||||
|
||||
it("should call onResolve with cancel action when Cancel button is clicked", async () => {
|
||||
renderElicitationRequest();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
});
|
||||
|
||||
expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "cancel" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Validation", () => {
|
||||
it("should show validation error for missing required fields", async () => {
|
||||
renderElicitationRequest();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/Required field missing: name/),
|
||||
).toBeInTheDocument();
|
||||
expect(mockOnResolve).not.toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ action: "accept" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show validation error for invalid email format", async () => {
|
||||
renderElicitationRequest();
|
||||
|
||||
const input = screen.getByTestId("form-input");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, {
|
||||
target: { value: '{"name": "John", "email": "invalid-email"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/Invalid email format: email/),
|
||||
).toBeInTheDocument();
|
||||
expect(mockOnResolve).not.toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ action: "accept" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
client/src/components/__tests__/ElicitationTab.test.tsx
Normal file
50
client/src/components/__tests__/ElicitationTab.test.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import ElicitationTab, { PendingElicitationRequest } from "../ElicitationTab";
|
||||
|
||||
describe("Elicitation tab", () => {
|
||||
const mockOnResolve = jest.fn();
|
||||
|
||||
const renderElicitationTab = (pendingRequests: PendingElicitationRequest[]) =>
|
||||
render(
|
||||
<Tabs defaultValue="elicitations">
|
||||
<ElicitationTab
|
||||
pendingRequests={pendingRequests}
|
||||
onResolve={mockOnResolve}
|
||||
/>
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
it("should render 'No pending requests' when there are no pending requests", () => {
|
||||
renderElicitationTab([]);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"When the server requests information from the user, requests will appear here for response.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.findByText("No pending requests")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render the correct number of requests", () => {
|
||||
renderElicitationTab(
|
||||
Array.from({ length: 3 }, (_, i) => ({
|
||||
id: i,
|
||||
request: {
|
||||
id: i,
|
||||
message: `Please provide information ${i}`,
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Your name",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
expect(screen.getAllByTestId("elicitation-request").length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,10 @@ describe("Sidebar Environment Variables", () => {
|
|||
setArgs: jest.fn(),
|
||||
sseUrl: "",
|
||||
setSseUrl: jest.fn(),
|
||||
oauthClientId: "",
|
||||
setOauthClientId: jest.fn(),
|
||||
oauthScope: "",
|
||||
setOauthScope: jest.fn(),
|
||||
env: {},
|
||||
setEnv: jest.fn(),
|
||||
bearerToken: "",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ToolsTab from "../ToolsTab";
|
|||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
|
||||
import { within } from "@testing-library/react";
|
||||
|
||||
describe("ToolsTab", () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -55,6 +56,8 @@ describe("ToolsTab", () => {
|
|||
toolResult: null,
|
||||
nextCursor: "",
|
||||
error: null,
|
||||
resourceContent: {},
|
||||
onReadResource: jest.fn(),
|
||||
};
|
||||
|
||||
const renderToolsTab = (props = {}) => {
|
||||
|
|
@ -249,10 +252,12 @@ describe("ToolsTab", () => {
|
|||
},
|
||||
};
|
||||
|
||||
it("should display structured content when present", () => {
|
||||
// Cache the tool's output schema so hasOutputSchema returns true
|
||||
beforeEach(() => {
|
||||
// Cache the tool's output schema before each test
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
});
|
||||
|
||||
it("should display structured content when present", () => {
|
||||
const structuredResult = {
|
||||
content: [],
|
||||
structuredContent: {
|
||||
|
|
@ -261,6 +266,7 @@ describe("ToolsTab", () => {
|
|||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: structuredResult,
|
||||
});
|
||||
|
|
@ -272,8 +278,6 @@ describe("ToolsTab", () => {
|
|||
});
|
||||
|
||||
it("should show validation error for invalid structured content", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const invalidResult = {
|
||||
content: [],
|
||||
structuredContent: {
|
||||
|
|
@ -282,6 +286,7 @@ describe("ToolsTab", () => {
|
|||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: invalidResult,
|
||||
});
|
||||
|
|
@ -290,14 +295,13 @@ describe("ToolsTab", () => {
|
|||
});
|
||||
|
||||
it("should show error when tool with output schema doesn't return structured content", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const resultWithoutStructured = {
|
||||
content: [{ type: "text", text: "some result" }],
|
||||
// No structuredContent
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: resultWithoutStructured,
|
||||
});
|
||||
|
|
@ -310,14 +314,13 @@ describe("ToolsTab", () => {
|
|||
});
|
||||
|
||||
it("should show unstructured content title when both structured and unstructured exist", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const resultWithBoth = {
|
||||
content: [{ type: "text", text: '{"temperature": 25}' }],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: resultWithBoth,
|
||||
});
|
||||
|
|
@ -342,26 +345,100 @@ describe("ToolsTab", () => {
|
|||
});
|
||||
|
||||
it("should show compatibility check when tool has output schema", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const compatibleResult = {
|
||||
content: [{ type: "text", text: '{"temperature": 25}' }],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: compatibleResult,
|
||||
});
|
||||
|
||||
// Should show compatibility result
|
||||
expect(
|
||||
screen.getByText(
|
||||
/matches structured content|not a single text block|not valid JSON|does not match/,
|
||||
),
|
||||
screen.getByText(/structured content matches/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should accept multiple content blocks with structured output", () => {
|
||||
const multipleBlocksResult = {
|
||||
content: [
|
||||
{ type: "text", text: "Here is the weather data:" },
|
||||
{ type: "text", text: '{"temperature": 25}' },
|
||||
{ type: "text", text: "Have a nice day!" },
|
||||
],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: multipleBlocksResult,
|
||||
});
|
||||
|
||||
// Should show compatible result with multiple blocks
|
||||
expect(
|
||||
screen.getByText(/structured content matches.*multiple/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should accept mixed content types with structured output", () => {
|
||||
const mixedContentResult = {
|
||||
content: [
|
||||
{ type: "text", text: "Weather report:" },
|
||||
{ type: "text", text: '{"temperature": 25}' },
|
||||
{ type: "image", data: "base64data", mimeType: "image/png" },
|
||||
],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: mixedContentResult,
|
||||
});
|
||||
|
||||
// Should render without crashing - the validation logic has been updated
|
||||
expect(screen.getAllByText("weatherTool")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should reject when no text blocks match structured content", () => {
|
||||
const noMatchResult = {
|
||||
content: [
|
||||
{ type: "text", text: "Some text" },
|
||||
{ type: "text", text: '{"humidity": 60}' }, // Different structure
|
||||
],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: noMatchResult,
|
||||
});
|
||||
|
||||
// Should render without crashing - the validation logic has been updated
|
||||
expect(screen.getAllByText("weatherTool")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should reject when no text blocks are present", () => {
|
||||
const noTextBlocksResult = {
|
||||
content: [{ type: "image", data: "base64data", mimeType: "image/png" }],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: noTextBlocksResult,
|
||||
});
|
||||
|
||||
// Should render without crashing - the validation logic has been updated
|
||||
expect(screen.getAllByText("weatherTool")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should not show compatibility check when tool has no output schema", () => {
|
||||
const resultWithBoth = {
|
||||
content: [{ type: "text", text: '{"data": "value"}' }],
|
||||
|
|
@ -376,9 +453,188 @@ describe("ToolsTab", () => {
|
|||
// Should not show any compatibility messages
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/matches structured content|not a single text block|not valid JSON|does not match/,
|
||||
/structured content matches|no text blocks|no.*matches/i,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Resource Link Content Type", () => {
|
||||
it("should render resource_link content type and handle expansion", async () => {
|
||||
const mockOnReadResource = jest.fn();
|
||||
const resourceContent = {
|
||||
"test://static/resource/1": JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
uri: "test://static/resource/1",
|
||||
name: "Resource 1",
|
||||
mimeType: "text/plain",
|
||||
text: "Resource 1: This is a plaintext resource",
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const result = {
|
||||
content: [
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "test://static/resource/1",
|
||||
name: "Resource 1",
|
||||
description: "Resource 1: plaintext resource",
|
||||
mimeType: "text/plain",
|
||||
},
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "test://static/resource/2",
|
||||
name: "Resource 2",
|
||||
description: "Resource 2: binary blob resource",
|
||||
mimeType: "application/octet-stream",
|
||||
},
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "test://static/resource/3",
|
||||
name: "Resource 3",
|
||||
description: "Resource 3: plaintext resource",
|
||||
mimeType: "text/plain",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0],
|
||||
toolResult: result,
|
||||
resourceContent,
|
||||
onReadResource: mockOnReadResource,
|
||||
});
|
||||
|
||||
["1", "2", "3"].forEach((id) => {
|
||||
expect(
|
||||
screen.getByText(`test://static/resource/${id}`),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText("text/plain")).toHaveLength(2);
|
||||
expect(screen.getByText("application/octet-stream")).toBeInTheDocument();
|
||||
|
||||
const expandButtons = screen.getAllByRole("button", {
|
||||
name: /expand resource/i,
|
||||
});
|
||||
expect(expandButtons).toHaveLength(3);
|
||||
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
|
||||
|
||||
expandButtons.forEach((button) => {
|
||||
expect(button).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
const resource1Button = screen.getByRole("button", {
|
||||
name: /expand resource test:\/\/static\/resource\/1/i,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(resource1Button);
|
||||
});
|
||||
|
||||
expect(mockOnReadResource).toHaveBeenCalledWith(
|
||||
"test://static/resource/1",
|
||||
);
|
||||
expect(screen.getByText("Resource:")).toBeInTheDocument();
|
||||
expect(document.body).toHaveTextContent("contents:");
|
||||
expect(document.body).toHaveTextContent('uri:"test://static/resource/1"');
|
||||
expect(resource1Button).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(resource1Button);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
|
||||
expect(document.body).not.toHaveTextContent("contents:");
|
||||
expect(document.body).not.toHaveTextContent(
|
||||
'uri:"test://static/resource/1"',
|
||||
);
|
||||
expect(resource1Button).toHaveAttribute("aria-expanded", "false");
|
||||
expect(mockOnReadResource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Meta Display", () => {
|
||||
const toolWithMeta = {
|
||||
name: "metaTool",
|
||||
description: "Tool with meta",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
foo: { type: "string" as const },
|
||||
},
|
||||
},
|
||||
_meta: {
|
||||
author: "tester",
|
||||
version: 1,
|
||||
},
|
||||
} as unknown as Tool;
|
||||
|
||||
it("should display meta section when tool has _meta", () => {
|
||||
renderToolsTab({
|
||||
tools: [toolWithMeta],
|
||||
selectedTool: toolWithMeta,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Meta:")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /expand/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle meta expansion", () => {
|
||||
renderToolsTab({
|
||||
tools: [toolWithMeta],
|
||||
selectedTool: toolWithMeta,
|
||||
});
|
||||
|
||||
// There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section
|
||||
const metaHeading = screen.getByText("Meta:");
|
||||
const metaContainer = metaHeading.closest("div");
|
||||
expect(metaContainer).toBeTruthy();
|
||||
const toggleButton = within(metaContainer as HTMLElement).getByRole(
|
||||
"button",
|
||||
{ name: /expand/i },
|
||||
);
|
||||
|
||||
// Expand Meta
|
||||
fireEvent.click(toggleButton);
|
||||
expect(
|
||||
within(metaContainer as HTMLElement).getByRole("button", {
|
||||
name: /collapse/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Collapse Meta
|
||||
fireEvent.click(toggleButton);
|
||||
expect(
|
||||
within(metaContainer as HTMLElement).getByRole("button", {
|
||||
name: /expand/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolResults Meta", () => {
|
||||
it("should display meta information when present in toolResult", () => {
|
||||
const resultWithMeta = {
|
||||
content: [],
|
||||
_meta: { info: "details", version: 2 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0],
|
||||
toolResult: resultWithMeta,
|
||||
});
|
||||
|
||||
// Only ToolResults meta should be present since selectedTool has no _meta
|
||||
expect(screen.getAllByText("Meta:")).toHaveLength(1);
|
||||
expect(screen.getByText(/info/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/version/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue