Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions site/src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MockTemplateVersion2,
MockWorkspace,
MockWorkspaceBuild,
MockWorkspaceBuildStop,
} from "#/testHelpers/entities";
import { API, getURLWithSearchParams, ParameterValidationError } from "./api";
import type * as TypesGen from "./typesGenerated";
Expand Down Expand Up @@ -371,6 +372,139 @@ describe("api.ts", () => {
});
});

describe("restartWorkspace", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

const stopBuild: TypesGen.WorkspaceBuild = {
...MockWorkspaceBuildStop,
build_number: 7,
};
const childBuild: TypesGen.WorkspaceBuild = {
...MockWorkspaceBuild,
build_number: stopBuild.build_number + 1,
id: "child-build",
transition: "start",
};
const buildParameters: TypesGen.WorkspaceBuildParameter[] = [
{ name: "region", value: "us-east" },
];
it("forwards an abort signal when listing workspace builds", async () => {
const controller = new AbortController();
const get = vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({
data: [childBuild],
});

await API.getWorkspaceBuilds(
stopBuild.workspace_id,
undefined,
controller.signal,
);

expect(get).toHaveBeenCalledWith(
`/api/v2/workspaces/${stopBuild.workspace_id}/builds`,
{ signal: controller.signal },
);
});

it("submits one stop build with a follow-up start", async () => {
const postWorkspaceBuild = vi
.spyOn(API, "postWorkspaceBuild")
.mockResolvedValue(stopBuild);
vi.spyOn(API, "waitForBuild")
.mockResolvedValueOnce(MockProvisionerJob)
.mockResolvedValueOnce(MockProvisionerJob);
vi.spyOn(API, "getWorkspaceBuilds").mockResolvedValue([childBuild]);
const startWorkspace = vi.spyOn(API, "startWorkspace");

await API.restartWorkspace({
workspace: MockWorkspace,
buildParameters,
});

expect(postWorkspaceBuild).toHaveBeenCalledOnce();
expect(postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "stop",
reason: "dashboard",
on_success: {
transition: "start",
rich_parameter_values: buildParameters,
},
});
expect(startWorkspace).not.toHaveBeenCalled();
});

it("does not look for a follow-up build when the stop is canceled", async () => {
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue(stopBuild);
vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({
...MockProvisionerJob,
status: "canceled",
});
const getWorkspaceBuilds = vi.spyOn(API, "getWorkspaceBuilds");

await API.restartWorkspace({ workspace: MockWorkspace });

expect(getWorkspaceBuilds).not.toHaveBeenCalled();
});

it("waits for the server-created child build after the stop succeeds", async () => {
vi.useFakeTimers();
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue(stopBuild);
const waitForBuild = vi
.spyOn(API, "waitForBuild")
.mockResolvedValueOnce(MockProvisionerJob)
.mockResolvedValueOnce(MockProvisionerJob);
const getWorkspaceBuilds = vi
.spyOn(API, "getWorkspaceBuilds")
.mockResolvedValueOnce([stopBuild])
.mockResolvedValue([childBuild, stopBuild]);

const restart = API.restartWorkspace({ workspace: MockWorkspace });
await vi.advanceTimersByTimeAsync(1000);
await restart;

expect(getWorkspaceBuilds).toHaveBeenCalledTimes(2);
expect(getWorkspaceBuilds).toHaveBeenCalledWith(
stopBuild.workspace_id,
{ limit: 5 },
expect.any(AbortSignal),
);
expect(waitForBuild).toHaveBeenNthCalledWith(1, stopBuild);
expect(waitForBuild).toHaveBeenNthCalledWith(2, childBuild);
});

it("stops waiting when the follow-up build is not created in time", async () => {
vi.useFakeTimers();
vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue(stopBuild);
vi.spyOn(API, "waitForBuild").mockResolvedValue(MockProvisionerJob);
const getWorkspaceBuilds = vi
.spyOn(API, "getWorkspaceBuilds")
.mockImplementation((_workspaceId, _req, signal) => {
return new Promise((_, reject) => {
signal?.addEventListener("abort", () =>
reject(new Error("canceled")),
);
});
});

const restart = API.restartWorkspace({ workspace: MockWorkspace });
// Attach the rejection handler before the deadline fires so the
// rejection is never unhandled.
const rejection = expect(restart).rejects.toThrow(
"The workspace stopped, but the server did not start it again.",
);

await vi.advanceTimersByTimeAsync(179_999);
expect(getWorkspaceBuilds.mock.calls[0][2]?.aborted).toBe(false);

await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(getWorkspaceBuilds.mock.calls[0][2]?.aborted).toBe(true);
});
});

