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:
Ethan
2026-07-08 12:12:29 +10:00
committed by GitHub
parent bc0f7cf0ae
commit 9a1aab7986
2 changed files with 179 additions and 163 deletions
@@ -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,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);
}