From 58a777cbe2245f3b1b1a73427de676ebda7572bc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:39:57 +0000 Subject: [PATCH 01/12] fix(site): use server-side workspace restart orchestration --- site/src/api/api.test.ts | 171 ++++++++++++++++++ site/src/api/api.ts | 73 ++++++-- .../WorkspacePage/WorkspacePage.test.tsx | 29 ++- .../WorkspacePage/WorkspaceReadyPage.tsx | 2 + 4 files changed, 255 insertions(+), 20 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index f832533a53bf3..72bdb54cb53fa 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -5,6 +5,7 @@ import { MockTemplateVersion2, MockWorkspace, MockWorkspaceBuild, + MockWorkspaceBuildStop, } from "#/testHelpers/entities"; import { API, getURLWithSearchParams, ParameterValidationError } from "./api"; import type * as TypesGen from "./typesGenerated"; @@ -371,6 +372,176 @@ 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" }, + ]; + const notFoundError = { + isAxiosError: true, + response: { status: 404 }, + }; + + it("forwards an abort signal when fetching a workspace build", async () => { + const controller = new AbortController(); + const get = vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({ + data: childBuild, + }); + + await API.getWorkspaceBuildByNumber( + childBuild.workspace_owner_name, + childBuild.workspace_name, + childBuild.build_number, + controller.signal, + ); + + expect(get).toHaveBeenCalledWith( + `/api/v2/users/${childBuild.workspace_owner_name}/workspace/${childBuild.workspace_name}/builds/${childBuild.build_number}`, + { 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, "getWorkspaceBuildByNumber").mockResolvedValue(childBuild); + const startWorkspace = vi + .spyOn(API, "startWorkspace") + .mockResolvedValue(childBuild); + + 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 pin the follow-up start to a template version", async () => { + const postWorkspaceBuild = vi + .spyOn(API, "postWorkspaceBuild") + .mockResolvedValue(stopBuild); + vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({ + ...MockProvisionerJob, + status: "canceled", + }); + + await API.restartWorkspace({ workspace: MockWorkspace }); + + const request = postWorkspaceBuild.mock.calls[0][1]; + expect(request.on_success).toBeDefined(); + expect(request.on_success).not.toHaveProperty("template_version_id"); + }); + + 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 getWorkspaceBuildByNumber = vi.spyOn( + API, + "getWorkspaceBuildByNumber", + ); + + await API.restartWorkspace({ workspace: MockWorkspace }); + + expect(getWorkspaceBuildByNumber).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 getWorkspaceBuildByNumber = vi + .spyOn(API, "getWorkspaceBuildByNumber") + .mockRejectedValueOnce(notFoundError) + .mockResolvedValue(childBuild); + + const restart = API.restartWorkspace({ workspace: MockWorkspace }); + await vi.advanceTimersByTimeAsync(1000); + await restart; + + expect(getWorkspaceBuildByNumber).toHaveBeenCalledTimes(2); + expect(getWorkspaceBuildByNumber).toHaveBeenCalledWith( + stopBuild.workspace_owner_name, + stopBuild.workspace_name, + stopBuild.build_number + 1, + 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 getWorkspaceBuildByNumber = vi + .spyOn(API, "getWorkspaceBuildByNumber") + .mockImplementation( + (_username, _workspaceName, _buildNumber, signal) => { + return new Promise((_, reject) => { + signal?.addEventListener("abort", () => + reject(new Error("canceled")), + ); + }); + }, + ); + + const restart = API.restartWorkspace({ workspace: MockWorkspace }); + let settled = false; + void restart.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + await vi.advanceTimersByTimeAsync(59_999); + expect(settled).toBe(false); + expect(getWorkspaceBuildByNumber.mock.calls[0][3]?.aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(restart).rejects.toThrow( + "The workspace stopped but the follow-up start build was not created.", + ); + expect(getWorkspaceBuildByNumber.mock.calls[0][3]?.aborted).toBe(true); + }); + }); + describe("changeWorkspaceVersion", () => { it("stops workspace before changing version if running", async () => { vi.spyOn(API, "stopWorkspace").mockResolvedValueOnce({ diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 078da917beb1e..b9887fd78b953 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1276,10 +1276,12 @@ class ApiMethods { username: string, workspaceName: string, buildNumber: number, + signal?: AbortSignal, ): Promise => { - const response = await this.axios.get( - `/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`, - ); + const url = `/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`; + const response = await this.axios.get(url, { + signal, + }); return response.data; }; @@ -1313,6 +1315,53 @@ class ApiMethods { }); }; + private waitForRestartBuild = async ( + stopBuild: TypesGen.WorkspaceBuild, + ): Promise => { + const deadline = Date.now() + 60_000; + const timeoutError = new Error( + "The workspace stopped but the follow-up start build was not created.", + ); + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + Math.max(0, deadline - Date.now()), + ); + try { + while (true) { + const remainingTime = deadline - Date.now(); + if (remainingTime <= 0) { + throw timeoutError; + } + + try { + return await this.getWorkspaceBuildByNumber( + stopBuild.workspace_owner_name, + stopBuild.workspace_name, + stopBuild.build_number + 1, + controller.signal, + ); + } catch (error) { + if (controller.signal.aborted) { + throw timeoutError; + } + if (!isAxiosError(error) || error.response?.status !== 404) { + throw error; + } + } + + const delayTime = Math.min(1000, deadline - Date.now()); + if (delayTime <= 0) { + throw timeoutError; + } + await delay(delayTime); + } + } finally { + clearTimeout(timeoutId); + } + }; + postWorkspaceBuild = async ( workspaceId: string, data: TypesGen.CreateWorkspaceBuildRequest, @@ -1411,21 +1460,21 @@ class ApiMethods { workspace, buildParameters, }: RestartWorkspaceParameters): Promise => { - 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, + }, + }); 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); }; diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 31c901af8db77..360995df035e6 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -26,6 +26,7 @@ import { MockWorkspace, MockWorkspaceBuild, MockWorkspaceBuildDelete, + MockWorkspaceBuildStop, } from "#/testHelpers/entities"; import { type RenderWithAuthOptions, @@ -194,24 +195,36 @@ 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); + it("requests an orchestrated restart when the user presses Restart", async () => { + const childBuild = { + ...MockWorkspaceBuild, + build_number: MockWorkspaceBuildStop.build_number + 1, + }; + const postWorkspaceBuild = vi + .spyOn(API, "postWorkspaceBuild") + .mockResolvedValueOnce(MockWorkspaceBuildStop); + vi.spyOn(API, "waitForBuild").mockResolvedValue(MockWorkspaceBuild.job); + vi.spyOn(API, "getWorkspaceBuildByNumber").mockResolvedValue(childBuild); + const startWorkspace = vi.spyOn(API, "startWorkspace"); - // 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(); + expect(postWorkspaceBuild).toHaveBeenCalledOnce(); }); + expect(postWorkspaceBuild).toHaveBeenCalledWith( + MockWorkspace.id, + expect.objectContaining({ + transition: "stop", + on_success: expect.objectContaining({ transition: "start" }), + }), + ); + expect(startWorkspace).not.toHaveBeenCalled(); }); it("requests cancellation when the user presses Cancel", async () => { diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index d390ea2bb355e..a200b7fcd9fca 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -385,6 +385,8 @@ export const WorkspaceReadyPage: FC = ({ <> Restarting your workspace will stop all running processes and{" "} delete non-persistent data. + {workspace.outdated && + " This workspace will start using the template's latest active version."} } /> From 808615d9100623e9bc744b7c25a046fd4d0cf08a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:49:24 +0000 Subject: [PATCH 02/12] chore(site): simplify restart orchestration helpers and tests --- site/src/api/api.test.ts | 30 +-------------- site/src/api/api.ts | 37 ++++++------------- .../WorkspacePage/WorkspacePage.test.tsx | 26 +++---------- 3 files changed, 18 insertions(+), 75 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 72bdb54cb53fa..b21ae314a3f80 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -423,9 +423,7 @@ describe("api.ts", () => { .mockResolvedValueOnce(MockProvisionerJob) .mockResolvedValueOnce(MockProvisionerJob); vi.spyOn(API, "getWorkspaceBuildByNumber").mockResolvedValue(childBuild); - const startWorkspace = vi - .spyOn(API, "startWorkspace") - .mockResolvedValue(childBuild); + const startWorkspace = vi.spyOn(API, "startWorkspace"); await API.restartWorkspace({ workspace: MockWorkspace, @@ -444,22 +442,6 @@ describe("api.ts", () => { expect(startWorkspace).not.toHaveBeenCalled(); }); - it("does not pin the follow-up start to a template version", async () => { - const postWorkspaceBuild = vi - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValue(stopBuild); - vi.spyOn(API, "waitForBuild").mockResolvedValueOnce({ - ...MockProvisionerJob, - status: "canceled", - }); - - await API.restartWorkspace({ workspace: MockWorkspace }); - - const request = postWorkspaceBuild.mock.calls[0][1]; - expect(request.on_success).toBeDefined(); - expect(request.on_success).not.toHaveProperty("template_version_id"); - }); - 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({ @@ -520,18 +502,8 @@ describe("api.ts", () => { ); const restart = API.restartWorkspace({ workspace: MockWorkspace }); - let settled = false; - void restart.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); await vi.advanceTimersByTimeAsync(59_999); - expect(settled).toBe(false); expect(getWorkspaceBuildByNumber.mock.calls[0][3]?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(1); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b9887fd78b953..7216fd7293439 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1278,10 +1278,10 @@ class ApiMethods { buildNumber: number, signal?: AbortSignal, ): Promise => { - const url = `/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`; - const response = await this.axios.get(url, { - signal, - }); + const response = await this.axios.get( + `/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`, + { signal }, + ); return response.data; }; @@ -1318,23 +1318,10 @@ class ApiMethods { private waitForRestartBuild = async ( stopBuild: TypesGen.WorkspaceBuild, ): Promise => { - const deadline = Date.now() + 60_000; - const timeoutError = new Error( - "The workspace stopped but the follow-up start build was not created.", - ); - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - Math.max(0, deadline - Date.now()), - ); + const timeoutId = setTimeout(() => controller.abort(), 60_000); try { - while (true) { - const remainingTime = deadline - Date.now(); - if (remainingTime <= 0) { - throw timeoutError; - } - + while (!controller.signal.aborted) { try { return await this.getWorkspaceBuildByNumber( stopBuild.workspace_owner_name, @@ -1344,22 +1331,20 @@ class ApiMethods { ); } catch (error) { if (controller.signal.aborted) { - throw timeoutError; + break; } if (!isAxiosError(error) || error.response?.status !== 404) { throw error; } } - - const delayTime = Math.min(1000, deadline - Date.now()); - if (delayTime <= 0) { - throw timeoutError; - } - await delay(delayTime); + await delay(1000); } } finally { clearTimeout(timeoutId); } + throw new Error( + "The workspace stopped but the follow-up start build was not created.", + ); }; postWorkspaceBuild = async ( diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 360995df035e6..8c7b1db0363ac 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -26,7 +26,6 @@ import { MockWorkspace, MockWorkspaceBuild, MockWorkspaceBuildDelete, - MockWorkspaceBuildStop, } from "#/testHelpers/entities"; import { type RenderWithAuthOptions, @@ -196,16 +195,9 @@ describe("WorkspacePage", () => { }); it("requests an orchestrated restart when the user presses Restart", async () => { - const childBuild = { - ...MockWorkspaceBuild, - build_number: MockWorkspaceBuildStop.build_number + 1, - }; - const postWorkspaceBuild = vi - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValueOnce(MockWorkspaceBuildStop); - vi.spyOn(API, "waitForBuild").mockResolvedValue(MockWorkspaceBuild.job); - vi.spyOn(API, "getWorkspaceBuildByNumber").mockResolvedValue(childBuild); - const startWorkspace = vi.spyOn(API, "startWorkspace"); + const restartWorkspaceMock = vi + .spyOn(API, "restartWorkspace") + .mockResolvedValueOnce(undefined); await renderWorkspacePage(MockWorkspace); @@ -215,16 +207,10 @@ describe("WorkspacePage", () => { await user.click(confirmButton); await waitFor(() => { - expect(postWorkspaceBuild).toHaveBeenCalledOnce(); + expect(restartWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ workspace: MockWorkspace }), + ); }); - expect(postWorkspaceBuild).toHaveBeenCalledWith( - MockWorkspace.id, - expect.objectContaining({ - transition: "stop", - on_success: expect.objectContaining({ transition: "start" }), - }), - ); - expect(startWorkspace).not.toHaveBeenCalled(); }); it("requests cancellation when the user presses Cancel", async () => { From e48ac6910b49acda7253e65423d2be244a42f8a2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:48:24 +0000 Subject: [PATCH 03/12] fix(site): address restart orchestration review feedback --- site/src/api/api.test.ts | 2 +- site/src/api/api.ts | 5 +- .../WorkspaceReadyPage.stories.tsx | 91 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index b21ae314a3f80..70b5945f2f16a 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -503,7 +503,7 @@ describe("api.ts", () => { const restart = API.restartWorkspace({ workspace: MockWorkspace }); - await vi.advanceTimersByTimeAsync(59_999); + await vi.advanceTimersByTimeAsync(179_999); expect(getWorkspaceBuildByNumber.mock.calls[0][3]?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(1); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7216fd7293439..c71a779bb92c7 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1318,8 +1318,11 @@ class ApiMethods { private waitForRestartBuild = async ( stopBuild: TypesGen.WorkspaceBuild, ): Promise => { + // 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(), 60_000); + const timeoutId = setTimeout(() => controller.abort(), 180_000); try { while (!controller.signal.aborted) { try { diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx new file mode 100644 index 0000000000000..974d67417dd35 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +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 meta: Meta = { + title: "pages/WorkspacePage/WorkspaceReadyPage", + component: WorkspaceReadyPage, + args: { + workspace: Mocks.MockWorkspace, + template: Mocks.MockTemplate, + permissions, + }, + parameters: { + queries: [ + { key: ["buildInfo"], data: Mocks.MockBuildInfo }, + { + key: ["portForward", Mocks.MockWorkspaceAgent.id], + data: Mocks.MockListeningPortsResponse, + }, + { + key: [ + "templateVersion", + Mocks.MockWorkspace.template_active_version_id, + ], + data: Mocks.MockTemplateVersion, + }, + ], + 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; + +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); + await waitFor(() => + expect(dialog).toHaveTextContent(/delete non-persistent data/), + ); + expect(dialog).not.toHaveTextContent(/latest active version/); + }, +}; + +export const RestartDialogOutdatedWorkspace: Story = { + args: { + workspace: Mocks.MockRunningOutdatedWorkspace, + }, + play: async ({ canvasElement }) => { + const dialog = await openRestartDialog(canvasElement); + await waitFor(() => + expect(dialog).toHaveTextContent( + /This workspace will start using the template's latest active version/, + ), + ); + }, +}; From a2d7cb23f21e6d407ad74284841c9f5af624fbaa Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:57:21 +0000 Subject: [PATCH 04/12] fix(site): warn about template updates in schedule restart dialog --- .../WorkspaceReadyPage.stories.tsx | 13 +++++---- .../WorkspaceSchedulePage.stories.tsx | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx index 974d67417dd35..f5ad7350175f1 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx @@ -1,6 +1,9 @@ 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 * as Mocks from "#/testHelpers/entities"; import { withAuthProvider, @@ -28,16 +31,14 @@ const meta: Meta = { }, parameters: { queries: [ - { key: ["buildInfo"], data: Mocks.MockBuildInfo }, + { key: buildInfoKey, data: Mocks.MockBuildInfo }, { - key: ["portForward", Mocks.MockWorkspaceAgent.id], + key: agentListeningPorts(Mocks.MockWorkspaceAgent.id).queryKey, data: Mocks.MockListeningPortsResponse, }, { - key: [ - "templateVersion", - Mocks.MockWorkspace.template_active_version_id, - ], + key: templateVersion(Mocks.MockWorkspace.template_active_version_id) + .queryKey, data: Mocks.MockTemplateVersion, }, ], diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx index 1af29059047d0..e307916f8476f 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx @@ -149,6 +149,35 @@ export const ChangingAutostopValueShowsRestartDialog: Story = { }, }; +const outdatedWorkspace: Workspace = { ...MockWorkspace, outdated: true }; + +export const RestartDialogWarnsAboutTemplateUpdate: Story = { + parameters: { + reactRouter: workspaceRouterParameters(outdatedWorkspace), + queries: workspaceQueries(outdatedWorkspace), + }, + beforeEach: () => { + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + outdatedWorkspace, + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + const ttlInput = await canvas.findByLabelText( + "Time until shutdown (hours)", + ); + await user.clear(ttlInput); + await user.type(ttlInput, "4"); + await user.click(await canvas.findByRole("button", { name: /save/i })); + await body.findByText("Restart workspace?"); + await body.findByText( + /Restarting now will also update the workspace to the template's latest active version/, + ); + }, +}; + export const DisablingAutostopSkipsRestartDialog: Story = { parameters: { reactRouter: workspaceRouterParameters(MockWorkspace), From f6addc443d35c07ae307b520932d1e37be199961 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:59:29 +0000 Subject: [PATCH 05/12] fix(site): warn about template updates in schedule restart dialog --- .../WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 91fda3f5562ea..40d0ed87fbfc3 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -164,7 +164,11 @@ const WorkspaceSchedulePage: FC = () => { Date: Tue, 11 Aug 2026 18:09:00 +0000 Subject: [PATCH 06/12] fix(site): explain manual recovery when restart start build never appears --- site/src/api/api.test.ts | 2 +- site/src/api/api.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 70b5945f2f16a..a7b41f9275702 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -508,7 +508,7 @@ describe("api.ts", () => { await vi.advanceTimersByTimeAsync(1); await expect(restart).rejects.toThrow( - "The workspace stopped but the follow-up start build was not created.", + "The workspace stopped, but the server did not start it again.", ); expect(getWorkspaceBuildByNumber.mock.calls[0][3]?.aborted).toBe(true); }); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c71a779bb92c7..2b39543af920c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1346,7 +1346,9 @@ class ApiMethods { clearTimeout(timeoutId); } throw new Error( - "The workspace stopped but the follow-up start build was not created.", + "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.", ); }; From ce1bf50f4f804f6bf6605f5015989d389b367291 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:16:45 +0000 Subject: [PATCH 07/12] fix(site): warn unconditionally that restart uses the active template version --- .../WorkspacePage/WorkspaceReadyPage.stories.tsx | 6 ++++-- site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx | 6 +++--- .../WorkspaceSchedulePage.stories.tsx | 12 ++++-------- .../WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 6 +----- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx index f5ad7350175f1..c41f07503b100 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx @@ -73,7 +73,9 @@ export const RestartDialog: Story = { await waitFor(() => expect(dialog).toHaveTextContent(/delete non-persistent data/), ); - expect(dialog).not.toHaveTextContent(/latest active version/); + expect(dialog).toHaveTextContent( + /The workspace will start using the template's active version/, + ); }, }; @@ -85,7 +87,7 @@ export const RestartDialogOutdatedWorkspace: Story = { const dialog = await openRestartDialog(canvasElement); await waitFor(() => expect(dialog).toHaveTextContent( - /This workspace will start using the template's latest active version/, + /The workspace will start using the template's active version/, ), ); }, diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index a200b7fcd9fca..18102ccd674b4 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -384,9 +384,9 @@ export const WorkspaceReadyPage: FC = ({ description={ <> Restarting your workspace will stop all running processes and{" "} - delete non-persistent data. - {workspace.outdated && - " This workspace will start using the template's latest active version."} + delete non-persistent data. The workspace will + start using the template's active version, which may include an + update. } /> diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx index e307916f8476f..435fa4f803cf8 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx @@ -149,17 +149,13 @@ export const ChangingAutostopValueShowsRestartDialog: Story = { }, }; -const outdatedWorkspace: Workspace = { ...MockWorkspace, outdated: true }; - export const RestartDialogWarnsAboutTemplateUpdate: Story = { parameters: { - reactRouter: workspaceRouterParameters(outdatedWorkspace), - queries: workspaceQueries(outdatedWorkspace), + reactRouter: workspaceRouterParameters(MockWorkspace), + queries: workspaceQueries(MockWorkspace), }, beforeEach: () => { - spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( - outdatedWorkspace, - ); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(MockWorkspace); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -173,7 +169,7 @@ export const RestartDialogWarnsAboutTemplateUpdate: Story = { await user.click(await canvas.findByRole("button", { name: /save/i })); await body.findByText("Restart workspace?"); await body.findByText( - /Restarting now will also update the workspace to the template's latest active version/, + /The restarted workspace will use the template's active version/, ); }, }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 40d0ed87fbfc3..72999f86e32ab 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -164,11 +164,7 @@ const WorkspaceSchedulePage: FC = () => { Date: Tue, 11 Aug 2026 18:23:41 +0000 Subject: [PATCH 08/12] fix(site): move restart confirm interaction coverage into Storybook --- .../WorkspacePage/WorkspacePage.test.tsx | 19 ------------------- .../WorkspaceReadyPage.stories.tsx | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 8c7b1db0363ac..1e9abadc73f77 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -194,25 +194,6 @@ describe("WorkspacePage", () => { await testButton(MockWorkspace, "Stop", stopWorkspaceMock); }); - it("requests an orchestrated restart when the user presses Restart", async () => { - const restartWorkspaceMock = vi - .spyOn(API, "restartWorkspace") - .mockResolvedValueOnce(undefined); - - await renderWorkspacePage(MockWorkspace); - - const user = userEvent.setup(); - await user.click(screen.getByTestId("workspace-restart-button")); - const confirmButton = await screen.findByTestId("confirm-button"); - await user.click(confirmButton); - - await waitFor(() => { - expect(restartWorkspaceMock).toHaveBeenCalledWith( - expect.objectContaining({ workspace: MockWorkspace }), - ); - }); - }); - it("requests cancellation when the user presses Cancel", async () => { server.use( http.get("/api/v2/users/:userId/workspace/:workspaceName", () => { diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx index c41f07503b100..2a6edaf6c3a66 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx @@ -92,3 +92,20 @@ export const RestartDialogOutdatedWorkspace: Story = { ); }, }; + +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 }), + ), + ); + }, +}; From bd72398fa27b8ef74d8e3825cc04315f64a5ab22 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:34:10 +0000 Subject: [PATCH 09/12] fix(site): derive story query fixtures from each story's workspace --- .../WorkspaceReadyPage.stories.tsx | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx index 2a6edaf6c3a66..6959bef3d3fdc 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx @@ -4,6 +4,7 @@ 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, @@ -21,6 +22,18 @@ const permissions: WorkspacePermissions = { 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 = { title: "pages/WorkspacePage/WorkspaceReadyPage", component: WorkspaceReadyPage, @@ -30,18 +43,7 @@ const meta: Meta = { permissions, }, parameters: { - queries: [ - { key: buildInfoKey, data: Mocks.MockBuildInfo }, - { - key: agentListeningPorts(Mocks.MockWorkspaceAgent.id).queryKey, - data: Mocks.MockListeningPortsResponse, - }, - { - key: templateVersion(Mocks.MockWorkspace.template_active_version_id) - .queryKey, - data: Mocks.MockTemplateVersion, - }, - ], + queries: workspaceQueries(Mocks.MockWorkspace), user: Mocks.MockUserOwner, }, decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()], @@ -83,6 +85,9 @@ export const RestartDialogOutdatedWorkspace: Story = { args: { workspace: Mocks.MockRunningOutdatedWorkspace, }, + parameters: { + queries: workspaceQueries(Mocks.MockRunningOutdatedWorkspace), + }, play: async ({ canvasElement }) => { const dialog = await openRestartDialog(canvasElement); await waitFor(() => From 568ac3ddb79f5993a80699bd55f70accbfde340a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:43:59 +0000 Subject: [PATCH 10/12] fix(site): discover restart child build via workspace ID --- site/src/api/api.test.ts | 68 +++++++++++++++++----------------------- site/src/api/api.ts | 22 +++++++------ 2 files changed, 41 insertions(+), 49 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index a7b41f9275702..d9cca3f42eef0 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -391,26 +391,20 @@ describe("api.ts", () => { const buildParameters: TypesGen.WorkspaceBuildParameter[] = [ { name: "region", value: "us-east" }, ]; - const notFoundError = { - isAxiosError: true, - response: { status: 404 }, - }; - - it("forwards an abort signal when fetching a workspace build", async () => { + it("forwards an abort signal when listing workspace builds", async () => { const controller = new AbortController(); const get = vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({ - data: childBuild, + data: [childBuild], }); - await API.getWorkspaceBuildByNumber( - childBuild.workspace_owner_name, - childBuild.workspace_name, - childBuild.build_number, + await API.getWorkspaceBuilds( + stopBuild.workspace_id, + undefined, controller.signal, ); expect(get).toHaveBeenCalledWith( - `/api/v2/users/${childBuild.workspace_owner_name}/workspace/${childBuild.workspace_name}/builds/${childBuild.build_number}`, + `/api/v2/workspaces/${stopBuild.workspace_id}/builds`, { signal: controller.signal }, ); }); @@ -422,7 +416,7 @@ describe("api.ts", () => { vi.spyOn(API, "waitForBuild") .mockResolvedValueOnce(MockProvisionerJob) .mockResolvedValueOnce(MockProvisionerJob); - vi.spyOn(API, "getWorkspaceBuildByNumber").mockResolvedValue(childBuild); + vi.spyOn(API, "getWorkspaceBuilds").mockResolvedValue([childBuild]); const startWorkspace = vi.spyOn(API, "startWorkspace"); await API.restartWorkspace({ @@ -448,14 +442,11 @@ describe("api.ts", () => { ...MockProvisionerJob, status: "canceled", }); - const getWorkspaceBuildByNumber = vi.spyOn( - API, - "getWorkspaceBuildByNumber", - ); + const getWorkspaceBuilds = vi.spyOn(API, "getWorkspaceBuilds"); await API.restartWorkspace({ workspace: MockWorkspace }); - expect(getWorkspaceBuildByNumber).not.toHaveBeenCalled(); + expect(getWorkspaceBuilds).not.toHaveBeenCalled(); }); it("waits for the server-created child build after the stop succeeds", async () => { @@ -465,20 +456,19 @@ describe("api.ts", () => { .spyOn(API, "waitForBuild") .mockResolvedValueOnce(MockProvisionerJob) .mockResolvedValueOnce(MockProvisionerJob); - const getWorkspaceBuildByNumber = vi - .spyOn(API, "getWorkspaceBuildByNumber") - .mockRejectedValueOnce(notFoundError) - .mockResolvedValue(childBuild); + const getWorkspaceBuilds = vi + .spyOn(API, "getWorkspaceBuilds") + .mockResolvedValueOnce([stopBuild]) + .mockResolvedValue([childBuild, stopBuild]); const restart = API.restartWorkspace({ workspace: MockWorkspace }); await vi.advanceTimersByTimeAsync(1000); await restart; - expect(getWorkspaceBuildByNumber).toHaveBeenCalledTimes(2); - expect(getWorkspaceBuildByNumber).toHaveBeenCalledWith( - stopBuild.workspace_owner_name, - stopBuild.workspace_name, - stopBuild.build_number + 1, + expect(getWorkspaceBuilds).toHaveBeenCalledTimes(2); + expect(getWorkspaceBuilds).toHaveBeenCalledWith( + stopBuild.workspace_id, + { limit: 5 }, expect.any(AbortSignal), ); expect(waitForBuild).toHaveBeenNthCalledWith(1, stopBuild); @@ -489,28 +479,26 @@ describe("api.ts", () => { vi.useFakeTimers(); vi.spyOn(API, "postWorkspaceBuild").mockResolvedValue(stopBuild); vi.spyOn(API, "waitForBuild").mockResolvedValue(MockProvisionerJob); - const getWorkspaceBuildByNumber = vi - .spyOn(API, "getWorkspaceBuildByNumber") - .mockImplementation( - (_username, _workspaceName, _buildNumber, signal) => { - return new Promise((_, reject) => { - signal?.addEventListener("abort", () => - reject(new Error("canceled")), - ); - }); - }, - ); + 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 }); await vi.advanceTimersByTimeAsync(179_999); - expect(getWorkspaceBuildByNumber.mock.calls[0][3]?.aborted).toBe(false); + expect(getWorkspaceBuilds.mock.calls[0][2]?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(1); await expect(restart).rejects.toThrow( "The workspace stopped, but the server did not start it again.", ); - expect(getWorkspaceBuildByNumber.mock.calls[0][3]?.aborted).toBe(true); + expect(getWorkspaceBuilds.mock.calls[0][2]?.aborted).toBe(true); }); }); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2b39543af920c..818d4553c662a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1276,11 +1276,9 @@ class ApiMethods { username: string, workspaceName: string, buildNumber: number, - signal?: AbortSignal, ): Promise => { const response = await this.axios.get( `/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`, - { signal }, ); return response.data; @@ -1318,6 +1316,7 @@ class ApiMethods { private waitForRestartBuild = async ( stopBuild: TypesGen.WorkspaceBuild, ): Promise => { + 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. @@ -1326,19 +1325,22 @@ class ApiMethods { try { while (!controller.signal.aborted) { try { - return await this.getWorkspaceBuildByNumber( - stopBuild.workspace_owner_name, - stopBuild.workspace_name, - stopBuild.build_number + 1, + 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; } - if (!isAxiosError(error) || error.response?.status !== 404) { - throw error; - } + throw error; } await delay(1000); } @@ -1830,9 +1832,11 @@ class ApiMethods { getWorkspaceBuilds = async ( workspaceId: string, req?: TypesGen.WorkspaceBuildsRequest, + signal?: AbortSignal, ) => { const response = await this.axios.get( getURLWithSearchParams(`/api/v2/workspaces/${workspaceId}/builds`, req), + { signal }, ); return response.data; From 4f6bb7c0fddb82c6bc35135b55660fed3fdeaeff Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:53:27 +0000 Subject: [PATCH 11/12] fix(site): handle restart timeout rejection before advancing timers --- site/src/api/api.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index d9cca3f42eef0..fbfcd1268f45a 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -490,14 +490,17 @@ describe("api.ts", () => { }); 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 expect(restart).rejects.toThrow( - "The workspace stopped, but the server did not start it again.", - ); + await rejection; expect(getWorkspaceBuilds.mock.calls[0][2]?.aborted).toBe(true); }); }); From f3321207d10a3db6a2482573b412212a41e1dc36 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:59:38 +0000 Subject: [PATCH 12/12] chore(site): drop redundant waitFor around synchronous dialog assertions --- .../pages/WorkspacePage/WorkspaceReadyPage.stories.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx index 6959bef3d3fdc..d297f24a467a9 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.stories.tsx @@ -72,9 +72,7 @@ const openRestartDialog = async (canvasElement: HTMLElement) => { export const RestartDialog: Story = { play: async ({ canvasElement }) => { const dialog = await openRestartDialog(canvasElement); - await waitFor(() => - expect(dialog).toHaveTextContent(/delete non-persistent data/), - ); + expect(dialog).toHaveTextContent(/delete non-persistent data/); expect(dialog).toHaveTextContent( /The workspace will start using the template's active version/, ); @@ -90,10 +88,8 @@ export const RestartDialogOutdatedWorkspace: Story = { }, play: async ({ canvasElement }) => { const dialog = await openRestartDialog(canvasElement); - await waitFor(() => - expect(dialog).toHaveTextContent( - /The workspace will start using the template's active version/, - ), + expect(dialog).toHaveTextContent( + /The workspace will start using the template's active version/, ); }, };