mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
test: migrate template settings tests to Storybook play stories (#26957)
Migrates the TemplateSettingsPage tests from vitest to Storybook play-function stories. The old `TemplateSettingsPage.test.tsx` rendered the full settings layout through `renderWithTemplateSettingsLayout` and MSW, which is slow and contributes to `test-js` timeout flakes. The new stories seed the react-query cache directly and assert the same behavior in `play` functions, so they run as Storybook interaction tests instead of in the vitest `unit` project. Coverage is preserved across four flows: a successful metadata update, the validation error surfaced in the form when the name is already taken, deprecating a template when the access_control entitlement is present, and leaving the deprecation message empty when it is not. The pure-logic description validation tests stay in `TemplateSettingsPage.test.tsx`. Relates to CODAGT-686
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { API } from "#/api/api";
|
||||
import { getAuthorizationKey } from "#/api/queries/authCheck";
|
||||
import { templateByNameKey } from "#/api/queries/templates";
|
||||
import { MockTemplate, mockApiError } from "#/testHelpers/entities";
|
||||
import { withDashboardProvider, withToaster } from "#/testHelpers/storybook";
|
||||
import { TemplateSettingsLayout } from "../TemplateSettingsLayout";
|
||||
import TemplateSettingsPage from "./TemplateSettingsPage";
|
||||
|
||||
const meta = {
|
||||
title: "pages/TemplateSettingsPage/TemplateSettingsPage",
|
||||
component: TemplateSettingsLayout,
|
||||
decorators: [withToaster, withDashboardProvider],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
path: "/templates/:template/settings",
|
||||
pathParams: { template: MockTemplate.name },
|
||||
},
|
||||
routing: [
|
||||
{
|
||||
path: "/templates/:template/settings",
|
||||
useStoryElement: true,
|
||||
children: [{ index: true, element: <TemplateSettingsPage /> }],
|
||||
},
|
||||
{ path: "/templates/:template", element: <div>Template</div> },
|
||||
],
|
||||
}),
|
||||
queries: [
|
||||
{
|
||||
key: templateByNameKey("default", MockTemplate.name),
|
||||
data: MockTemplate,
|
||||
},
|
||||
{
|
||||
key: getAuthorizationKey({
|
||||
checks: {
|
||||
canUpdateTemplate: {
|
||||
object: {
|
||||
resource_type: "template",
|
||||
resource_id: MockTemplate.id,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
},
|
||||
}),
|
||||
data: { canUpdateTemplate: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Meta<typeof TemplateSettingsLayout>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const UpdateSucceeds: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const updateTemplateMetaSpy = spyOn(
|
||||
API,
|
||||
"updateTemplateMeta",
|
||||
).mockResolvedValue({ ...MockTemplate, name: "new-name" });
|
||||
await fillAndSubmitForm(canvas, user);
|
||||
await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1));
|
||||
},
|
||||
};
|
||||
|
||||
export const DisplaysErrorWhenNameIsTaken: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const updateTemplateMetaSpy = spyOn(
|
||||
API,
|
||||
"updateTemplateMeta",
|
||||
).mockRejectedValue(
|
||||
mockApiError({
|
||||
message: `Template with name "test-template" already exists`,
|
||||
validations: [
|
||||
{
|
||||
field: "name",
|
||||
detail: "This value is already in use and should be unique.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await fillAndSubmitForm(canvas, user);
|
||||
await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1));
|
||||
const form = await canvas.findByRole("form", {
|
||||
name: /template settings/i,
|
||||
});
|
||||
await within(form).findByText(
|
||||
"This value is already in use and should be unique.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DeprecatesTemplateWithAccessControl: Story = {
|
||||
parameters: {
|
||||
features: ["access_control"],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const updateTemplateMetaSpy = spyOn(
|
||||
API,
|
||||
"updateTemplateMeta",
|
||||
).mockResolvedValue(MockTemplate);
|
||||
const deprecationMessage = "This template is deprecated";
|
||||
await deprecateTemplate(canvas, user, deprecationMessage);
|
||||
await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1));
|
||||
const [templateId, data] = updateTemplateMetaSpy.mock.calls[0];
|
||||
expect(templateId).toEqual(MockTemplate.id);
|
||||
expect(data).toEqual(
|
||||
expect.objectContaining({ deprecation_message: deprecationMessage }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DoesNotDeprecateWithoutAccessControl: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
const updateTemplateMetaSpy = spyOn(
|
||||
API,
|
||||
"updateTemplateMeta",
|
||||
).mockResolvedValue(MockTemplate);
|
||||
await deprecateTemplate(
|
||||
canvas,
|
||||
user,
|
||||
"This template should not be able to deprecate",
|
||||
);
|
||||
await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1));
|
||||
const [templateId, data] = updateTemplateMetaSpy.mock.calls[0];
|
||||
expect(templateId).toEqual(MockTemplate.id);
|
||||
expect(data).toEqual(expect.objectContaining({ deprecation_message: "" }));
|
||||
},
|
||||
};
|
||||
|
||||
async function fillAndSubmitForm(
|
||||
canvas: ReturnType<typeof within>,
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
const nameField = await canvas.findByLabelText("Name");
|
||||
await user.clear(nameField);
|
||||
await user.type(nameField, "Name");
|
||||
|
||||
const displayNameField = await canvas.findByLabelText("Display name");
|
||||
await user.clear(displayNameField);
|
||||
await user.type(displayNameField, "A display name");
|
||||
|
||||
const descriptionField = await canvas.findByLabelText("Description");
|
||||
await user.clear(descriptionField);
|
||||
await user.type(descriptionField, "A description");
|
||||
|
||||
const iconField = await canvas.findByLabelText("Icon");
|
||||
await user.clear(iconField);
|
||||
await user.type(iconField, "vscode.png");
|
||||
|
||||
const allowCancelJobsField = canvas.getByRole("checkbox", {
|
||||
name: /allow users to cancel in-progress workspace jobs/i,
|
||||
});
|
||||
await user.click(allowCancelJobsField);
|
||||
|
||||
await user.click(await canvas.findByRole("button", { name: /save/i }));
|
||||
}
|
||||
|
||||
async function deprecateTemplate(
|
||||
canvas: ReturnType<typeof within>,
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
message: string,
|
||||
) {
|
||||
const deprecationField = await canvas.findByLabelText("Deprecation Message");
|
||||
await user.type(deprecationField, message);
|
||||
await user.click(await canvas.findByRole("button", { name: /save/i }));
|
||||
}
|
||||
+1
-163
@@ -1,20 +1,5 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { API, withDefaultFeatures } from "#/api/api";
|
||||
import type { UpdateTemplateMeta } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockEntitlements,
|
||||
MockTemplate,
|
||||
mockApiError,
|
||||
} from "#/testHelpers/entities";
|
||||
import {
|
||||
renderWithTemplateSettingsLayout,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "#/testHelpers/renderHelpers";
|
||||
import { server } from "#/testHelpers/server";
|
||||
import { validationSchema } from "./TemplateSettingsForm";
|
||||
import TemplateSettingsPage from "./TemplateSettingsPage";
|
||||
|
||||
type FormValues = Required<
|
||||
Omit<
|
||||
@@ -62,92 +47,7 @@ const validFormValues: FormValues = {
|
||||
disable_module_cache: false,
|
||||
};
|
||||
|
||||
const renderTemplateSettingsPage = async () => {
|
||||
renderWithTemplateSettingsLayout(<TemplateSettingsPage />, {
|
||||
route: `/templates/${MockTemplate.name}/settings`,
|
||||
path: "/templates/:template/settings",
|
||||
extraRoutes: [
|
||||
{ path: "/templates/:template", element: <div>Template</div> },
|
||||
],
|
||||
});
|
||||
await waitForLoaderToBeRemoved();
|
||||
};
|
||||
|
||||
const fillAndSubmitForm = async ({
|
||||
name,
|
||||
display_name,
|
||||
description,
|
||||
icon,
|
||||
allow_user_cancel_workspace_jobs,
|
||||
}: FormValues) => {
|
||||
const nameField = await screen.findByLabelText("Name");
|
||||
await userEvent.clear(nameField);
|
||||
await userEvent.type(nameField, name);
|
||||
|
||||
const displayNameField = await screen.findByLabelText("Display name");
|
||||
await userEvent.clear(displayNameField);
|
||||
await userEvent.type(displayNameField, display_name);
|
||||
|
||||
const descriptionField = await screen.findByLabelText("Description");
|
||||
await userEvent.clear(descriptionField);
|
||||
await userEvent.type(descriptionField, description);
|
||||
|
||||
const iconField = await screen.findByLabelText("Icon");
|
||||
await userEvent.clear(iconField);
|
||||
await userEvent.type(iconField, icon);
|
||||
|
||||
const allowCancelJobsField = screen.getByRole("checkbox", {
|
||||
name: /allow users to cancel in-progress workspace jobs/i,
|
||||
});
|
||||
// checkbox is checked by default, so it must be clicked to get unchecked
|
||||
if (!allow_user_cancel_workspace_jobs) {
|
||||
await userEvent.click(allowCancelJobsField);
|
||||
}
|
||||
|
||||
const submitButton = await screen.findByText(/save/i);
|
||||
await userEvent.click(submitButton);
|
||||
};
|
||||
|
||||
describe("TemplateSettingsPage", { timeout: 20_000 }, () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("succeeds", async () => {
|
||||
await renderTemplateSettingsPage();
|
||||
vi.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
|
||||
...MockTemplate,
|
||||
...validFormValues,
|
||||
});
|
||||
await fillAndSubmitForm(validFormValues);
|
||||
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1));
|
||||
});
|
||||
|
||||
it("displays an error if the name is taken", async () => {
|
||||
await renderTemplateSettingsPage();
|
||||
vi.spyOn(API, "updateTemplateMeta").mockRejectedValueOnce(
|
||||
mockApiError({
|
||||
message: `Template with name "test-template" already exists`,
|
||||
validations: [
|
||||
{
|
||||
field: "name",
|
||||
detail: "This value is already in use and should be unique.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await fillAndSubmitForm(validFormValues);
|
||||
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1));
|
||||
const form = await screen.findByRole("form", {
|
||||
name: /template settings/i,
|
||||
});
|
||||
expect(
|
||||
await within(form).findByText(
|
||||
"This value is already in use and should be unique.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("TemplateSettingsPage", () => {
|
||||
it("allows a description of 128 chars", () => {
|
||||
const values: UpdateTemplateMeta = {
|
||||
...validFormValues,
|
||||
@@ -167,66 +67,4 @@ describe("TemplateSettingsPage", { timeout: 20_000 }, () => {
|
||||
const validate = () => validationSchema.validateSync(values);
|
||||
expect(validate).toThrowError();
|
||||
});
|
||||
|
||||
describe("Deprecate template", () => {
|
||||
it("deprecates a template when has access control", async () => {
|
||||
server.use(
|
||||
http.get("/api/v2/entitlements", () => {
|
||||
return HttpResponse.json({
|
||||
...MockEntitlements,
|
||||
features: withDefaultFeatures({
|
||||
access_control: { enabled: true, entitlement: "entitled" },
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
const updateTemplateMetaSpy = vi.spyOn(API, "updateTemplateMeta");
|
||||
const deprecationMessage = "This template is deprecated";
|
||||
|
||||
await renderTemplateSettingsPage();
|
||||
await deprecateTemplate(deprecationMessage);
|
||||
await waitFor(() =>
|
||||
expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
const [templateId, data] = updateTemplateMetaSpy.mock.calls[0];
|
||||
expect(templateId).toEqual(MockTemplate.id);
|
||||
expect(data).toEqual(
|
||||
expect.objectContaining({ deprecation_message: deprecationMessage }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not deprecate a template when does not have access control", async () => {
|
||||
server.use(
|
||||
http.get("/api/v2/entitlements", () => {
|
||||
return HttpResponse.json({
|
||||
...MockEntitlements,
|
||||
features: withDefaultFeatures({
|
||||
access_control: { enabled: false, entitlement: "not_entitled" },
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
const updateTemplateMetaSpy = vi.spyOn(API, "updateTemplateMeta");
|
||||
|
||||
await renderTemplateSettingsPage();
|
||||
await deprecateTemplate("This template should not be able to deprecate");
|
||||
await waitFor(() =>
|
||||
expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
const [templateId, data] = updateTemplateMetaSpy.mock.calls[0];
|
||||
expect(templateId).toEqual(MockTemplate.id);
|
||||
expect(data).toEqual(
|
||||
expect.objectContaining({ deprecation_message: "" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function deprecateTemplate(message: string) {
|
||||
const deprecationField = screen.getByLabelText("Deprecation Message");
|
||||
await userEvent.type(deprecationField, message);
|
||||
const submitButton = await screen.findByRole("button", { name: /save/i });
|
||||
await userEvent.click(submitButton);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user