Merge branch 'main' into dev/eh/tool-meta-support

This commit is contained in:
Ola Hungerford 2025-07-30 21:20:21 -07:00 • committed by GitHub
commit 14657973bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2235 additions and 209 deletions

View file

@ -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 = {

View 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" }),
);
});
});
});

View 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);
});
});

View file

@ -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: "",