Merge pull request #535 from QuantGeekDev/feature/elicitations
add: elicitation support
This commit is contained in:
commit
9f6e731dc4
12 changed files with 1684 additions and 57 deletions
|
|
@ -25,9 +25,8 @@
|
|||
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
||||
"@modelcontextprotocol/sdk": "^1.14.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"ajv": "^6.12.6",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
|
|
@ -37,6 +36,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"ajv": "^6.12.6",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ import {
|
|||
initializeInspectorConfig,
|
||||
saveInspectorConfig,
|
||||
} from "./utils/configUtils";
|
||||
import ElicitationTab, {
|
||||
PendingElicitationRequest,
|
||||
ElicitationResponse,
|
||||
} from "./components/ElicitationTab";
|
||||
|
||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||
|
||||
|
|
@ -135,13 +139,19 @@ const App = () => {
|
|||
}
|
||||
>
|
||||
>([]);
|
||||
const [pendingElicitationRequests, setPendingElicitationRequests] = useState<
|
||||
Array<
|
||||
PendingElicitationRequest & {
|
||||
resolve: (response: ElicitationResponse) => void;
|
||||
decline: (error: Error) => void;
|
||||
}
|
||||
>
|
||||
>([]);
|
||||
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
|
||||
|
||||
// Auth debugger state
|
||||
const [authState, setAuthState] =
|
||||
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
||||
|
||||
// Helper function to update specific auth state properties
|
||||
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||
setAuthState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
|
@ -169,6 +179,19 @@ const App = () => {
|
|||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||
const progressTokenRef = useRef(0);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const initialTab = hash || "resources";
|
||||
return initialTab;
|
||||
});
|
||||
|
||||
const currentTabRef = useRef<string>(activeTab);
|
||||
const lastToolCallOriginTabRef = useRef<string>(activeTab);
|
||||
|
||||
useEffect(() => {
|
||||
currentTabRef.current = activeTab;
|
||||
}, [activeTab]);
|
||||
|
||||
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
||||
const {
|
||||
width: sidebarWidth,
|
||||
|
|
@ -213,9 +236,64 @@ const App = () => {
|
|||
{ id: nextRequestId.current++, request, resolve, reject },
|
||||
]);
|
||||
},
|
||||
onElicitationRequest: (request, resolve) => {
|
||||
const currentTab = lastToolCallOriginTabRef.current;
|
||||
|
||||
setPendingElicitationRequests((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextRequestId.current++,
|
||||
request: {
|
||||
id: nextRequestId.current,
|
||||
message: request.params.message,
|
||||
requestedSchema: request.params.requestedSchema,
|
||||
},
|
||||
originatingTab: currentTab,
|
||||
resolve,
|
||||
decline: (error: Error) => {
|
||||
console.error("Elicitation request rejected:", error);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
setActiveTab("elicitations");
|
||||
window.location.hash = "elicitations";
|
||||
},
|
||||
getRoots: () => rootsRef.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (serverCapabilities) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const validTabs = [
|
||||
...(serverCapabilities?.resources ? ["resources"] : []),
|
||||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
];
|
||||
|
||||
const isValidTab = validTabs.includes(hash);
|
||||
|
||||
if (!isValidTab) {
|
||||
const defaultTab = serverCapabilities?.resources
|
||||
? "resources"
|
||||
: serverCapabilities?.prompts
|
||||
? "prompts"
|
||||
: serverCapabilities?.tools
|
||||
? "tools"
|
||||
: "ping";
|
||||
|
||||
setActiveTab(defaultTab);
|
||||
window.location.hash = defaultTab;
|
||||
}
|
||||
}
|
||||
}, [serverCapabilities]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastCommand", command);
|
||||
}, [command]);
|
||||
|
|
@ -252,7 +330,6 @@ const App = () => {
|
|||
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
||||
}, [config]);
|
||||
|
||||
// Auto-connect to previously saved serverURL after OAuth callback
|
||||
const onOAuthConnect = useCallback(
|
||||
(serverUrl: string) => {
|
||||
setSseUrl(serverUrl);
|
||||
|
|
@ -262,7 +339,6 @@ const App = () => {
|
|||
[connectMcpServer],
|
||||
);
|
||||
|
||||
// Update OAuth debug state during debug callback
|
||||
const onOAuthDebugConnect = useCallback(
|
||||
async ({
|
||||
authorizationCode,
|
||||
|
|
@ -283,7 +359,6 @@ const App = () => {
|
|||
}
|
||||
|
||||
if (restoredState && authorizationCode) {
|
||||
// Restore the previous auth state and continue the OAuth flow
|
||||
let currentState: AuthDebuggerState = {
|
||||
...restoredState,
|
||||
authorizationCode,
|
||||
|
|
@ -294,12 +369,10 @@ const App = () => {
|
|||
};
|
||||
|
||||
try {
|
||||
// Create a new state machine instance to continue the flow
|
||||
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
});
|
||||
|
||||
// Continue stepping through the OAuth flow from where we left off
|
||||
while (
|
||||
currentState.oauthStep !== "complete" &&
|
||||
currentState.oauthStep !== "authorization_code"
|
||||
|
|
@ -308,7 +381,6 @@ const App = () => {
|
|||
}
|
||||
|
||||
if (currentState.oauthStep === "complete") {
|
||||
// After the flow completes or reaches a user-input step, update the app state
|
||||
updateAuthState({
|
||||
...currentState,
|
||||
statusMessage: {
|
||||
|
|
@ -331,7 +403,6 @@ const App = () => {
|
|||
});
|
||||
}
|
||||
} else if (authorizationCode) {
|
||||
// Fallback to the original behavior if no state was restored
|
||||
updateAuthState({
|
||||
authorizationCode,
|
||||
oauthStep: "token_request",
|
||||
|
|
@ -341,7 +412,6 @@ const App = () => {
|
|||
[sseUrl],
|
||||
);
|
||||
|
||||
// Load OAuth tokens when sseUrl changes
|
||||
useEffect(() => {
|
||||
const loadOAuthTokens = async () => {
|
||||
try {
|
||||
|
|
@ -400,6 +470,18 @@ const App = () => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash && hash !== activeTab) {
|
||||
setActiveTab(hash);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
return () => window.removeEventListener("hashchange", handleHashChange);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
||||
setPendingSampleRequests((prev) => {
|
||||
const request = prev.find((r) => r.id === id);
|
||||
|
|
@ -416,6 +498,44 @@ const App = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleResolveElicitation = (
|
||||
id: number,
|
||||
response: ElicitationResponse,
|
||||
) => {
|
||||
setPendingElicitationRequests((prev) => {
|
||||
const request = prev.find((r) => r.id === id);
|
||||
if (request) {
|
||||
request.resolve(response);
|
||||
|
||||
if (request.originatingTab) {
|
||||
const originatingTab = request.originatingTab;
|
||||
|
||||
const validTabs = [
|
||||
...(serverCapabilities?.resources ? ["resources"] : []),
|
||||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
];
|
||||
|
||||
if (validTabs.includes(originatingTab)) {
|
||||
setActiveTab(originatingTab);
|
||||
window.location.hash = originatingTab;
|
||||
|
||||
setTimeout(() => {
|
||||
setActiveTab(originatingTab);
|
||||
window.location.hash = originatingTab;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
return prev.filter((r) => r.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const clearError = (tabKey: keyof typeof errors) => {
|
||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||
};
|
||||
|
|
@ -473,7 +593,23 @@ const App = () => {
|
|||
setNextResourceTemplateCursor(response.nextCursor);
|
||||
};
|
||||
|
||||
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "prompts/get" as const,
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
GetPromptResultSchema,
|
||||
"prompts",
|
||||
);
|
||||
setPromptContent(JSON.stringify(response, null, 2));
|
||||
};
|
||||
|
||||
const readResource = async (uri: string) => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "resources/read" as const,
|
||||
|
|
@ -535,18 +671,6 @@ const App = () => {
|
|||
setNextPromptCursor(response.nextCursor);
|
||||
};
|
||||
|
||||
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "prompts/get" as const,
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
GetPromptResultSchema,
|
||||
"prompts",
|
||||
);
|
||||
setPromptContent(JSON.stringify(response, null, 2));
|
||||
};
|
||||
|
||||
const listTools = async () => {
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
|
|
@ -558,11 +682,12 @@ const App = () => {
|
|||
);
|
||||
setTools(response.tools);
|
||||
setNextToolCursor(response.nextCursor);
|
||||
// Cache output schemas for validation
|
||||
cacheToolOutputSchemas(response.tools);
|
||||
};
|
||||
|
||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
try {
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
|
|
@ -578,6 +703,7 @@ const App = () => {
|
|||
CompatibilityCallToolResultSchema,
|
||||
"tools",
|
||||
);
|
||||
|
||||
setToolResult(response);
|
||||
} catch (e) {
|
||||
const toolResult: CompatibilityCallToolResult = {
|
||||
|
|
@ -612,7 +738,6 @@ const App = () => {
|
|||
setStdErrNotifications([]);
|
||||
};
|
||||
|
||||
// Helper component for rendering the AuthDebugger
|
||||
const AuthDebuggerWrapper = () => (
|
||||
<TabsContent value="auth">
|
||||
<AuthDebugger
|
||||
|
|
@ -624,7 +749,6 @@ const App = () => {
|
|||
</TabsContent>
|
||||
);
|
||||
|
||||
// Helper function to render OAuth callback components
|
||||
if (window.location.pathname === "/oauth/callback") {
|
||||
const OAuthCallback = React.lazy(
|
||||
() => import("./components/OAuthCallback"),
|
||||
|
|
@ -688,7 +812,6 @@ const App = () => {
|
|||
loggingSupported={!!serverCapabilities?.logging || false}
|
||||
clearStdErrNotifications={clearStdErrNotifications}
|
||||
/>
|
||||
{/* Drag handle for resizing sidebar */}
|
||||
<div
|
||||
onMouseDown={handleSidebarDragStart}
|
||||
style={{
|
||||
|
|
@ -709,21 +832,12 @@ const App = () => {
|
|||
<div className="flex-1 overflow-auto">
|
||||
{mcpClient ? (
|
||||
<Tabs
|
||||
defaultValue={
|
||||
Object.keys(serverCapabilities ?? {}).includes(
|
||||
window.location.hash.slice(1),
|
||||
)
|
||||
? window.location.hash.slice(1)
|
||||
: serverCapabilities?.resources
|
||||
? "resources"
|
||||
: serverCapabilities?.prompts
|
||||
? "prompts"
|
||||
: serverCapabilities?.tools
|
||||
? "tools"
|
||||
: "ping"
|
||||
}
|
||||
value={activeTab}
|
||||
className="w-full p-4"
|
||||
onValueChange={(value) => (window.location.hash = value)}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value);
|
||||
window.location.hash = value;
|
||||
}}
|
||||
>
|
||||
<TabsList className="mb-4 py-0">
|
||||
<TabsTrigger
|
||||
|
|
@ -760,6 +874,15 @@ const App = () => {
|
|||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="elicitations" className="relative">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Elicitations
|
||||
{pendingElicitationRequests.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
|
||||
{pendingElicitationRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="roots">
|
||||
<FolderTree className="w-4 h-4 mr-2" />
|
||||
Roots
|
||||
|
|
@ -876,7 +999,6 @@ const App = () => {
|
|||
clearTools={() => {
|
||||
setTools([]);
|
||||
setNextToolCursor(undefined);
|
||||
// Clear cached output schemas
|
||||
cacheToolOutputSchemas([]);
|
||||
}}
|
||||
callTool={async (name, params) => {
|
||||
|
|
@ -915,6 +1037,10 @@ const App = () => {
|
|||
onApprove={handleApproveSampling}
|
||||
onReject={handleRejectSampling}
|
||||
/>
|
||||
<ElicitationTab
|
||||
pendingRequests={pendingElicitationRequests}
|
||||
onResolve={handleResolveElicitation}
|
||||
/>
|
||||
<RootsTab
|
||||
roots={roots}
|
||||
setRoots={setRoots}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ interface DynamicJsonFormProps {
|
|||
|
||||
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") {
|
||||
|
|
@ -182,10 +182,89 @@ const DynamicJsonForm = ({
|
|||
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;
|
||||
|
|
@ -194,8 +273,13 @@ const DynamicJsonForm = ({
|
|||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
minLength={propSchema.minLength}
|
||||
maxLength={propSchema.maxLength}
|
||||
pattern={propSchema.pattern}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
|
|
@ -214,8 +298,11 @@ const DynamicJsonForm = ({
|
|||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
min={propSchema.minimum}
|
||||
max={propSchema.maximum}
|
||||
/>
|
||||
);
|
||||
|
||||
case "integer":
|
||||
return (
|
||||
<Input
|
||||
|
|
@ -228,7 +315,6 @@ const DynamicJsonForm = ({
|
|||
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);
|
||||
}
|
||||
|
|
@ -236,8 +322,11 @@ const DynamicJsonForm = ({
|
|||
}}
|
||||
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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,10 @@ import { z } from "zod";
|
|||
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
||||
import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
ElicitResult,
|
||||
ElicitRequest,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
|
|
@ -200,6 +204,252 @@ describe("useConnection", () => {
|
|||
).rejects.toThrow("MCP client not connected");
|
||||
});
|
||||
|
||||
describe("Elicitation Support", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("declares elicitation capability during client initialization", async () => {
|
||||
const Client = jest.requireMock(
|
||||
"@modelcontextprotocol/sdk/client/index.js",
|
||||
).Client;
|
||||
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
expect(Client).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "mcp-inspector",
|
||||
version: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
capabilities: expect.objectContaining({
|
||||
elicitation: {},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("sets up elicitation request handler when onElicitationRequest is provided", async () => {
|
||||
const mockOnElicitationRequest = jest.fn();
|
||||
const propsWithElicitation = {
|
||||
...defaultProps,
|
||||
onElicitationRequest: mockOnElicitationRequest,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithElicitation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const elicitRequestHandlerCall =
|
||||
mockClient.setRequestHandler.mock.calls.find((call) => {
|
||||
try {
|
||||
const schema = call[0];
|
||||
const testRequest = {
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "test message",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const parseResult =
|
||||
schema.safeParse && schema.safeParse(testRequest);
|
||||
return parseResult?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(elicitRequestHandlerCall).toBeDefined();
|
||||
expect(mockClient.setRequestHandler).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
test("does not set up elicitation request handler when onElicitationRequest is not provided", async () => {
|
||||
const { result } = renderHook(() => useConnection(defaultProps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const elicitRequestHandlerCall =
|
||||
mockClient.setRequestHandler.mock.calls.find((call) => {
|
||||
try {
|
||||
const schema = call[0];
|
||||
const testRequest = {
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "test message",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const parseResult =
|
||||
schema.safeParse && schema.safeParse(testRequest);
|
||||
return parseResult?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(elicitRequestHandlerCall).toBeUndefined();
|
||||
});
|
||||
|
||||
test("elicitation request handler calls onElicitationRequest callback", async () => {
|
||||
const mockOnElicitationRequest = jest.fn();
|
||||
const propsWithElicitation = {
|
||||
...defaultProps,
|
||||
onElicitationRequest: mockOnElicitationRequest,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithElicitation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const elicitRequestHandlerCall =
|
||||
mockClient.setRequestHandler.mock.calls.find((call) => {
|
||||
try {
|
||||
const schema = call[0];
|
||||
const testRequest = {
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "test message",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const parseResult =
|
||||
schema.safeParse && schema.safeParse(testRequest);
|
||||
return parseResult?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(elicitRequestHandlerCall).toBeDefined();
|
||||
const [, handler] = elicitRequestHandlerCall;
|
||||
|
||||
const mockElicitationRequest: ElicitRequest = {
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "Please provide your name",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockOnElicitationRequest.mockImplementation((_request, resolve) => {
|
||||
resolve({ action: "accept", content: { name: "test" } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await handler(mockElicitationRequest);
|
||||
});
|
||||
|
||||
expect(mockOnElicitationRequest).toHaveBeenCalledWith(
|
||||
mockElicitationRequest,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
test("elicitation request handler returns a promise that resolves with the callback result", async () => {
|
||||
const mockOnElicitationRequest = jest.fn();
|
||||
const propsWithElicitation = {
|
||||
...defaultProps,
|
||||
onElicitationRequest: mockOnElicitationRequest,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithElicitation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const elicitRequestHandlerCall =
|
||||
mockClient.setRequestHandler.mock.calls.find((call) => {
|
||||
try {
|
||||
const schema = call[0];
|
||||
const testRequest = {
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "test message",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const parseResult =
|
||||
schema.safeParse && schema.safeParse(testRequest);
|
||||
return parseResult?.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const [, handler] = elicitRequestHandlerCall;
|
||||
|
||||
const mockElicitationRequest: ElicitRequest = {
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "Please provide your name",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse: ElicitResult = {
|
||||
action: "accept",
|
||||
content: { name: "John Doe" },
|
||||
};
|
||||
|
||||
mockOnElicitationRequest.mockImplementation((_request, resolve) => {
|
||||
resolve(mockResponse);
|
||||
});
|
||||
|
||||
let handlerResult;
|
||||
await act(async () => {
|
||||
handlerResult = await handler(mockElicitationRequest);
|
||||
});
|
||||
|
||||
expect(handlerResult).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL Port Handling", () => {
|
||||
const SSEClientTransport = jest.requireMock(
|
||||
"@modelcontextprotocol/sdk/client/sse.js",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
ToolListChangedNotificationSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
Progress,
|
||||
ElicitRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -68,6 +69,8 @@ interface UseConnectionOptions {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onElicitationRequest?: (request: any, resolve: any) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getRoots?: () => any[];
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +88,7 @@ export function useConnection({
|
|||
onNotification,
|
||||
onStdErrNotification,
|
||||
onPendingRequest,
|
||||
onElicitationRequest,
|
||||
getRoots,
|
||||
}: UseConnectionOptions) {
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
|
|
@ -322,6 +326,7 @@ export function useConnection({
|
|||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
elicitation: {},
|
||||
roots: {
|
||||
listChanged: true,
|
||||
},
|
||||
|
|
@ -548,6 +553,14 @@ export function useConnection({
|
|||
});
|
||||
}
|
||||
|
||||
if (onElicitationRequest) {
|
||||
client.setRequestHandler(ElicitRequestSchema, async (request) => {
|
||||
return new Promise((resolve) => {
|
||||
onElicitationRequest(request, resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setMcpClient(client);
|
||||
setConnectionStatus("connected");
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
updateValueAtPath,
|
||||
getValueAtPath,
|
||||
} from "../jsonUtils";
|
||||
import type { JsonValue } from "../jsonUtils";
|
||||
import type { JsonValue, JsonSchemaType } from "../jsonUtils";
|
||||
|
||||
describe("getDataType", () => {
|
||||
test("should return 'string' for string values", () => {
|
||||
|
|
@ -317,3 +317,210 @@ describe("getValueAtPath", () => {
|
|||
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JsonSchemaType elicitation field support", () => {
|
||||
const sampleSchema: JsonSchemaType = {
|
||||
type: "object",
|
||||
title: "User Info",
|
||||
description: "User information form",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Full Name",
|
||||
description: "Your full name",
|
||||
minLength: 2,
|
||||
maxLength: 50,
|
||||
pattern: "^[A-Za-z\\s]+$",
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
format: "email",
|
||||
title: "Email Address",
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
minimum: 18,
|
||||
maximum: 120,
|
||||
default: 25,
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{ const: "admin", title: "Administrator" },
|
||||
{ const: "user", title: "User" },
|
||||
{ const: "guest", title: "Guest" },
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ["name", "email"],
|
||||
};
|
||||
|
||||
test("should parse JsonSchemaType with elicitation fields", () => {
|
||||
const schemaString = JSON.stringify(sampleSchema);
|
||||
const result = tryParseJson(schemaString);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(sampleSchema);
|
||||
});
|
||||
|
||||
test("should update schema properties with new validation fields", () => {
|
||||
const updated = updateValueAtPath(
|
||||
sampleSchema,
|
||||
["properties", "name", "minLength"],
|
||||
5,
|
||||
);
|
||||
|
||||
expect(getValueAtPath(updated, ["properties", "name", "minLength"])).toBe(
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle oneOf with const and title fields", () => {
|
||||
const schema = {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{ const: "option1", title: "Option 1" },
|
||||
{ const: "option2", title: "Option 2" },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getValueAtPath(schema, ["oneOf", "0", "const"])).toBe("option1");
|
||||
expect(getValueAtPath(schema, ["oneOf", "1", "title"])).toBe("Option 2");
|
||||
});
|
||||
|
||||
test("should handle validation constraints", () => {
|
||||
const numberSchema = {
|
||||
type: "number" as const,
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
default: 50,
|
||||
};
|
||||
|
||||
expect(getValueAtPath(numberSchema, ["minimum"])).toBe(0);
|
||||
expect(getValueAtPath(numberSchema, ["maximum"])).toBe(100);
|
||||
expect(getValueAtPath(numberSchema, ["default"])).toBe(50);
|
||||
});
|
||||
|
||||
test("should handle string format and pattern fields", () => {
|
||||
const stringSchema = {
|
||||
type: "string" as const,
|
||||
format: "email",
|
||||
pattern: "^[a-z]+@[a-z]+\\.[a-z]+$",
|
||||
minLength: 5,
|
||||
maxLength: 100,
|
||||
};
|
||||
|
||||
expect(getValueAtPath(stringSchema, ["format"])).toBe("email");
|
||||
expect(getValueAtPath(stringSchema, ["pattern"])).toBe(
|
||||
"^[a-z]+@[a-z]+\\.[a-z]+$",
|
||||
);
|
||||
expect(getValueAtPath(stringSchema, ["minLength"])).toBe(5);
|
||||
});
|
||||
|
||||
test("should handle title and description fields", () => {
|
||||
const schema = {
|
||||
type: "boolean" as const,
|
||||
title: "Accept Terms",
|
||||
description: "Do you accept the terms and conditions?",
|
||||
default: false,
|
||||
};
|
||||
|
||||
expect(getValueAtPath(schema, ["title"])).toBe("Accept Terms");
|
||||
expect(getValueAtPath(schema, ["description"])).toBe(
|
||||
"Do you accept the terms and conditions?",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle JSON Schema spec compliant oneOf with const for labeled enums", () => {
|
||||
// Example from JSON Schema spec: labeled enums using oneOf with const
|
||||
const trafficLightSchema = {
|
||||
type: "string" as const,
|
||||
title: "Traffic Light",
|
||||
description: "Select a traffic light color",
|
||||
oneOf: [
|
||||
{ const: "red", title: "Stop" },
|
||||
{ const: "amber", title: "Caution" },
|
||||
{ const: "green", title: "Go" },
|
||||
],
|
||||
};
|
||||
|
||||
// Verify the schema structure
|
||||
expect(trafficLightSchema.type).toBe("string");
|
||||
expect(trafficLightSchema.oneOf).toHaveLength(3);
|
||||
|
||||
// Verify each oneOf option has const and title
|
||||
expect(trafficLightSchema.oneOf[0].const).toBe("red");
|
||||
expect(trafficLightSchema.oneOf[0].title).toBe("Stop");
|
||||
|
||||
expect(trafficLightSchema.oneOf[1].const).toBe("amber");
|
||||
expect(trafficLightSchema.oneOf[1].title).toBe("Caution");
|
||||
|
||||
expect(trafficLightSchema.oneOf[2].const).toBe("green");
|
||||
expect(trafficLightSchema.oneOf[2].title).toBe("Go");
|
||||
|
||||
// Test with JsonValue operations
|
||||
const schemaAsJsonValue = trafficLightSchema as JsonValue;
|
||||
expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "0", "const"])).toBe(
|
||||
"red",
|
||||
);
|
||||
expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "1", "title"])).toBe(
|
||||
"Caution",
|
||||
);
|
||||
expect(getValueAtPath(schemaAsJsonValue, ["oneOf", "2", "const"])).toBe(
|
||||
"green",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle complex oneOf scenarios with mixed schema types", () => {
|
||||
const complexSchema = {
|
||||
type: "object" as const,
|
||||
title: "User Preference",
|
||||
properties: {
|
||||
theme: {
|
||||
type: "string" as const,
|
||||
oneOf: [
|
||||
{ const: "light", title: "Light Mode" },
|
||||
{ const: "dark", title: "Dark Mode" },
|
||||
{ const: "auto", title: "Auto (System)" },
|
||||
],
|
||||
},
|
||||
notifications: {
|
||||
type: "string" as const,
|
||||
oneOf: [
|
||||
{ const: "all", title: "All Notifications" },
|
||||
{ const: "important", title: "Important Only" },
|
||||
{ const: "none", title: "None" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
getValueAtPath(complexSchema, [
|
||||
"properties",
|
||||
"theme",
|
||||
"oneOf",
|
||||
"0",
|
||||
"const",
|
||||
]),
|
||||
).toBe("light");
|
||||
expect(
|
||||
getValueAtPath(complexSchema, [
|
||||
"properties",
|
||||
"theme",
|
||||
"oneOf",
|
||||
"1",
|
||||
"title",
|
||||
]),
|
||||
).toBe("Dark Mode");
|
||||
expect(
|
||||
getValueAtPath(complexSchema, [
|
||||
"properties",
|
||||
"notifications",
|
||||
"oneOf",
|
||||
"2",
|
||||
"const",
|
||||
]),
|
||||
).toBe("none");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,8 +7,14 @@ export type JsonValue =
|
|||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export type JsonSchemaConst = {
|
||||
const: JsonValue;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type JsonSchemaType = {
|
||||
type:
|
||||
type?:
|
||||
| "string"
|
||||
| "number"
|
||||
| "integer"
|
||||
|
|
@ -16,11 +22,22 @@ export type JsonSchemaType = {
|
|||
| "array"
|
||||
| "object"
|
||||
| "null";
|
||||
title?: string;
|
||||
description?: string;
|
||||
required?: string[];
|
||||
default?: JsonValue;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
items?: JsonSchemaType;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
format?: string;
|
||||
enum?: string[];
|
||||
const?: JsonValue;
|
||||
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
|
||||
anyOf?: (JsonSchemaType | JsonSchemaConst)[];
|
||||
};
|
||||
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
|
|
|||
Loading…
Reference in a new issue