fix: display explicit 'retry' button(s) when a workspace fails (#10720)

* refactor: remove workspace error enums

* fix: add in retry button for failed workspaces

* fix: make handleBuildRetry auto-detect debug permissions

* chore: consolidate retry messaging

* chore: update renderWorkspacePage to accept parameters

* chore: make workspace test helpers take explicit workspace parameter

* refactor: update how parameters for tests are defined

* fix: update old tests to be correctly parameterized
This commit is contained in:
Michael Smith
2023-11-22 16:03:09 -05:00
committed by GitHub
parent 65c726eb50
commit 491e0e3abf
7 changed files with 371 additions and 228 deletions
@@ -3,7 +3,7 @@ import { Meta, StoryObj } from "@storybook/react";
import { WatchAgentMetadataContext } from "components/Resources/AgentMetadata";
import { ProvisionerJobLog } from "api/typesGenerated";
import * as Mocks from "testHelpers/entities";
import { Workspace, WorkspaceErrors } from "./Workspace";
import { Workspace } from "./Workspace";
import { withReactContext } from "storybook-react-context";
import EventSource from "eventsourcemock";
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
@@ -133,7 +133,7 @@ export const Failed: Story = {
...Running.args,
workspace: Mocks.MockFailedWorkspace,
workspaceErrors: {
[WorkspaceErrors.BUILD_ERROR]: Mocks.mockApiError({
buildError: Mocks.mockApiError({
message: "A workspace build is already active.",
}),
},
@@ -172,7 +172,6 @@ export const FailedWithRetry: Story = {
},
},
},
canRetryDebugMode: true,
buildLogs: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
},
};
@@ -224,7 +223,7 @@ export const GetBuildsError: Story = {
args: {
...Running.args,
workspaceErrors: {
[WorkspaceErrors.GET_BUILDS_ERROR]: Mocks.mockApiError({
getBuildsError: Mocks.mockApiError({
message: "There is a problem fetching builds.",
}),
},
@@ -235,7 +234,7 @@ export const CancellationError: Story = {
args: {
...Failed.args,
workspaceErrors: {
[WorkspaceErrors.CANCELLATION_ERROR]: Mocks.mockApiError({
cancellationError: Mocks.mockApiError({
message: "Job could not be canceled.",
}),
},
+45 -48
View File
@@ -29,11 +29,13 @@ import { BuildsTable } from "./BuildsTable";
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
import { WorkspaceStats } from "./WorkspaceStats";
export enum WorkspaceErrors {
GET_BUILDS_ERROR = "getBuildsError",
BUILD_ERROR = "buildError",
CANCELLATION_ERROR = "cancellationError",
}
export type WorkspaceError =
| "getBuildsError"
| "buildError"
| "cancellationError";
export type WorkspaceErrors = Partial<Record<WorkspaceError, unknown>>;
export interface WorkspaceProps {
scheduleProps: {
onDeadlinePlus: (hours: number) => void;
@@ -56,16 +58,17 @@ export interface WorkspaceProps {
resources?: TypesGen.WorkspaceResource[];
canUpdateWorkspace: boolean;
updateMessage?: string;
canRetryDebugMode: boolean;
canChangeVersions: boolean;
hideSSHButton?: boolean;
hideVSCodeDesktopButton?: boolean;
workspaceErrors: Partial<Record<WorkspaceErrors, unknown>>;
workspaceErrors: WorkspaceErrors;
buildInfo?: TypesGen.BuildInfoResponse;
sshPrefix?: string;
template?: TypesGen.Template;
quotaBudget?: number;
canRetryDebugMode: boolean;
handleBuildRetry: () => void;
handleBuildRetryDebug: () => void;
buildLogs?: React.ReactNode;
builds: TypesGen.WorkspaceBuild[] | undefined;
onLoadMoreBuilds: () => void;
@@ -87,7 +90,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleCancel,
handleSettings,
handleChangeVersion,
handleDormantActivate: handleDormantActivate,
handleDormantActivate,
workspace,
isUpdating,
isRestarting,
@@ -95,7 +98,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
builds,
canUpdateWorkspace,
updateMessage,
canRetryDebugMode,
canChangeVersions,
workspaceErrors,
hideSSHButton,
@@ -103,7 +105,9 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
buildInfo,
sshPrefix,
template,
canRetryDebugMode,
handleBuildRetry,
handleBuildRetryDebug,
buildLogs,
onLoadMoreBuilds,
isLoadingMoreBuilds,
@@ -111,31 +115,14 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
canAutostart,
}) => {
const navigate = useNavigate();
const serverVersion = buildInfo?.version || "";
const { saveLocal, getLocal } = useLocalStorage();
const buildError = Boolean(workspaceErrors[WorkspaceErrors.BUILD_ERROR]) && (
<ErrorAlert
error={workspaceErrors[WorkspaceErrors.BUILD_ERROR]}
dismissible
/>
);
const cancellationError = Boolean(
workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR],
) && (
<ErrorAlert
error={workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR]}
dismissible
/>
);
let transitionStats: TypesGen.TransitionStats | undefined = undefined;
if (template !== undefined) {
transitionStats = ActiveTransition(template, workspace);
}
const [showAlertPendingInQueue, setShowAlertPendingInQueue] = useState(false);
// 2023-11-15 - MES - This effect will be called every single render because
// "now" will always change and invalidate the dependency array. Need to
// figure out if this effect really should run every render (possibly meaning
// no dependency array at all), or how to get the array stabilized (ideal)
const now = dayjs();
useEffect(() => {
if (
@@ -174,6 +161,9 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
const autoStartFailing = workspace.autostart_schedule && !canAutostart;
const requiresManualUpdate = updateRequired && autoStartFailing;
const transitionStats =
template !== undefined ? ActiveTransition(template, workspace) : undefined;
return (
<>
<FullWidthPageHeader>
@@ -213,8 +203,11 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleSettings={handleSettings}
handleRetry={handleBuildRetry}
handleRetryDebug={handleBuildRetryDebug}
handleChangeVersion={handleChangeVersion}
handleDormantActivate={handleDormantActivate}
canRetryDebug={canRetryDebugMode}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
@@ -244,8 +237,15 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
{updateMessage && <AlertDetail>{updateMessage}</AlertDetail>}
</Alert>
))}
{buildError}
{cancellationError}
{Boolean(workspaceErrors.buildError) && (
<ErrorAlert error={workspaceErrors.buildError} dismissible />
)}
{Boolean(workspaceErrors.cancellationError) && (
<ErrorAlert error={workspaceErrors.cancellationError} dismissible />
)}
{workspace.latest_build.status === "running" &&
!workspace.health.healthy && (
<Alert
@@ -311,16 +311,15 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
<Alert
severity="error"
actions={
canRetryDebugMode && (
<Button
key={0}
onClick={handleBuildRetry}
variant="text"
size="small"
>
Try in debug mode
</Button>
)
<Button
onClick={
canRetryDebugMode ? handleBuildRetryDebug : handleBuildRetry
}
variant="text"
size="small"
>
Retry{canRetryDebugMode && " in debug mode"}
</Button>
}
>
<AlertTitle>Workspace build failed</AlertTitle>
@@ -357,17 +356,15 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
showBuiltinApps={canUpdateWorkspace}
hideSSHButton={hideSSHButton}
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
serverVersion={serverVersion}
serverVersion={buildInfo?.version || ""}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
)}
/>
)}
{workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? (
<ErrorAlert
error={workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR]}
/>
{workspaceErrors.getBuildsError ? (
<ErrorAlert error={workspaceErrors.getBuildsError} />
) : (
<BuildsTable
builds={builds}
@@ -1,26 +1,29 @@
import { type FC } from "react";
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { BuildParametersPopover } from "./BuildParametersPopover";
import Tooltip from "@mui/material/Tooltip";
import Button from "@mui/material/Button";
import BlockIcon from "@mui/icons-material/Block";
import LoadingButton from "@mui/lab/LoadingButton";
import ButtonGroup from "@mui/material/ButtonGroup";
import CloudQueueIcon from "@mui/icons-material/CloudQueue";
import CropSquareIcon from "@mui/icons-material/CropSquare";
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
import ReplayIcon from "@mui/icons-material/Replay";
import { FC } from "react";
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import ButtonGroup from "@mui/material/ButtonGroup";
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { BuildParametersPopover } from "./BuildParametersPopover";
import BlockIcon from "@mui/icons-material/Block";
import OutlinedBlockIcon from "@mui/icons-material/BlockOutlined";
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
import LoadingButton from "@mui/lab/LoadingButton";
import Tooltip from "@mui/material/Tooltip";
import RetryIcon from "@mui/icons-material/BuildOutlined";
import RetryDebugIcon from "@mui/icons-material/BugReportOutlined";
interface WorkspaceAction {
interface WorkspaceActionProps {
loading?: boolean;
handleAction: () => void;
disabled?: boolean;
tooltipText?: string;
}
export const UpdateButton: FC<WorkspaceAction> = ({
export const UpdateButton: FC<WorkspaceActionProps> = ({
handleAction,
loading,
}) => {
@@ -37,7 +40,7 @@ export const UpdateButton: FC<WorkspaceAction> = ({
);
};
export const ActivateButton: FC<WorkspaceAction> = ({
export const ActivateButton: FC<WorkspaceActionProps> = ({
handleAction,
loading,
}) => {
@@ -54,7 +57,7 @@ export const ActivateButton: FC<WorkspaceAction> = ({
};
export const StartButton: FC<
Omit<WorkspaceAction, "handleAction"> & {
Omit<WorkspaceActionProps, "handleAction"> & {
workspace: Workspace;
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void;
}
@@ -63,7 +66,7 @@ export const StartButton: FC<
<ButtonGroup
variant="outlined"
sx={{
// Workaround to make the border transitions smmothly on button groups
// Workaround to make the border transitions smoothly on button groups
"& > button:hover + button": {
borderLeft: "1px solid #FFF",
},
@@ -94,7 +97,10 @@ export const StartButton: FC<
);
};
export const StopButton: FC<WorkspaceAction> = ({ handleAction, loading }) => {
export const StopButton: FC<WorkspaceActionProps> = ({
handleAction,
loading,
}) => {
return (
<LoadingButton
loading={loading}
@@ -109,7 +115,7 @@ export const StopButton: FC<WorkspaceAction> = ({ handleAction, loading }) => {
};
export const RestartButton: FC<
Omit<WorkspaceAction, "handleAction"> & {
Omit<WorkspaceActionProps, "handleAction"> & {
workspace: Workspace;
handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void;
}
@@ -118,7 +124,7 @@ export const RestartButton: FC<
<ButtonGroup
variant="outlined"
sx={{
// Workaround to make the border transitions smmothly on button groups
// Workaround to make the border transitions smoothly on button groups
"& > button:hover + button": {
borderLeft: "1px solid #FFF",
},
@@ -150,7 +156,7 @@ export const RestartButton: FC<
);
};
export const CancelButton: FC<WorkspaceAction> = ({ handleAction }) => {
export const CancelButton: FC<WorkspaceActionProps> = ({ handleAction }) => {
return (
<Button startIcon={<BlockIcon />} onClick={handleAction}>
Cancel
@@ -164,7 +170,7 @@ interface DisabledProps {
export const DisabledButton: FC<DisabledProps> = ({ label }) => {
return (
<Button startIcon={<BlockOutlined />} disabled>
<Button startIcon={<OutlinedBlockIcon />} disabled>
{label}
</Button>
);
@@ -181,3 +187,21 @@ export const ActionLoadingButton: FC<LoadingProps> = ({ label }) => {
</LoadingButton>
);
};
type DebugButtonProps = Omit<WorkspaceActionProps, "loading"> & {
debug?: boolean;
};
export const RetryButton = ({
handleAction,
debug = false,
}: DebugButtonProps) => {
return (
<Button
startIcon={debug ? <RetryDebugIcon /> : <RetryIcon />}
onClick={handleAction}
>
Retry{debug && " (Debug)"}
</Button>
);
};
@@ -1,6 +1,10 @@
import { FC, Fragment, ReactNode } from "react";
import { type FC, type ReactNode, Fragment } from "react";
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication";
import { workspaceUpdatePolicy } from "utils/workspace";
import { type ButtonType, actionsByWorkspaceStatus } from "./constants";
import {
ActionLoadingButton,
CancelButton,
@@ -10,12 +14,8 @@ import {
RestartButton,
UpdateButton,
ActivateButton,
RetryButton,
} from "./Buttons";
import {
ButtonMapping,
ButtonTypesEnum,
actionsByWorkspaceStatus,
} from "./constants";
import Divider from "@mui/material/Divider";
import DuplicateIcon from "@mui/icons-material/FileCopyOutlined";
@@ -30,7 +30,6 @@ import {
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { workspaceUpdatePolicy } from "utils/workspace";
export interface WorkspaceActionsProps {
workspace: Workspace;
@@ -42,11 +41,14 @@ export interface WorkspaceActionsProps {
handleCancel: () => void;
handleSettings: () => void;
handleChangeVersion: () => void;
handleRetry: () => void;
handleRetryDebug: () => void;
handleDormantActivate: () => void;
isUpdating: boolean;
isRestarting: boolean;
children?: ReactNode;
canChangeVersions: boolean;
canRetryDebug: boolean;
}
export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
@@ -58,92 +60,78 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleUpdate,
handleCancel,
handleSettings,
handleRetry,
handleRetryDebug,
handleChangeVersion,
handleDormantActivate: handleDormantActivate,
handleDormantActivate,
isUpdating,
isRestarting,
canChangeVersions,
canRetryDebug,
}) => {
const {
canCancel,
canAcceptJobs,
actions: actionsByStatus,
} = actionsByWorkspaceStatus(workspace, workspace.latest_build.status);
const canBeUpdated = workspace.outdated && canAcceptJobs;
const { duplicateWorkspace, isDuplicationReady } =
useWorkspaceDuplication(workspace);
const disabled =
const { actions, canCancel, canAcceptJobs } = actionsByWorkspaceStatus(
workspace,
canRetryDebug,
);
const mustUpdate =
workspaceUpdatePolicy(workspace, canChangeVersions) === "always" &&
workspace.outdated;
const tooltipText = ((): string => {
if (!disabled) {
return "";
}
if (workspace.template_require_active_version) {
return "This template requires automatic updates";
}
if (workspace.automatic_updates === "always") {
return "You have enabled automatic updates for this workspace";
}
return "";
})();
const tooltipText = getTooltipText(workspace, mustUpdate);
const canBeUpdated = workspace.outdated && canAcceptJobs;
// A mapping of button type to the corresponding React component
const buttonMapping: ButtonMapping = {
[ButtonTypesEnum.update]: <UpdateButton handleAction={handleUpdate} />,
[ButtonTypesEnum.updating]: (
<UpdateButton loading handleAction={handleUpdate} />
),
[ButtonTypesEnum.start]: (
const buttonMapping: Record<ButtonType, ReactNode> = {
update: <UpdateButton handleAction={handleUpdate} />,
updating: <UpdateButton loading handleAction={handleUpdate} />,
start: (
<StartButton
workspace={workspace}
handleAction={handleStart}
disabled={disabled}
disabled={mustUpdate}
tooltipText={tooltipText}
/>
),
[ButtonTypesEnum.starting]: (
starting: (
<StartButton
loading
workspace={workspace}
handleAction={handleStart}
disabled={disabled}
disabled={mustUpdate}
tooltipText={tooltipText}
/>
),
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
[ButtonTypesEnum.stopping]: (
<StopButton loading handleAction={handleStop} />
),
[ButtonTypesEnum.restart]: (
stop: <StopButton handleAction={handleStop} />,
stopping: <StopButton loading handleAction={handleStop} />,
restart: (
<RestartButton
workspace={workspace}
handleAction={handleRestart}
disabled={disabled}
disabled={mustUpdate}
tooltipText={tooltipText}
/>
),
[ButtonTypesEnum.restarting]: (
restarting: (
<RestartButton
loading
workspace={workspace}
handleAction={handleRestart}
disabled={disabled}
disabled={mustUpdate}
tooltipText={tooltipText}
/>
),
[ButtonTypesEnum.deleting]: <ActionLoadingButton label="Deleting" />,
[ButtonTypesEnum.canceling]: <DisabledButton label="Canceling..." />,
[ButtonTypesEnum.deleted]: <DisabledButton label="Deleted" />,
[ButtonTypesEnum.pending]: <ActionLoadingButton label="Pending..." />,
[ButtonTypesEnum.activate]: (
<ActivateButton handleAction={handleDormantActivate} />
),
[ButtonTypesEnum.activating]: (
<ActivateButton loading handleAction={handleDormantActivate} />
),
deleting: <ActionLoadingButton label="Deleting" />,
canceling: <DisabledButton label="Canceling..." />,
deleted: <DisabledButton label="Deleted" />,
pending: <ActionLoadingButton label="Pending..." />,
activate: <ActivateButton handleAction={handleDormantActivate} />,
activating: <ActivateButton loading handleAction={handleDormantActivate} />,
retry: <RetryButton handleAction={handleRetry} />,
retryDebug: <RetryButton debug handleAction={handleRetryDebug} />,
};
return (
@@ -151,19 +139,18 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
css={{ display: "flex", alignItems: "center", gap: 12 }}
data-testid="workspace-actions"
>
{canBeUpdated &&
(isUpdating
? buttonMapping[ButtonTypesEnum.updating]
: buttonMapping[ButtonTypesEnum.update])}
{canBeUpdated && (
<>{isUpdating ? buttonMapping.updating : buttonMapping.update}</>
)}
{isRestarting && buttonMapping[ButtonTypesEnum.restarting]}
{!isRestarting &&
actionsByStatus.map((action) => (
<Fragment key={action}>{buttonMapping[action]}</Fragment>
))}
{isRestarting
? buttonMapping.restarting
: actions.map((action) => (
<Fragment key={action}>{buttonMapping[action]}</Fragment>
))}
{canCancel && <CancelButton handleAction={handleCancel} />}
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton
@@ -211,3 +198,19 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
</div>
);
};
function getTooltipText(workspace: Workspace, disabled: boolean): string {
if (!disabled) {
return "";
}
if (workspace.template_require_active_version) {
return "This template requires automatic updates";
}
if (workspace.automatic_updates === "always") {
return "You have enabled automatic updates for this workspace";
}
return "";
}
@@ -1,101 +1,119 @@
import { Workspace, WorkspaceStatus } from "api/typesGenerated";
import { ReactNode } from "react";
import { type Workspace, type WorkspaceStatus } from "api/typesGenerated";
// the button types we have
export enum ButtonTypesEnum {
start = "start",
starting = "starting",
stop = "stop",
stopping = "stopping",
restart = "restart",
restarting = "restarting",
deleting = "deleting",
update = "update",
updating = "updating",
activate = "activate",
activating = "activating",
// disabled buttons
canceling = "canceling",
deleted = "deleted",
pending = "pending",
}
/**
* An iterable of all button types supported by the workspace actions UI
*/
export const buttonTypes = [
"start",
"starting",
"stop",
"stopping",
"restart",
"restarting",
"deleting",
"update",
"updating",
"activate",
"activating",
export type ButtonMapping = {
[key in ButtonTypesEnum]: ReactNode;
};
// There's no need for a retrying state because retrying starts a transition
// into one of the starting, stopping, or deleting states (based on the
// WorkspaceTransition type)
"retry",
"retryDebug",
interface WorkspaceAbilities {
actions: ButtonTypesEnum[];
// These are buttons that should be used with disabled UI elements
"canceling",
"deleted",
"pending",
] as const;
/**
* A button type supported by the workspace actions UI
*/
export type ButtonType = (typeof buttonTypes)[number];
type WorkspaceAbilities = {
actions: readonly ButtonType[];
canCancel: boolean;
canAcceptJobs: boolean;
}
};
export const actionsByWorkspaceStatus = (
workspace: Workspace,
status: WorkspaceStatus,
canRetryDebug: boolean,
): WorkspaceAbilities => {
if (workspace.dormant_at) {
return {
actions: [ButtonTypesEnum.activate],
actions: ["activate"],
canCancel: false,
canAcceptJobs: false,
};
}
const status = workspace.latest_build.status;
if (status === "failed" && canRetryDebug) {
return {
...statusToActions.failed,
actions: ["retry", "retryDebug"],
};
}
return statusToActions[status];
};
const statusToActions: Record<WorkspaceStatus, WorkspaceAbilities> = {
starting: {
actions: [ButtonTypesEnum.starting],
actions: ["starting"],
canCancel: true,
canAcceptJobs: false,
},
running: {
actions: [ButtonTypesEnum.stop, ButtonTypesEnum.restart],
actions: ["stop", "restart"],
canCancel: false,
canAcceptJobs: true,
},
stopping: {
actions: [ButtonTypesEnum.stopping],
actions: ["stopping"],
canCancel: true,
canAcceptJobs: false,
},
stopped: {
actions: [ButtonTypesEnum.start],
actions: ["start"],
canCancel: false,
canAcceptJobs: true,
},
canceled: {
actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop],
actions: ["start", "stop"],
canCancel: false,
canAcceptJobs: true,
},
// in the case of an error
failed: {
actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop],
actions: ["retry"],
canCancel: false,
canAcceptJobs: true,
},
/**
* disabled states
*/
// Disabled states
canceling: {
actions: [ButtonTypesEnum.canceling],
actions: ["canceling"],
canCancel: false,
canAcceptJobs: false,
},
deleting: {
actions: [ButtonTypesEnum.deleting],
actions: ["deleting"],
canCancel: true,
canAcceptJobs: false,
},
deleted: {
actions: [ButtonTypesEnum.deleted],
actions: ["deleted"],
canCancel: false,
canAcceptJobs: false,
},
pending: {
actions: [ButtonTypesEnum.pending],
actions: ["pending"],
canCancel: false,
canAcceptJobs: false,
},
@@ -1,3 +1,4 @@
import { type Workspace } from "api/typesGenerated";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import EventSourceMock from "eventsourcemock";
@@ -5,6 +6,7 @@ import { rest } from "msw";
import {
MockTemplate,
MockWorkspace,
MockFailedWorkspace,
MockWorkspaceBuild,
MockStoppedWorkspace,
MockStartingWorkspace,
@@ -21,8 +23,9 @@ import { renderWithAuth } from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { WorkspacePage } from "./WorkspacePage";
// It renders the workspace page and waits for it be loaded
const renderWorkspacePage = async () => {
// 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
@@ -34,24 +37,33 @@ const renderWorkspacePage = async () => {
options.onDone && options.onDone();
return new WebSocket("");
});
renderWithAuth(<WorkspacePage />, {
route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`,
route: `/@${workspace.owner_name}/${workspace.name}`,
path: "/:username/:workspace",
});
await screen.findByText(MockWorkspace.name);
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.
* 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 (label: string, actionMock: jest.SpyInstance) => {
const user = userEvent.setup();
await renderWorkspacePage();
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: label });
const button = within(workspaceActions).getByRole("button", { name });
const user = userEvent.setup();
await user.click(button);
expect(actionMock).toBeCalled();
};
@@ -78,7 +90,7 @@ describe("WorkspacePage", () => {
const deleteWorkspaceMock = jest
.spyOn(api, "deleteWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
await renderWorkspacePage();
await renderWorkspacePage(MockWorkspace);
// open the workspace action popover so we have access to all available ctas
const trigger = screen.getByTestId("workspace-options-button");
@@ -121,7 +133,7 @@ describe("WorkspacePage", () => {
const deleteWorkspaceMock = jest
.spyOn(api, "deleteWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuildDelete);
await renderWorkspacePage();
await renderWorkspacePage(MockWorkspace);
// open the workspace action popover so we have access to all available ctas
const trigger = screen.getByTestId("workspace-options-button");
@@ -166,10 +178,12 @@ describe("WorkspacePage", () => {
},
),
);
const startWorkspaceMock = jest
.spyOn(api, "startWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild));
await testButton("Start", startWorkspaceMock);
await testButton(MockStoppedWorkspace, "Start", startWorkspaceMock);
});
it("requests a stop job when the user presses Stop", async () => {
@@ -177,7 +191,7 @@ describe("WorkspacePage", () => {
.spyOn(api, "stopWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild);
await testButton("Stop", stopWorkspaceMock);
await testButton(MockWorkspace, "Stop", stopWorkspaceMock);
});
it("requests a stop when the user presses Restart", async () => {
@@ -186,7 +200,7 @@ describe("WorkspacePage", () => {
.mockResolvedValueOnce(MockWorkspaceBuild);
// Render
await renderWorkspacePage();
await renderWorkspacePage(MockWorkspace);
// Actions
const user = userEvent.setup();
@@ -209,20 +223,12 @@ describe("WorkspacePage", () => {
},
),
);
const cancelWorkspaceMock = jest
.spyOn(api, "cancelWorkspaceBuild")
.mockImplementation(() => Promise.resolve({ message: "job canceled" }));
await renderWorkspacePage();
const workspaceActions = screen.getByTestId("workspace-actions");
const cancelButton = within(workspaceActions).getByRole("button", {
name: "Cancel",
});
await userEvent.click(cancelButton);
expect(cancelWorkspaceMock).toBeCalled();
await testButton(MockStartingWorkspace, "Cancel", cancelWorkspaceMock);
});
it("requests an update when the user presses Update", async () => {
@@ -236,7 +242,7 @@ describe("WorkspacePage", () => {
.mockResolvedValueOnce(MockWorkspaceBuild);
// Render
await renderWorkspacePage();
await renderWorkspacePage(MockWorkspace);
// Actions
const user = userEvent.setup();
@@ -265,7 +271,7 @@ describe("WorkspacePage", () => {
);
// Render
await renderWorkspacePage();
await renderWorkspacePage(MockWorkspace);
// Actions
const user = userEvent.setup();
@@ -312,7 +318,7 @@ describe("WorkspacePage", () => {
});
it("shows the timeline build", async () => {
await renderWorkspacePage();
await renderWorkspacePage(MockWorkspace);
const table = await screen.findByTestId("builds-table");
// Wait for the results to be loaded
@@ -339,7 +345,7 @@ describe("WorkspacePage", () => {
});
const restartWorkspaceSpy = jest.spyOn(api, "restartWorkspace");
const user = userEvent.setup();
await renderWorkspacePage();
await renderWorkspacePage(MockWorkspace);
await user.click(screen.getByTestId("build-parameters-button"));
const buildParametersForm = await screen.findByTestId(
"build-parameters-form",
@@ -358,4 +364,91 @@ describe("WorkspacePage", () => {
});
});
});
// 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 = /^Retry \(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",
});
});
});
});
});
@@ -9,7 +9,7 @@ import {
getMaxDeadlineChange,
getMinDeadline,
} from "utils/schedule";
import { Workspace, WorkspaceErrors } from "./Workspace";
import { Workspace } from "./Workspace";
import { pageTitle } from "utils/page";
import { hasJobError } from "utils/workspace";
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
@@ -79,9 +79,6 @@ export const WorkspaceReadyPage = ({
...deploymentConfig(),
enabled: permissions?.viewDeploymentValues,
});
const canRetryDebugMode = Boolean(
deploymentValues?.config.enable_terraform_debug_mode,
);
// Build logs
const buildLogs = useWorkspaceBuildLogs(workspace.latest_build.id);
@@ -196,6 +193,22 @@ export const WorkspaceReadyPage = ({
// Cancel build
const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient));
const handleBuildRetry = (debug = false) => {
const logLevel = debug ? "debug" : undefined;
switch (workspace.latest_build.transition) {
case "start":
startWorkspaceMutation.mutate({ logLevel });
break;
case "stop":
stopWorkspaceMutation.mutate({ logLevel });
break;
case "delete":
deleteWorkspaceMutation.mutate({ logLevel });
break;
}
};
return (
<>
<Helmet>
@@ -242,19 +255,11 @@ export const WorkspaceReadyPage = ({
}}
handleCancel={cancelBuildMutation.mutate}
handleSettings={() => navigate("settings")}
handleBuildRetry={() => {
switch (workspace.latest_build.transition) {
case "start":
startWorkspaceMutation.mutate({ logLevel: "debug" });
break;
case "stop":
stopWorkspaceMutation.mutate({ logLevel: "debug" });
break;
case "delete":
deleteWorkspaceMutation.mutate({ logLevel: "debug" });
break;
}
}}
handleBuildRetry={() => handleBuildRetry(false)}
handleBuildRetryDebug={() => handleBuildRetry(true)}
canRetryDebugMode={
deploymentValues?.config.enable_terraform_debug_mode ?? false
}
handleChangeVersion={() => {
setChangeVersionDialogOpen(true);
}}
@@ -273,19 +278,18 @@ export const WorkspaceReadyPage = ({
hasMoreBuilds={hasMoreBuilds}
canUpdateWorkspace={canUpdateWorkspace}
updateMessage={latestVersion?.message}
canRetryDebugMode={canRetryDebugMode}
canChangeVersions={canChangeVersions}
hideSSHButton={featureVisibility["browser_only"]}
hideVSCodeDesktopButton={featureVisibility["browser_only"]}
workspaceErrors={{
[WorkspaceErrors.GET_BUILDS_ERROR]: buildsError,
[WorkspaceErrors.BUILD_ERROR]:
getBuildsError: buildsError,
buildError:
restartBuildError ??
startWorkspaceMutation.error ??
stopWorkspaceMutation.error ??
deleteWorkspaceMutation.error ??
updateWorkspaceMutation.error,
[WorkspaceErrors.CANCELLATION_ERROR]: cancelBuildMutation.error,
cancellationError: cancelBuildMutation.error,
}}
buildInfo={buildInfo}
sshPrefix={sshPrefixQuery.data?.hostname_prefix}
@@ -297,6 +301,7 @@ export const WorkspaceReadyPage = ({
}
canAutostart={canAutostart}
/>
<WorkspaceDeleteDialog
workspace={workspace}
canUpdateTemplate={canDeleteWorkspace}
@@ -310,6 +315,7 @@ export const WorkspaceReadyPage = ({
}}
workspaceBuildDateStr={dayjs(workspace.created_at).fromNow()}
/>
<UpdateBuildParametersDialog
missedParameters={
changeVersionMutation.error instanceof MissingBuildParameters
@@ -329,6 +335,7 @@ export const WorkspaceReadyPage = ({
}
}}
/>
<UpdateBuildParametersDialog
missedParameters={
updateWorkspaceMutation.error instanceof MissingBuildParameters
@@ -345,6 +352,7 @@ export const WorkspaceReadyPage = ({
}
}}
/>
<ChangeVersionDialog
templateVersions={allVersions?.reverse()}
template={template}
@@ -360,6 +368,7 @@ export const WorkspaceReadyPage = ({
changeVersionMutation.mutate({ versionId: templateVersion.id });
}}
/>
<WarningDialog
open={isConfirmingUpdate}
onConfirm={() => {