diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 31868b0d92d80..f90f5353b1c63 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -40,6 +40,12 @@ jest.mock("contexts/useProxyLatency", () => ({ global.scrollTo = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn(); +// Polyfill pointer capture methods for JSDOM compatibility with Radix UI +window.HTMLElement.prototype.hasPointerCapture = jest + .fn() + .mockReturnValue(false); +window.HTMLElement.prototype.setPointerCapture = jest.fn(); +window.HTMLElement.prototype.releasePointerCapture = jest.fn(); window.open = jest.fn(); navigator.sendBeacon = jest.fn(); diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.test.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.test.tsx new file mode 100644 index 0000000000000..43e75af1d2f0e --- /dev/null +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.test.tsx @@ -0,0 +1,1014 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { PreviewParameter } from "api/typesGenerated"; +import { render } from "testHelpers/renderHelpers"; +import { DynamicParameter } from "./DynamicParameter"; + +const createMockParameter = ( + overrides: Partial = {}, +): PreviewParameter => ({ + name: "test_param", + display_name: "Test Parameter", + description: "A test parameter", + type: "string", + mutable: true, + default_value: { value: "", valid: true }, + icon: "", + options: [], + validations: [], + styling: { + placeholder: "", + disabled: false, + label: "", + }, + diagnostics: [], + value: { value: "", valid: true }, + required: false, + order: 1, + form_type: "input", + ephemeral: false, + ...overrides, +}); + +const mockStringParameter = createMockParameter({ + name: "string_param", + display_name: "String Parameter", + description: "A string input parameter", + type: "string", + form_type: "input", + default_value: { value: "default_value", valid: true }, +}); + +const mockTextareaParameter = createMockParameter({ + name: "textarea_param", + display_name: "Textarea Parameter", + description: "A textarea input parameter", + type: "string", + form_type: "textarea", + default_value: { value: "default\nmultiline\nvalue", valid: true }, +}); + +const mockTagsParameter = createMockParameter({ + name: "tags_param", + display_name: "Tags Parameter", + description: "A tags parameter", + type: "list(string)", + form_type: "tag-select", + default_value: { value: '["tag1", "tag2"]', valid: true }, +}); + +const mockRequiredParameter = createMockParameter({ + name: "required_param", + display_name: "Required Parameter", + description: "A required parameter", + type: "string", + form_type: "input", + required: true, +}); + +describe("DynamicParameter", () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Input Parameter", () => { + const mockParameterWithIcon = createMockParameter({ + name: "icon_param", + display_name: "Parameter with Icon", + description: "A parameter with an icon", + type: "string", + form_type: "input", + icon: "/test-icon.png", + }); + + it("renders string input parameter correctly", () => { + render( + , + ); + + expect(screen.getByText("String Parameter")).toBeInTheDocument(); + expect(screen.getByText("A string input parameter")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("test_value"); + }); + + it("calls onChange when input value changes", async () => { + render( + , + ); + + const input = screen.getByRole("textbox"); + + await waitFor(async () => { + await userEvent.type(input, "new_value"); + }); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith("new_value"); + }); + }); + + it("shows required indicator for required parameters", () => { + render( + , + ); + + expect(screen.getByText("*")).toBeInTheDocument(); + }); + + it("disables input when disabled prop is true", () => { + render( + , + ); + + expect(screen.getByRole("textbox")).toBeDisabled(); + }); + + it("displays parameter icon when provided", () => { + render( + , + ); + + const icon = screen.getByRole("img"); + expect(icon).toHaveAttribute("src", "/test-icon.png"); + }); + }); + + describe("Textarea Parameter", () => { + it("renders textarea parameter correctly", () => { + const testValue = "multiline\ntext\nvalue"; + render( + , + ); + + expect(screen.getByText("Textarea Parameter")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue(testValue); + }); + + it("handles textarea value changes", async () => { + render( + , + ); + + const textarea = screen.getByRole("textbox"); + await waitFor(async () => { + await userEvent.type(textarea, "line1{enter}line2{enter}line3"); + }); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith("line1\nline2\nline3"); + }); + }); + }); + + describe("Select Parameter", () => { + const mockSelectParameter = createMockParameter({ + name: "select_param", + display_name: "Select Parameter", + description: "A select parameter with options", + type: "string", + form_type: "dropdown", + default_value: { value: "option1", valid: true }, + options: [ + { + name: "Option 1", + description: "First option", + value: { value: "option1", valid: true }, + icon: "", + }, + { + name: "Option 2", + description: "Second option", + value: { value: "option2", valid: true }, + icon: "/icon2.png", + }, + { + name: "Option 3", + description: "Third option", + value: { value: "option3", valid: true }, + icon: "", + }, + ], + }); + + it("renders select parameter with options", () => { + render( + , + ); + + expect(screen.getByText("Select Parameter")).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("displays all options when opened", async () => { + render( + , + ); + + const select = screen.getByRole("combobox"); + await waitFor(async () => { + await userEvent.click(select); + }); + + // Option 1 exists in the trigger and the dropdown + expect(screen.getAllByText("Option 1")).toHaveLength(2); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + + it("calls onChange when option is selected", async () => { + render( + , + ); + + const select = screen.getByRole("combobox"); + await waitFor(async () => { + await userEvent.click(select); + }); + + const option2 = screen.getByText("Option 2"); + await waitFor(async () => { + await userEvent.click(option2); + }); + + expect(mockOnChange).toHaveBeenCalledWith("option2"); + }); + + it("displays option icons when provided", async () => { + render( + , + ); + + const select = screen.getByRole("combobox"); + await waitFor(async () => { + await userEvent.click(select); + }); + + const icons = screen.getAllByRole("img"); + expect( + icons.some((icon) => icon.getAttribute("src") === "/icon2.png"), + ).toBe(true); + }); + }); + + describe("Radio Parameter", () => { + const mockRadioParameter = createMockParameter({ + name: "radio_param", + display_name: "Radio Parameter", + description: "A radio button parameter", + type: "string", + form_type: "radio", + default_value: { value: "radio1", valid: true }, + options: [ + { + name: "Radio 1", + description: "First radio option", + value: { value: "radio1", valid: true }, + icon: "", + }, + { + name: "Radio 2", + description: "Second radio option", + value: { value: "radio2", valid: true }, + icon: "", + }, + ], + }); + + it("renders radio parameter with options", () => { + render( + , + ); + + expect(screen.getByText("Radio Parameter")).toBeInTheDocument(); + expect(screen.getByRole("radiogroup")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /radio 1/i })).toBeChecked(); + expect(screen.getByRole("radio", { name: /radio 2/i })).not.toBeChecked(); + }); + + it("calls onChange when radio option is selected", async () => { + render( + , + ); + + const radio2 = screen.getByRole("radio", { name: /radio 2/i }); + await waitFor(async () => { + await userEvent.click(radio2); + }); + + expect(mockOnChange).toHaveBeenCalledWith("radio2"); + }); + }); + + describe("Checkbox Parameter", () => { + const mockCheckboxParameter = createMockParameter({ + name: "checkbox_param", + display_name: "Checkbox Parameter", + description: "A checkbox parameter", + type: "bool", + form_type: "checkbox", + default_value: { value: "true", valid: true }, + }); + + it("Renders checkbox parameter correctly and handles unchecked to checked transition", async () => { + render( + , + ); + expect(screen.getByText("Checkbox Parameter")).toBeInTheDocument(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).not.toBeChecked(); + + await waitFor(async () => { + await userEvent.click(checkbox); + }); + + expect(mockOnChange).toHaveBeenCalledWith("true"); + }); + }); + + describe("Switch Parameter", () => { + const mockSwitchParameter = createMockParameter({ + name: "switch_param", + display_name: "Switch Parameter", + description: "A switch parameter", + type: "bool", + form_type: "switch", + default_value: { value: "false", valid: true }, + }); + + it("renders switch parameter correctly", () => { + render( + , + ); + + expect(screen.getByText("Switch Parameter")).toBeInTheDocument(); + expect(screen.getByRole("switch")).not.toBeChecked(); + }); + + it("handles switch state changes", async () => { + render( + , + ); + + const switchElement = screen.getByRole("switch"); + await waitFor(async () => { + await userEvent.click(switchElement); + }); + + expect(mockOnChange).toHaveBeenCalledWith("true"); + }); + }); + + describe("Slider Parameter", () => { + const mockSliderParameter = createMockParameter({ + name: "slider_param", + display_name: "Slider Parameter", + description: "A slider parameter", + type: "number", + form_type: "slider", + default_value: { value: "50", valid: true }, + validations: [ + { + validation_min: 0, + validation_max: 100, + validation_error: "Value must be between 0 and 100", + validation_regex: null, + validation_monotonic: null, + }, + ], + }); + + it("renders slider parameter correctly", () => { + render( + , + ); + + expect(screen.getByText("Slider Parameter")).toBeInTheDocument(); + const slider = screen.getByRole("slider"); + expect(slider).toHaveAttribute("aria-valuenow", "50"); + }); + + it("respects min/max constraints from validation_condition", () => { + render( + , + ); + + const slider = screen.getByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemin", "0"); + expect(slider).toHaveAttribute("aria-valuemax", "100"); + }); + }); + + describe("Tags Parameter", () => { + it("renders tags parameter correctly", () => { + render( + , + ); + + expect(screen.getByText("Tags Parameter")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("handles tag additions", async () => { + render( + , + ); + + const input = screen.getByRole("textbox"); + await waitFor(async () => { + await userEvent.type(input, "newtag,"); + }); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith('["tag1","newtag"]'); + }); + }); + + it("handles tag removals", async () => { + render( + , + ); + + const deleteButtons = screen.getAllByTestId("CancelIcon"); + await waitFor(async () => { + await userEvent.click(deleteButtons[0]); + }); + + expect(mockOnChange).toHaveBeenCalledWith('["tag2"]'); + }); + }); + + describe("Multi-Select Parameter", () => { + const mockMultiSelectParameter = createMockParameter({ + name: "multiselect_param", + display_name: "Multi-Select Parameter", + description: "A multi-select parameter", + type: "list(string)", + form_type: "multi-select", + default_value: { value: '["option1", "option3"]', valid: true }, + options: [ + { + name: "Option 1", + description: "First option", + value: { value: "option1", valid: true }, + icon: "", + }, + { + name: "Option 2", + description: "Second option", + value: { value: "option2", valid: true }, + icon: "", + }, + { + name: "Option 3", + description: "Third option", + value: { value: "option3", valid: true }, + icon: "", + }, + { + name: "Option 4", + description: "Fourth option", + value: { value: "option4", valid: true }, + icon: "", + }, + ], + }); + + it("renders multi-select parameter correctly", () => { + render( + , + ); + + expect(screen.getByText("Multi-Select Parameter")).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("displays selected options", () => { + render( + , + ); + + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 3")).toBeInTheDocument(); + }); + + it("handles option selection", async () => { + render( + , + ); + + const combobox = screen.getByRole("combobox"); + await waitFor(async () => { + await userEvent.click(combobox); + }); + + const option2 = screen.getByText("Option 2"); + await waitFor(async () => { + await userEvent.click(option2); + }); + + expect(mockOnChange).toHaveBeenCalledWith('["option1","option2"]'); + }); + + it("handles option deselection", async () => { + render( + , + ); + + const removeButtons = screen.getAllByTestId("clear-option-button"); + await waitFor(async () => { + await userEvent.click(removeButtons[0]); + }); + + expect(mockOnChange).toHaveBeenCalledWith('["option2"]'); + }); + }); + + describe("Error Parameter", () => { + const mockErrorParameter = createMockParameter({ + name: "error_param", + display_name: "Error Parameter", + description: "A parameter with validation error", + type: "string", + form_type: "error", + diagnostics: [ + { + severity: "error", + summary: "Validation Error", + detail: "This parameter has a validation error", + extra: { + code: "validation_error", + }, + }, + ], + }); + + it("renders error parameter with validation message", () => { + render( + , + ); + + expect(screen.getByText("Error Parameter")).toBeInTheDocument(); + expect( + screen.getByText("This parameter has a validation error"), + ).toBeInTheDocument(); + }); + }); + + describe("Parameter Badges", () => { + const mockEphemeralParameter = createMockParameter({ + name: "ephemeral_param", + display_name: "Ephemeral Parameter", + description: "An ephemeral parameter", + type: "string", + form_type: "input", + ephemeral: true, + }); + + const mockImmutableParameter = createMockParameter({ + name: "immutable_param", + display_name: "Immutable Parameter", + description: "An immutable parameter", + type: "string", + form_type: "input", + mutable: false, + default_value: { value: "immutable_value", valid: true }, + }); + + it("shows immutable indicator for immutable parameters", () => { + render( + , + ); + + expect(screen.getByText("Immutable")).toBeInTheDocument(); + }); + + it("shows autofill indicator when autofill is true", () => { + render( + , + ); + + expect(screen.getByText(/URL Autofill/i)).toBeInTheDocument(); + }); + + it("shows ephemeral indicator for ephemeral parameters", () => { + render( + , + ); + + expect(screen.getByText("Ephemeral")).toBeInTheDocument(); + }); + + it("shows preset indicator when isPreset is true", () => { + render( + , + ); + + expect(screen.getByText(/preset/i)).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("associates labels with form controls", () => { + render( + , + ); + + const input = screen.getByRole("textbox"); + + expect(input).toHaveAccessibleName("String Parameter"); + }); + + it("marks required fields appropriately", () => { + render( + , + ); + + const input = screen.getByRole("textbox"); + expect(input).toBeRequired(); + }); + }); + + describe("Debounced Input", () => { + it("debounces input changes for text inputs", async () => { + jest.useFakeTimers(); + + render( + , + ); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "abc" } }); + + expect(mockOnChange).not.toHaveBeenCalled(); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockOnChange).toHaveBeenCalledWith("abc"); + + jest.useRealTimers(); + }); + + it("debounces textarea changes", async () => { + jest.useFakeTimers(); + + render( + , + ); + + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "line1\nline2" } }); + + expect(mockOnChange).not.toHaveBeenCalled(); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockOnChange).toHaveBeenCalledWith("line1\nline2"); + + jest.useRealTimers(); + }); + }); + + describe("Edge Cases", () => { + it("handles empty parameter options gracefully", () => { + const paramWithEmptyOptions = createMockParameter({ + form_type: "dropdown", + options: [], + }); + + render( + , + ); + + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("handles null/undefined values", () => { + render( + , + ); + + expect(screen.getByRole("textbox")).toHaveValue(""); + }); + + it("handles invalid JSON in list parameters", () => { + render( + , + ); + + expect(screen.getByText("Tags Parameter")).toBeInTheDocument(); + }); + + it("handles parameters with very long descriptions", () => { + const longDescriptionParam = createMockParameter({ + description: "A".repeat(1000), + }); + + render( + , + ); + + expect(screen.getByText("A".repeat(1000))).toBeInTheDocument(); + }); + + it("handles parameters with special characters in names", () => { + const specialCharParam = createMockParameter({ + name: "param-with_special.chars", + display_name: "Param with Special Characters!@#$%", + }); + + render( + , + ); + + expect( + screen.getByText("Param with Special Characters!@#$%"), + ).toBeInTheDocument(); + }); + }); + + describe("Number Input Parameter", () => { + const mockNumberInputParameter = createMockParameter({ + name: "number_input_param", + display_name: "Number Input Parameter", + description: "A numeric input parameter with min/max validations", + type: "number", + form_type: "input", + default_value: { value: "5", valid: true }, + validations: [ + { + validation_min: 1, + validation_max: 10, + validation_error: "Value must be between 1 and 10", + validation_regex: null, + validation_monotonic: null, + }, + ], + }); + + it("renders number input with correct min/max attributes", () => { + render( + , + ); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveAttribute("min", "1"); + expect(input).toHaveAttribute("max", "10"); + }); + + it("calls onChange when numeric value changes (debounced)", () => { + jest.useFakeTimers(); + render( + , + ); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "7" } }); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockOnChange).toHaveBeenCalledWith("7"); + jest.useRealTimers(); + }); + }); + + describe("Masked Input Parameter", () => { + const mockMaskedInputParameter = createMockParameter({ + name: "masked_input_param", + display_name: "Masked Input Parameter", + type: "string", + form_type: "input", + styling: { + placeholder: "********", + disabled: false, + label: "", + mask_input: true, + }, + }); + + it("renders a password field by default and toggles visibility on mouse events", async () => { + render( + , + ); + + const input = screen.getByLabelText("Masked Input Parameter"); + expect(input).toHaveAttribute("type", "password"); + + const toggleButton = screen.getByRole("button"); + fireEvent.mouseDown(toggleButton); + expect(input).toHaveAttribute("type", "text"); + + fireEvent.mouseUp(toggleButton); + expect(input).toHaveAttribute("type", "password"); + }); + }); + + describe("Parameter Diagnostics", () => { + const mockWarningParameter = createMockParameter({ + name: "warning_param", + display_name: "Warning Parameter", + description: "Parameter with a warning diagnostic", + form_type: "input", + diagnostics: [ + { + severity: "warning", + summary: "This is a warning", + detail: "Something might be wrong", + extra: { code: "warning" }, + }, + ], + }); + + it("renders warning diagnostics for non-error parameters", () => { + render( + , + ); + + expect(screen.getByText("This is a warning")).toBeInTheDocument(); + expect(screen.getByText("Something might be wrong")).toBeInTheDocument(); + }); + }); +});