coder/site/src/pages/WorkspacePage/WorkspacePage.test.tsx

525 lines
17 KiB
TypeScript

import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HttpResponse, http } from "msw";
import * as api from "api/api";
import type { TemplateVersionParameter, Workspace } from "api/typesGenerated";
import EventSourceMock from "eventsourcemock";
import {
MockTemplate,
MockWorkspace,
MockFailedWorkspace,
MockWorkspaceBuild,
MockStoppedWorkspace,
MockStartingWorkspace,
MockOutdatedWorkspace,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockUser,
MockDeploymentConfig,
MockWorkspaceBuildDelete,
} from "testHelpers/entities";
import { renderWithAuth } from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { WorkspacePage } from "./WorkspacePage";
// Renders the workspace page and waits for it be loaded
const renderWorkspacePage = async (workspace: Workspace) => {
jest.spyOn(api, "getWorkspaceByOwnerAndName").mockResolvedValue(workspace);
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate);
jest.spyOn(api, "getTemplateVersionRichParameters").mockResolvedValueOnce([]);
jest
.spyOn(api, "getDeploymentConfig")
.mockResolvedValueOnce(MockDeploymentConfig);
jest
.spyOn(api, "watchWorkspaceAgentLogs")
.mockImplementation((_, options) => {
options.onDone?.();
return new WebSocket("");
});
renderWithAuth(<WorkspacePage />, {
route: `/@${workspace.owner_name}/${workspace.name}`,
path: "/:username/:workspace",
});
await screen.findByText(workspace.name);
};
/**
* Requests and responses related to workspace status are unrelated, so we can't
* test in the usual way. Instead, test that button clicks produce the correct
* requests and that responses produce the correct UI.
*
* We don't need to test the UI exhaustively because Storybook does that; just
* enough to prove that the workspaceStatus was calculated correctly.
*/
const testButton = async (
workspace: Workspace,
name: string | RegExp,
actionMock: jest.SpyInstance,
) => {
await renderWorkspacePage(workspace);
const workspaceActions = screen.getByTestId("workspace-actions");
const button = within(workspaceActions).getByRole("button", { name });
const user = userEvent.setup();
await user.click(button);
expect(actionMock).toBeCalled();
};
let originalEventSource: typeof window.EventSource;
beforeAll(() => {
originalEventSource = window.EventSource;
// mocking out EventSource for SSE
window.EventSource = EventSourceMock;
});
beforeEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
window.EventSource = originalEventSource;
});
describe("WorkspacePage", () => {
it("requests a delete job when the user presses Delete and confirms", async () => {
const user = userEvent.setup({ delay: 0 });
const deleteWorkspaceMock = jest
.spyOn(api, "deleteWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
await renderWorkspacePage(MockWorkspace);
// open the workspace action popover so we have access to all available ctas
const trigger = screen.getByTestId("workspace-options-button");
await user.click(trigger);
// Click on delete
const button = await screen.findByTestId("delete-button");
await user.click(button);
// Get dialog and confirm
const dialog = await screen.findByTestId("dialog");
const labelText = "Workspace name";
const textField = within(dialog).getByLabelText(labelText);
await user.type(textField, MockWorkspace.name);
const confirmButton = within(dialog).getByRole("button", {
name: "Delete",
hidden: false,
});
await user.click(confirmButton);
expect(deleteWorkspaceMock).toBeCalled();
});
it("orphans the workspace on delete if option is selected", async () => {
const user = userEvent.setup({ delay: 0 });
// set permissions
server.use(
http.post("/api/v2/authcheck", async () => {
return HttpResponse.json({
updateTemplates: true,
updateWorkspace: true,
updateTemplate: true,
});
}),
);
const deleteWorkspaceMock = jest
.spyOn(api, "deleteWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuildDelete);
await renderWorkspacePage(MockFailedWorkspace);
// open the workspace action popover so we have access to all available ctas
const trigger = screen.getByTestId("workspace-options-button");
await user.click(trigger);
// Click on delete
const button = await screen.findByTestId("delete-button");
await user.click(button);
// Get dialog and enter confirmation text
const dialog = await screen.findByTestId("dialog");
const labelText = "Workspace name";
const textField = within(dialog).getByLabelText(labelText);
await user.type(textField, MockFailedWorkspace.name);
// check orphan option
const orphanCheckbox = within(
screen.getByTestId("orphan-checkbox"),
).getByRole("checkbox");
await user.click(orphanCheckbox);
// confirm
const confirmButton = within(dialog).getByRole("button", {
name: "Delete",
hidden: false,
});
await user.click(confirmButton);
// arguments are workspace.name, log level (undefined), and orphan
expect(deleteWorkspaceMock).toBeCalledWith(MockFailedWorkspace.id, {
log_level: undefined,
orphan: true,
});
});
it("requests a start job when the user presses Start", async () => {
server.use(
http.get(`/api/v2/users/:userId/workspace/:workspaceName`, () => {
return HttpResponse.json(MockStoppedWorkspace);
}),
);
const startWorkspaceMock = jest
.spyOn(api, "startWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild));
await testButton(MockStoppedWorkspace, "Start", startWorkspaceMock);
});
it("requests a stop job when the user presses Stop", async () => {
const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
await testButton(MockWorkspace, "Stop", stopWorkspaceMock);
});
it("requests a stop when the user presses Restart", async () => {
const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
// Render
await renderWorkspacePage(MockWorkspace);
// Actions
const user = userEvent.setup();
await user.click(screen.getByTestId("workspace-restart-button"));
const confirmButton = await screen.findByTestId("confirm-button");
await user.click(confirmButton);
// Assertions
await waitFor(() => {
expect(stopWorkspaceMock).toBeCalled();
});
});
it("requests cancellation when the user presses Cancel", async () => {
server.use(
http.get(`/api/v2/users/:userId/workspace/:workspaceName`, () => {
return HttpResponse.json(MockStartingWorkspace);
}),
);
const cancelWorkspaceMock = jest
.spyOn(api, "cancelWorkspaceBuild")
.mockImplementation(() => Promise.resolve({ message: "job canceled" }));
await testButton(MockStartingWorkspace, "Cancel", cancelWorkspaceMock);
});
it("requests an update when the user presses Update", async () => {
// Mocks
jest
.spyOn(api, "getWorkspaceByOwnerAndName")
.mockResolvedValueOnce(MockOutdatedWorkspace);
const updateWorkspaceMock = jest
.spyOn(api, "updateWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
// Render
await renderWorkspacePage(MockWorkspace);
// Actions
const user = userEvent.setup();
await user.click(screen.getByTestId("workspace-update-button"));
const confirmButton = await screen.findByTestId("confirm-button");
await user.click(confirmButton);
// Assertions
await waitFor(() => {
expect(updateWorkspaceMock).toBeCalled();
});
});
it("updates the parameters when they are missing during update", async () => {
// Mocks
jest
.spyOn(api, "getWorkspaceByOwnerAndName")
.mockResolvedValueOnce(MockOutdatedWorkspace);
const updateWorkspaceSpy = jest
.spyOn(api, "updateWorkspace")
.mockRejectedValueOnce(
new api.MissingBuildParameters(
[MockTemplateVersionParameter1, MockTemplateVersionParameter2],
MockOutdatedWorkspace.template_active_version_id,
),
);
// Render
await renderWorkspacePage(MockWorkspace);
// Actions
const user = userEvent.setup();
await user.click(screen.getByTestId("workspace-update-button"));
const confirmButton = await screen.findByTestId("confirm-button");
await user.click(confirmButton);
// The update was called
await waitFor(() => {
expect(api.updateWorkspace).toBeCalled();
updateWorkspaceSpy.mockClear();
});
// After trying to update, a new dialog asking for missed parameters should
// be displayed and filled
const dialog = await screen.findByTestId("dialog");
const firstParameterInput = within(dialog).getByLabelText(
MockTemplateVersionParameter1.name,
{ exact: false },
);
await user.clear(firstParameterInput);
await user.type(firstParameterInput, "some-value");
const secondParameterInput = within(dialog).getByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
);
await user.clear(secondParameterInput);
await user.type(secondParameterInput, "2");
await user.click(within(dialog).getByRole("button", { name: "Update" }));
// Check if the update was called using the values from the form
await waitFor(() => {
expect(api.updateWorkspace).toBeCalledWith(MockOutdatedWorkspace, [
{
name: MockTemplateVersionParameter1.name,
value: "some-value",
},
{
name: MockTemplateVersionParameter2.name,
value: "2",
},
]);
});
});
it("restart the workspace with one time parameters when having the confirmation dialog", async () => {
localStorage.removeItem(`${MockUser.id}_ignoredWarnings`);
jest.spyOn(api, "getWorkspaceParameters").mockResolvedValue({
templateVersionRichParameters: [
{
...MockTemplateVersionParameter1,
ephemeral: true,
name: "rebuild",
description: "Rebuild",
required: false,
},
],
buildParameters: [{ name: "rebuild", value: "false" }],
});
const restartWorkspaceSpy = jest.spyOn(api, "restartWorkspace");
const user = userEvent.setup();
await renderWorkspacePage(MockWorkspace);
await user.click(screen.getByTestId("build-parameters-button"));
const buildParametersForm = await screen.findByTestId(
"build-parameters-form",
);
const rebuildField = within(buildParametersForm).getByLabelText("Rebuild", {
exact: false,
});
await user.clear(rebuildField);
await user.type(rebuildField, "true");
await user.click(screen.getByTestId("build-parameters-submit"));
await user.click(screen.getByTestId("confirm-button"));
await waitFor(() => {
expect(restartWorkspaceSpy).toBeCalledWith({
workspace: MockWorkspace,
buildParameters: [{ name: "rebuild", value: "true" }],
});
});
});
// Tried to get these wired up via describe.each to reduce repetition, but the
// syntax just got too convoluted because of the variance in what arguments
// each function gets called with
describe("Retrying failed workspaces", () => {
const retryButtonRe = /^Retry$/i;
const retryDebugButtonRe = /^Debug$/i;
describe("Retries a failed 'Start' transition", () => {
const mockStart = jest.spyOn(api, "startWorkspace");
const failedStart: Workspace = {
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "start",
},
};
test("Retry with no debug", async () => {
await testButton(failedStart, retryButtonRe, mockStart);
expect(mockStart).toBeCalledWith(
failedStart.id,
failedStart.latest_build.template_version_id,
undefined,
undefined,
);
});
test("Retry with debug logs", async () => {
await testButton(failedStart, retryDebugButtonRe, mockStart);
expect(mockStart).toBeCalledWith(
failedStart.id,
failedStart.latest_build.template_version_id,
"debug",
undefined,
);
});
});
describe("Retries a failed 'Stop' transition", () => {
const mockStop = jest.spyOn(api, "stopWorkspace");
const failedStop: Workspace = {
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "stop",
},
};
test("Retry with no debug", async () => {
await testButton(failedStop, retryButtonRe, mockStop);
expect(mockStop).toBeCalledWith(failedStop.id, undefined);
});
test("Retry with debug logs", async () => {
await testButton(failedStop, retryDebugButtonRe, mockStop);
expect(mockStop).toBeCalledWith(failedStop.id, "debug");
});
});
describe("Retries a failed 'Delete' transition", () => {
const mockDelete = jest.spyOn(api, "deleteWorkspace");
const failedDelete: Workspace = {
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "delete",
},
};
test("Retry with no debug", async () => {
await testButton(failedDelete, retryButtonRe, mockDelete);
expect(mockDelete).toBeCalledWith(failedDelete.id, {
logLevel: undefined,
});
});
test("Retry with debug logs", async () => {
await testButton(failedDelete, retryDebugButtonRe, mockDelete);
expect(mockDelete).toBeCalledWith(failedDelete.id, {
logLevel: "debug",
});
});
});
});
it("retry with build parameters", async () => {
const user = userEvent.setup();
const workspace = {
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "start",
},
} satisfies Workspace;
const parameter = {
...MockTemplateVersionParameter1,
display_name: "Parameter 1",
ephemeral: true,
} satisfies TemplateVersionParameter;
server.use(
http.get("/api/v2/templateversions/:versionId/rich-parameters", () => {
return HttpResponse.json([parameter]);
}),
);
const startWorkspaceSpy = jest.spyOn(api, "startWorkspace");
await renderWorkspacePage(workspace);
const retryWithBuildParametersButton = await screen.findByRole("button", {
name: "Retry with build parameters",
});
await user.click(retryWithBuildParametersButton);
await screen.findByText("Build Options");
const parameterField = screen.getByLabelText(parameter.display_name, {
exact: false,
});
await user.clear(parameterField);
await user.type(parameterField, "some-value");
const submitButton = screen.getByText("Build workspace");
await user.click(submitButton);
await waitFor(() => {
expect(startWorkspaceSpy).toBeCalledWith(
workspace.id,
workspace.latest_build.template_version_id,
undefined,
[{ name: parameter.name, value: "some-value" }],
);
});
});
it("debug with build parameters", async () => {
const user = userEvent.setup();
const workspace = {
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "start",
},
} satisfies Workspace;
const parameter = {
...MockTemplateVersionParameter1,
display_name: "Parameter 1",
ephemeral: true,
} satisfies TemplateVersionParameter;
server.use(
http.get("/api/v2/templateversions/:versionId/rich-parameters", () => {
return HttpResponse.json([parameter]);
}),
);
const startWorkspaceSpy = jest.spyOn(api, "startWorkspace");
await renderWorkspacePage(workspace);
const retryWithBuildParametersButton = await screen.findByRole("button", {
name: "Debug with build parameters",
});
await user.click(retryWithBuildParametersButton);
await screen.findByText("Build Options");
const parameterField = screen.getByLabelText(parameter.display_name, {
exact: false,
});
await user.clear(parameterField);
await user.type(parameterField, "some-value");
const submitButton = screen.getByText("Build workspace");
await user.click(submitButton);
await waitFor(() => {
expect(startWorkspaceSpy).toBeCalledWith(
workspace.id,
workspace.latest_build.template_version_id,
"debug",
[{ name: parameter.name, value: "some-value" }],
);
});
});
});