describe("changeWorkspaceVersion", () => {
it("stops workspace before changing version if running", async () => {
vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({
Expand Down
61 changes: 52 additions & 9 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,47 @@ class ApiMethods {
});
};

private waitForRestartBuild = async (
stopBuild: TypesGen.WorkspaceBuild,
): Promise<TypesGen.WorkspaceBuild> => {
const childBuildNumber = stopBuild.build_number + 1;
// The server orchestrator may create the child build up to ~2 minutes
// after the stop succeeds (30s backup poll plus up to 3 attempts spaced
// 30s apart), so the deadline must outlast that retry lifecycle.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 180_000);
try {
while (!controller.signal.aborted) {
try {
const builds = await this.getWorkspaceBuilds(
stopBuild.workspace_id,
{ limit: 5 },
controller.signal,
);
const childBuild = builds.find(
(build) => build.build_number === childBuildNumber,
);
if (childBuild) {
return childBuild;
}
} catch (error) {
if (controller.signal.aborted) {
break;
}
throw error;
}
await delay(1000);
}
} finally {
clearTimeout(timeoutId);
}
throw new Error(
"The workspace stopped, but the server did not start it again. " +
"This can happen when the current build parameters are no longer valid for the template. " +
"Start the workspace manually from the workspace page.",
);
};

postWorkspaceBuild = async (
workspaceId: string,
data: TypesGen.CreateWorkspaceBuildRequest,
Expand Down Expand Up @@ -1411,21 +1452,21 @@ class ApiMethods {
workspace,
buildParameters,
}: RestartWorkspaceParameters): Promise<void> => {
const stopBuild = await this.stopWorkspace(workspace.id);
const stopBuild = await this.postWorkspaceBuild(workspace.id, {
transition: "stop",
reason: "dashboard",
on_success: {
transition: "start",
rich_parameter_values: buildParameters,
},
Comment thread
ibetitsmike marked this conversation as resolved.
});
const awaitedStopBuild = await this.waitForBuild(stopBuild);

// If the restart is canceled halfway through, make sure we bail
if (awaitedStopBuild?.status === "canceled") {
return;
}

const startBuild = await this.startWorkspace(
workspace.id,
workspace.latest_build.template_version_id,
undefined,
buildParameters,
);

const startBuild = await this.waitForRestartBuild(stopBuild);
await this.waitForBuild(startBuild);
};

Expand Down Expand Up @@ -1791,9 +1832,11 @@ class ApiMethods {
getWorkspaceBuilds = async (
workspaceId: string,
req?: TypesGen.WorkspaceBuildsRequest,
signal?: AbortSignal,
) => {
const response = await this.axios.get<TypesGen.WorkspaceBuild[]>(
getURLWithSearchParams(`/api/v2/workspaces/${workspaceId}/builds`, req),
{ signal },
);

return response.data;
Expand Down
20 changes: 0 additions & 20 deletions site/src/pages/WorkspacePage/WorkspacePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,26 +194,6 @@ describe("WorkspacePage", () => {
await testButton(MockWorkspace, "Stop", stopWorkspaceMock);
});

it("requests a stop when the user presses Restart", async () => {
const stopWorkspaceMock = vi
.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", () => {
Expand Down
112 changes: 112 additions & 0 deletions site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import { buildInfoKey } from "#/api/queries/buildInfo";
import { templateVersion } from "#/api/queries/templates";
import { agentListeningPorts } from "#/api/queries/workspaces";
import type { Workspace } from "#/api/typesGenerated";
import * as Mocks from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
withProxyProvider,
} from "#/testHelpers/storybook";
import type { WorkspacePermissions } from "../../modules/workspaces/permissions";
import { WorkspaceReadyPage } from "./WorkspaceReadyPage";

const permissions: WorkspacePermissions = {
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
};

const workspaceQueries = (workspace: Workspace) => [
{ key: buildInfoKey, data: Mocks.MockBuildInfo },
{
key: agentListeningPorts(Mocks.MockWorkspaceAgent.id).queryKey,
data: Mocks.MockListeningPortsResponse,
},
{
key: templateVersion(workspace.template_active_version_id).queryKey,
data: Mocks.MockTemplateVersion,
},
];

const meta: Meta<typeof WorkspaceReadyPage> = {
title: "pages/WorkspacePage/WorkspaceReadyPage",
component: WorkspaceReadyPage,
args: {
workspace: Mocks.MockWorkspace,
template: Mocks.MockTemplate,
permissions,
},
parameters: {
queries: workspaceQueries(Mocks.MockWorkspace),
user: Mocks.MockUserOwner,
},
decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()],
beforeEach: () => {
spyOn(API, "getDynamicParameters").mockResolvedValue([]);
spyOn(API, "workspaceBuildTimings").mockResolvedValue({
provisioner_timings: [],
agent_script_timings: [],
agent_connection_timings: [],
});
},
};

export default meta;
type Story = StoryObj<typeof WorkspaceReadyPage>;

const openRestartDialog = async (canvasElement: HTMLElement) => {
const body = within(canvasElement.ownerDocument.body);
const restartButton = await body.findByRole("button", {
name: /^restart…$/i,
});
await userEvent.click(restartButton);
return await body.findByRole("dialog");
};

export const RestartDialog: Story = {
play: async ({ canvasElement }) => {
const dialog = await openRestartDialog(canvasElement);
expect(dialog).toHaveTextContent(/delete non-persistent data/);
expect(dialog).toHaveTextContent(
/The workspace will start using the template's active version/,
);
},
};

export const RestartDialogOutdatedWorkspace: Story = {
args: {
workspace: Mocks.MockRunningOutdatedWorkspace,
},
parameters: {
queries: workspaceQueries(Mocks.MockRunningOutdatedWorkspace),
},
play: async ({ canvasElement }) => {
const dialog = await openRestartDialog(canvasElement);
expect(dialog).toHaveTextContent(
/The workspace will start using the template's active version/,
);
},
};

export const ConfirmRestart: Story = {
beforeEach: () => {
spyOn(API, "restartWorkspace").mockResolvedValue(undefined);
},
play: async ({ canvasElement }) => {
const dialog = await openRestartDialog(canvasElement);
await userEvent.click(
within(dialog).getByRole("button", { name: "Restart" }),
);
await waitFor(() =>
expect(API.restartWorkspace).toHaveBeenCalledWith(
expect.objectContaining({ workspace: Mocks.MockWorkspace }),
),
);
},
};
4 changes: 3 additions & 1 deletion site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
description={
<>
Restarting your workspace will stop all running processes and{" "}
<strong>delete non-persistent data</strong>.
<strong>delete non-persistent data</strong>. The workspace will
start using the template's active version, which may include an
update.
</>
}
/>
Expand Down
Loading
Loading