feat: add tests for dynamic parameters (#18679)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for a new testing attribute to the multi-select combobox
component, improving testability.
* Expanded mock data for dynamic parameters, covering a wider range of
input types and validation scenarios.

* **Bug Fixes**
* Improved loader and error handling on the experimental workspace
creation page to better display WebSocket errors.

* **Tests**
* Introduced comprehensive tests for the experimental workspace creation
page, including dynamic parameter updates, error handling, and form
submission scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Michael Smith <throwawayclover@gmail.com>
This commit is contained in:
Jaayden Halko
2025-08-11 20:43:50 +01:00
committed by GitHub
parent b8c9192d0b
commit 0bfe0d63ae
6 changed files with 803 additions and 12 deletions
@@ -34,6 +34,7 @@ interface ComboboxProps {
onInputChange?: (value: string) => void;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
onSelect: (value: string) => void;
id?: string;
}
type ComboboxOption = {
@@ -53,6 +54,7 @@ export const Combobox: FC<ComboboxProps> = ({
onInputChange,
onKeyDown,
onSelect,
id,
}) => {
const [managedOpen, setManagedOpen] = useState(false);
const [managedInputValue, setManagedInputValue] = useState("");
@@ -78,6 +80,7 @@ export const Combobox: FC<ComboboxProps> = ({
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id={id}
variant="outline"
aria-expanded={isOpen}
className="w-full justify-between group"
@@ -104,6 +104,8 @@ interface MultiSelectComboboxProps {
>;
/** hide or show the button that clears all the selected options. */
hideClearAllButton?: boolean;
/** Test ID for testing purposes */
"data-testid"?: string;
}
interface MultiSelectComboboxRef {
@@ -205,6 +207,7 @@ export const MultiSelectCombobox = forwardRef<
commandProps,
inputProps,
hideClearAllButton = false,
"data-testid": dataTestId,
}: MultiSelectComboboxProps,
ref,
) => {
@@ -454,6 +457,7 @@ export const MultiSelectCombobox = forwardRef<
<Command
ref={dropdownRef}
{...commandProps}
data-testid={dataTestId}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
@@ -453,6 +453,7 @@ const ParameterField: FC<ParameterFieldProps> = ({
case "dropdown": {
return (
<Combobox
id={id}
value={value ?? ""}
onSelect={(value) => onChange(value)}
options={parameter.options.map((option) => ({
@@ -497,7 +498,10 @@ const ParameterField: FC<ParameterFieldProps> = ({
return (
<MultiSelectCombobox
inputProps={{ id }}
inputProps={{
id: id,
}}
data-testid={`multiselect-${parameter.name}`}
options={options}
defaultOptions={selectedOptions}
onChange={(newValues) => {
@@ -0,0 +1,600 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import type { DynamicParametersResponse } from "api/typesGenerated";
import {
MockDropdownParameter,
MockDynamicParametersResponse,
MockDynamicParametersResponseWithError,
MockPermissions,
MockSliderParameter,
MockTemplate,
MockTemplateVersionExternalAuthGithub,
MockTemplateVersionExternalAuthGithubAuthenticated,
MockUserOwner,
MockValidationParameter,
MockWorkspace,
} from "testHelpers/entities";
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { createMockWebSocket } from "testHelpers/websockets";
import CreateWorkspacePageExperimental from "./CreateWorkspacePageExperimental";
describe("CreateWorkspacePageExperimental", () => {
const renderCreateWorkspacePageExperimental = (
route = `/templates/${MockTemplate.name}/workspace`,
) => {
return renderWithAuth(<CreateWorkspacePageExperimental />, {
route,
path: "/templates/:template/workspace",
extraRoutes: [
{
path: "/:username/:workspace",
element: <div>Workspace Page</div>,
},
],
});
};
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
jest.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]);
jest.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]);
jest.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace);
jest.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions);
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockWebSocket.addEventListener("error", () => {
callbacks.onError(
new Error("Connection for dynamic parameters failed."),
);
});
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponse),
}),
);
return mockWebSocket;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("WebSocket Integration", () => {
it("establishes WebSocket connection and receives initial parameters", async () => {
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith(
MockTemplate.active_version_id,
MockUserOwner.id,
expect.objectContaining({
onMessage: expect.any(Function),
onError: expect.any(Function),
onClose: expect.any(Function),
}),
);
await waitFor(() => {
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
expect(screen.getByText("CPU Count")).toBeInTheDocument();
expect(screen.getByText("Enable Monitoring")).toBeInTheDocument();
expect(screen.getByText("Tags")).toBeInTheDocument();
});
});
it("sends parameter updates via WebSocket when form values change", async () => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockWebSocket.addEventListener("error", () => {
callbacks.onError(
new Error("Connection for dynamic parameters failed."),
);
});
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponse),
}),
);
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
const instanceTypeSelect = screen.getByRole("button", {
name: /instance type/i,
});
expect(instanceTypeSelect).toBeInTheDocument();
await waitFor(async () => {
await userEvent.click(instanceTypeSelect);
});
let mediumOption: Element | null = null;
await waitFor(() => {
mediumOption = screen.queryByRole("option", { name: /t3\.medium/i });
expect(mediumOption).toBeTruthy();
});
await waitFor(async () => {
await userEvent.click(mediumOption!);
});
expect(mockWebSocket.send).toHaveBeenCalledWith(
expect.stringContaining('"instance_type":"t3.medium"'),
);
});
it("handles WebSocket error gracefully", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("error", () => {
callbacks.onError(new Error("Connection failed"));
});
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitFor(() => {
expect(mockPublisher).toBeDefined();
mockPublisher.publishError(new Event("Connection failed"));
expect(screen.getByText(/connection failed/i)).toBeInTheDocument();
});
});
it("handles WebSocket close event", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("close", () => {
callbacks.onClose();
});
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitFor(() => {
expect(mockPublisher).toBeDefined();
mockPublisher.publishClose(new Event("close") as CloseEvent);
expect(
screen.getByText(/websocket connection.*unexpectedly closed/i),
).toBeInTheDocument();
});
});
it("only parameters from latest response are displayed", async () => {
const [mockWebSocket, mockPublisher] = createMockWebSocket("ws://test");
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
mockPublisher.publishOpen(new Event("open"));
mockPublisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify({
id: 0,
parameters: [MockDropdownParameter],
diagnostics: [],
}),
}),
);
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
const response1: DynamicParametersResponse = {
id: 1,
parameters: [MockDropdownParameter],
diagnostics: [],
};
const response2: DynamicParametersResponse = {
id: 4,
parameters: [MockSliderParameter],
diagnostics: [],
};
await waitFor(() => {
mockPublisher.publishMessage(
new MessageEvent("message", { data: JSON.stringify(response1) }),
);
mockPublisher.publishMessage(
new MessageEvent("message", { data: JSON.stringify(response2) }),
);
});
expect(screen.queryByText("CPU Count")).toBeInTheDocument();
expect(screen.queryByText("Instance Type")).not.toBeInTheDocument();
});
});
describe("Dynamic Parameter Types", () => {
it("displays parameter validation errors", async () => {
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(MockDynamicParametersResponseWithError),
}),
);
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("Validation failed")).toBeInTheDocument();
expect(
screen.getByText(
"The selected instance type is not available in this region",
),
).toBeInTheDocument();
});
});
it("displays parameter validation errors for min/max constraints", async () => {
const mockResponseInitial: DynamicParametersResponse = {
id: 1,
parameters: [MockValidationParameter],
diagnostics: [],
};
const mockResponseWithError: DynamicParametersResponse = {
id: 2,
parameters: [
{
...MockValidationParameter,
value: { value: "200", valid: false },
diagnostics: [
{
severity: "error",
summary:
"Invalid parameter value according to 'validation' block",
detail: "value 200 is more than the maximum 100",
extra: {
code: "",
},
},
],
},
],
diagnostics: [],
};
jest
.spyOn(API, "templateVersionDynamicParameters")
.mockImplementation((_versionId, _ownerId, callbacks) => {
const [mockWebSocket, publisher] = createMockWebSocket("ws://test");
mockWebSocket.addEventListener("message", (event) => {
callbacks.onMessage(JSON.parse(event.data));
});
publisher.publishOpen(new Event("open"));
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(mockResponseInitial),
}),
);
const originalSend = mockWebSocket.send;
mockWebSocket.send = jest.fn((data) => {
originalSend.call(mockWebSocket, data);
if (typeof data === "string" && data.includes('"200"')) {
publisher.publishMessage(
new MessageEvent("message", {
data: JSON.stringify(mockResponseWithError),
}),
);
}
});
return mockWebSocket;
});
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("Invalid Parameter")).toBeInTheDocument();
});
const numberInput = screen.getByDisplayValue("50");
expect(numberInput).toBeInTheDocument();
await waitFor(async () => {
await userEvent.clear(numberInput);
await userEvent.type(numberInput, "200");
});
await waitFor(() => {
expect(screen.getByDisplayValue("200")).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText(
"Invalid parameter value according to 'validation' block",
),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByText("value 200 is more than the maximum 100"),
).toBeInTheDocument();
});
const errorElement = screen.getByText(
"value 200 is more than the maximum 100",
);
expect(errorElement.closest("div")).toHaveClass(
"text-content-destructive",
);
});
});
describe("External Authentication", () => {
it("displays external auth providers", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("GitHub")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /login with github/i }),
).toBeInTheDocument();
});
});
it("shows authenticated state for connected providers", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(screen.getByText("GitHub")).toBeInTheDocument();
expect(screen.getByText(/authenticated/i)).toBeInTheDocument();
});
});
it("prevents auto-creation when required external auth is missing", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([MockTemplateVersionExternalAuthGithub]);
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?mode=auto`,
);
await waitForLoaderToBeRemoved();
await waitFor(() => {
expect(
screen.getByText(
/external authentication providers that are not connected/i,
),
).toBeInTheDocument();
expect(
screen.getByText(/auto-creation has been disabled/i),
).toBeInTheDocument();
});
});
});
describe("Auto-creation Mode", () => {
it("falls back to form mode when auto-creation fails", async () => {
jest
.spyOn(API, "getTemplateVersionExternalAuth")
.mockResolvedValue([
MockTemplateVersionExternalAuthGithubAuthenticated,
]);
jest
.spyOn(API, "createWorkspace")
.mockRejectedValue(new Error("Auto-creation failed"));
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?mode=auto`,
);
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Create workspace")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /create workspace/i }),
).toBeInTheDocument();
});
});
});
describe("Form Submission", () => {
it("creates workspace with correct parameters", async () => {
renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
await waitFor(async () => {
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "my-test-workspace");
});
const createButton = screen.getByRole("button", {
name: /create workspace/i,
});
await waitFor(async () => {
await userEvent.click(createButton);
});
await waitFor(() => {
expect(API.createWorkspace).toHaveBeenCalledWith(
"test-user",
expect.objectContaining({
name: "my-test-workspace",
template_version_id: MockTemplate.active_version_id,
template_id: undefined,
rich_parameter_values: [
expect.objectContaining({ name: "instance_type", value: "" }),
expect.objectContaining({ name: "cpu_count", value: "2" }),
expect.objectContaining({
name: "enable_monitoring",
value: "true",
}),
expect.objectContaining({ name: "tags", value: "[]" }),
expect.objectContaining({ name: "ides", value: "[]" }),
],
}),
);
});
});
});
describe("URL Parameters", () => {
it("pre-fills parameters from URL", async () => {
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?param.instance_type=t3.large&param.cpu_count=4`,
);
await waitForLoaderToBeRemoved();
expect(screen.getByText(/instance type/i)).toBeInTheDocument();
expect(screen.getByText("CPU Count")).toBeInTheDocument();
});
it("uses custom template version when specified", async () => {
const customVersionId = "custom-version-123";
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?version=${customVersionId}`,
);
await waitFor(() => {
expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith(
customVersionId,
MockUserOwner.id,
expect.any(Object),
);
});
});
it("pre-fills workspace name from URL", async () => {
const workspaceName = "my-custom-workspace";
renderCreateWorkspacePageExperimental(
`/templates/${MockTemplate.name}/workspace?name=${workspaceName}`,
);
await waitForLoaderToBeRemoved();
await waitFor(() => {
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
expect(nameInput).toHaveValue(workspaceName);
});
});
});
describe("Navigation", () => {
it("navigates to workspace after successful creation", async () => {
const { router } = renderCreateWorkspacePageExperimental();
await waitForLoaderToBeRemoved();
const nameInput = screen.getByRole("textbox", {
name: /workspace name/i,
});
await waitFor(async () => {
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "my-test-workspace");
});
// Submit form
const createButton = screen.getByRole("button", {
name: /create workspace/i,
});
await waitFor(async () => {
await userEvent.click(createButton);
});
await waitFor(() => {
expect(router.state.location.pathname).toBe(
`/@${MockWorkspace.owner_name}/${MockWorkspace.name}`,
);
});
});
});
});
@@ -274,16 +274,19 @@ const CreateWorkspacePageExperimental: FC = () => {
return [...latestResponse.parameters].sort((a, b) => a.order - b.order);
}, [latestResponse?.parameters]);
const shouldShowLoader =
!templateQuery.data ||
isLoadingFormData ||
isLoadingExternalAuth ||
autoCreateReady ||
(!latestResponse && !wsError);
return (
<>
<Helmet>
<title>{pageTitle(title)}</title>
</Helmet>
{!latestResponse ||
!templateQuery.data ||
isLoadingFormData ||
isLoadingExternalAuth ||
autoCreateReady ? (
{shouldShowLoader ? (
<Loader />
) : (
<CreateWorkspacePageViewExperimental
+183 -6
View File
@@ -3111,20 +3111,197 @@ export const MockPreviewParameter: TypesGen.PreviewParameter = {
display_name: "Parameter 1",
description: "This is a parameter",
type: "string",
mutable: true,
form_type: "input",
validations: [],
value: { valid: true, value: "" },
diagnostics: [],
options: [],
mutable: true,
ephemeral: false,
required: true,
value: { valid: true, value: "" },
default_value: { valid: true, value: "" },
options: [],
validations: [],
diagnostics: [],
icon: "",
styling: {},
default_value: { valid: true, value: "" },
order: 0,
};
export const MockDropdownParameter: TypesGen.PreviewParameter = {
...MockPreviewParameter,
name: "instance_type",
display_name: "Instance Type",
description: "The type of instance to create",
form_type: "dropdown",
default_value: { value: "t3.micro", valid: true },
options: [
{
name: "t3.micro",
description: "Micro instance",
value: { value: "t3.micro", valid: true },
icon: "",
},
{
name: "t3.small",
description: "Small instance",
value: { value: "t3.small", valid: true },
icon: "",
},
{
name: "t3.medium",
description: "Medium instance",
value: { value: "t3.medium", valid: true },
icon: "",
},
],
styling: {
placeholder: "",
disabled: false,
label: "",
},
order: 1,
};
const MockTagSelectParameter: TypesGen.PreviewParameter = {
...MockPreviewParameter,
name: "tags",
display_name: "Tags",
description: "Resource tags",
type: "list(string)",
form_type: "tag-select",
required: false,
value: { value: "[]", valid: true },
default_value: { value: "[]", valid: true },
styling: {
placeholder: "",
disabled: false,
label: "",
},
order: 4,
};
const MockSwitchParameter: TypesGen.PreviewParameter = {
...MockPreviewParameter,
name: "enable_monitoring",
display_name: "Enable Monitoring",
description: "Enable system monitoring",
type: "bool",
form_type: "switch",
required: false,
value: { value: "true", valid: true },
default_value: { value: "true", valid: true },
styling: {
placeholder: "",
disabled: false,
label: "",
},
order: 3,
};
export const MockSliderParameter: TypesGen.PreviewParameter = {
...MockPreviewParameter,
name: "cpu_count",
display_name: "CPU Count",
description: "Number of CPU cores",
type: "number",
form_type: "slider",
value: { value: "2", valid: true },
default_value: { value: "2", valid: true },
styling: {
placeholder: "",
disabled: false,
label: "",
},
order: 2,
};
const MockMultiSelectParameter: TypesGen.PreviewParameter = {
...MockPreviewParameter,
name: "ides",
display_name: "IDEs",
description: "Enabled IDEs",
type: "list(string)",
form_type: "multi-select",
required: false,
value: { value: "[]", valid: true },
default_value: { value: "[]", valid: true },
options: [
{
name: "vscode",
description: "Visual Studio Code",
value: { value: "vscode", valid: true },
icon: "",
},
{
name: "cursor",
description: "Cursor",
value: { value: "cursor", valid: true },
icon: "",
},
{
name: "goland",
description: "Goland",
value: { value: "goland", valid: true },
icon: "",
},
{
name: "windsurf",
description: "Windsurf",
value: { value: "windsurf", valid: true },
icon: "",
},
],
order: 5,
};
export const MockValidationParameter: TypesGen.PreviewParameter = {
...MockPreviewParameter,
name: "invalid_number",
display_name: "Invalid Parameter",
description: "Number parameter with validation error",
type: "number",
form_type: "input",
value: { value: "50", valid: true },
default_value: { value: "50", valid: true },
validations: [
{
validation_error: "Number must be between 0 and 100",
validation_regex: null,
validation_min: 0,
validation_max: 100,
validation_monotonic: null,
},
],
order: 1,
};
export const MockDynamicParametersResponse: TypesGen.DynamicParametersResponse =
{
id: 1,
parameters: [
MockDropdownParameter,
MockSliderParameter,
MockSwitchParameter,
MockTagSelectParameter,
MockMultiSelectParameter,
],
diagnostics: [],
};
export const MockDynamicParametersResponseWithError: TypesGen.DynamicParametersResponse =
{
id: 2,
parameters: [MockDropdownParameter],
diagnostics: [
{
severity: "error",
summary: "Validation failed",
detail: "The selected instance type is not available in this region",
extra: {
code: "",
},
},
],
};
export const MockTemplateVersionExternalAuthGithub: TypesGen.TemplateVersionExternalAuth =
{
id: "github",