Skip to content

Commit 668ce42

Browse files
committed
test: consolidate workspace update fixtures and scenarios
1 parent ea2de58 commit 668ce42

7 files changed

Lines changed: 309 additions & 431 deletions

File tree

‎test/unit/api/workspace.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ describe("updateWorkspace", () => {
300300
};
301301
ctx.workspace = source;
302302
restClient.getWorkspace.mockResolvedValue(source);
303-
const accepted = {
303+
const accepted: WorkspaceBuild = {
304304
...ctx.workspace.latest_build,
305305
id: "accepted-stop-build",
306306
build_number: ctx.workspace.latest_build.build_number + 1,

‎test/unit/api/workspaceUpdate.test.ts‎

Lines changed: 56 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,32 @@ import { workspace as createWorkspace } from "@repo/mocks";
1414

1515
import type { WorkspaceBuild } from "coder/site/src/api/typesGenerated";
1616

17-
function setup(status: "running" | "stopped" | "failed" = "running") {
17+
const parameters = [{ name: "region", value: "east" }];
18+
const expectedStart = {
19+
transition: "start" as const,
20+
template_version_id: "target-version",
21+
rich_parameter_values: parameters,
22+
};
23+
24+
function setup(
25+
status: "running" | "stopped" = "running",
26+
jobStatus: WorkspaceBuild["job"]["status"] = "pending",
27+
) {
1828
const client = new Api();
1929
const http = axios.create();
20-
vi.spyOn(client, "getAxiosInstance").mockReturnValue(http);
2130
const source = createWorkspace({
2231
template_active_version_id: "target-version",
2332
latest_build: { status, build_number: 1 },
2433
});
2534
const acceptedBuild: WorkspaceBuild = {
2635
...source.latest_build,
2736
id: "accepted-build",
28-
workspace_id: source.id,
2937
build_number: 2,
3038
transition: status === "running" ? "stop" : "start",
3139
template_version_id: "target-version",
32-
job: { ...source.latest_build.job, status: "pending" },
40+
job: { ...source.latest_build.job, status: jobStatus },
3341
};
42+
vi.spyOn(client, "getAxiosInstance").mockReturnValue(http);
3443
const getWorkspace = vi
3544
.spyOn(client, "getWorkspace")
3645
.mockResolvedValue(source);
@@ -40,6 +49,10 @@ function setup(status: "running" | "stopped" | "failed" = "running") {
4049
return { client, source, acceptedBuild, getWorkspace, post };
4150
}
4251

52+
function buildInfo(version: string): Awaited<ReturnType<Api["getBuildInfo"]>> {
53+
return { version } as Awaited<ReturnType<Api["getBuildInfo"]>>;
54+
}
55+
4356
function responseError(status: number): AxiosError {
4457
return new AxiosError("request failed", undefined, undefined, undefined, {
4558
status,
@@ -68,9 +81,7 @@ describe("server capability and preflight", () => {
6881
"does not require template permission for a stopped workspace on %s",
6982
async (version) => {
7083
const { client, source } = setup("stopped");
71-
vi.spyOn(client, "getBuildInfo").mockResolvedValue({ version } as Awaited<
72-
ReturnType<Api["getBuildInfo"]>
73-
>);
84+
vi.spyOn(client, "getBuildInfo").mockResolvedValue(buildInfo(version));
7485
const auth = vi.spyOn(client, "checkAuthorization");
7586

7687
await preflightWorkspaceUpdate(client, source);
@@ -83,9 +94,7 @@ describe("server capability and preflight", () => {
8394
"refuses denied or unverifiable permission: %s",
8495
async (permission) => {
8596
const { client, source, post } = setup();
86-
vi.spyOn(client, "getBuildInfo").mockResolvedValue({
87-
version: "2.36.0",
88-
} as Awaited<ReturnType<Api["getBuildInfo"]>>);
97+
vi.spyOn(client, "getBuildInfo").mockResolvedValue(buildInfo("2.36.0"));
8998
const auth = vi.spyOn(client, "checkAuthorization");
9099
if (permission === "error") auth.mockRejectedValue(new Error("secret"));
91100
else auth.mockResolvedValue({ templateUpdate: false });
@@ -99,57 +108,34 @@ describe("server capability and preflight", () => {
99108
});
100109

101110
describe("pinned update acceptance", () => {
102-
it.each(["pending", "succeeded"] as const)(
103-
"returns an accepted direct start without waiting for completion: %s",
104-
async (jobStatus) => {
105-
const { client, source, acceptedBuild, post } = setup("stopped");
106-
const accepted = {
107-
...acceptedBuild,
108-
job: { ...acceptedBuild.job, status: jobStatus },
109-
};
110-
post.mockResolvedValue({ data: accepted });
111+
it.each([
112+
["stopped", "pending", { ...expectedStart, reason: "vscode_connection" }],
113+
["stopped", "succeeded", { ...expectedStart, reason: "vscode_connection" }],
114+
[
115+
"running",
116+
"pending",
117+
{
118+
transition: "stop",
119+
reason: "vscode_connection",
120+
on_success: expectedStart,
121+
},
122+
],
123+
] as const)(
124+
"accepts %s workspace update with %s build job",
125+
async (status, jobStatus, request) => {
126+
const { client, source, acceptedBuild, post } = setup(status, jobStatus);
111127

112128
await expect(
113-
updateWorkspaceWithPinnedBuild(client, source, [
114-
{ name: "region", value: "east" },
115-
]),
116-
).resolves.toEqual({ ...source, latest_build: accepted });
129+
updateWorkspaceWithPinnedBuild(client, source, parameters),
130+
).resolves.toEqual({ ...source, latest_build: acceptedBuild });
117131
expect(post).toHaveBeenCalledExactlyOnceWith(
118132
`/api/v2/workspaces/${source.id}/builds`,
119-
{
120-
transition: "start",
121-
template_version_id: "target-version",
122-
rich_parameter_values: [{ name: "region", value: "east" }],
123-
reason: "vscode_connection",
124-
},
133+
request,
125134
{ signal: undefined, sensitive: true },
126135
);
127136
},
128137
);
129138

130-
it("queues a pinned restart for a running workspace and returns its accepted stop build", async () => {
131-
const { client, source, acceptedBuild, post } = setup();
132-
133-
await expect(
134-
updateWorkspaceWithPinnedBuild(client, source, [
135-
{ name: "region", value: "east" },
136-
]),
137-
).resolves.toEqual({ ...source, latest_build: acceptedBuild });
138-
expect(post).toHaveBeenCalledExactlyOnceWith(
139-
`/api/v2/workspaces/${source.id}/builds`,
140-
{
141-
transition: "stop",
142-
reason: "vscode_connection",
143-
on_success: {
144-
transition: "start",
145-
template_version_id: "target-version",
146-
rich_parameter_values: [{ name: "region", value: "east" }],
147-
},
148-
},
149-
{ signal: undefined, sensitive: true },
150-
);
151-
});
152-
153139
it.each(["build", "version"] as const)(
154140
"rejects source %s drift before queueing",
155141
async (drift) => {
@@ -172,17 +158,12 @@ describe("pinned update acceptance", () => {
172158
const { client, source, post } = setup();
173159
post.mockRejectedValue(new Error("secret server detail"));
174160

175-
const error = await updateWorkspaceWithPinnedBuild(
176-
client,
177-
source,
178-
[],
179-
).catch((error: unknown) => error);
180-
181-
expect(error).toBeInstanceOf(WorkspaceUpdateFailedError);
182-
expect(error).toHaveProperty(
183-
"message",
184-
"Workspace update failed or could not be queued safely. Check the workspace in the dashboard before retrying.",
185-
);
161+
const result = updateWorkspaceWithPinnedBuild(client, source, []);
162+
await expect(result).rejects.toBeInstanceOf(WorkspaceUpdateFailedError);
163+
await expect(result).rejects.toMatchObject({
164+
message:
165+
"Workspace update failed or could not be queued safely. Check the workspace in the dashboard before retrying.",
166+
});
186167
expect(post).toHaveBeenCalledOnce();
187168
});
188169

@@ -196,27 +177,24 @@ describe("pinned update acceptance", () => {
196177
expect(post).toHaveBeenCalledOnce();
197178
});
198179

199-
it("aborts after an accepted request", async () => {
180+
it.each([
181+
["after an accepted request", true, 1],
182+
["before sending a request", false, 0],
183+
] as const)("aborts %s", async (_when, abortAfterRequest, requestCount) => {
200184
const { client, source, post } = setup();
201185
const controller = new AbortController();
202-
post.mockImplementation(() => {
186+
if (abortAfterRequest) {
187+
post.mockImplementation(() => {
188+
controller.abort();
189+
return Promise.resolve({ data: source.latest_build });
190+
});
191+
} else {
203192
controller.abort();
204-
return Promise.resolve({ data: source.latest_build });
205-
});
206-
207-
await expect(
208-
updateWorkspaceWithPinnedBuild(client, source, [], controller.signal),
209-
).rejects.toMatchObject({ name: "AbortError" });
210-
expect(post).toHaveBeenCalledOnce();
211-
});
212-
it("does not send a request when aborted", async () => {
213-
const { client, source, post } = setup();
214-
const controller = new AbortController();
215-
controller.abort();
193+
}
216194

217195
await expect(
218196
updateWorkspaceWithPinnedBuild(client, source, [], controller.signal),
219197
).rejects.toMatchObject({ name: "AbortError" });
220-
expect(post).not.toHaveBeenCalled();
198+
expect(post).toHaveBeenCalledTimes(requestCount);
221199
});
222200
});

‎test/unit/command/updateWorkspace.telemetry.test.ts‎

Lines changed: 49 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,13 @@ import {
55
preflightWorkspaceUpdate,
66
WorkspaceUpdateRefusedError,
77
} from "@/api/workspaceUpdate";
8-
import { Commands } from "@/commands";
9-
import { MementoManager } from "@/core/mementoManager";
108

119
import { workspace } from "@repo/mocks";
1210

1311
import { createTelemetryHarness } from "../../mocks/telemetry";
14-
import { createMockLogger, InMemoryMemento } from "../../mocks/testHelpers";
12+
import { createTestCommands } from "../../mocks/testHelpers";
1513

1614
import type { CoderApi } from "@/api/coderApi";
17-
import type { ServiceContainer } from "@/core/container";
18-
import type { DeploymentManager } from "@/deployment/deploymentManager";
1915

2016
vi.mock("@/api/workspaceUpdate", async (importActual) => {
2117
const actual = await importActual<typeof import("@/api/workspaceUpdate")>();
@@ -29,25 +25,9 @@ const UPDATE_ACTION = "Update and Restart";
2925

3026
function setup() {
3127
const { sink, service } = createTelemetryHarness();
32-
const mementoManager = new MementoManager(new InMemoryMemento());
33-
const logger = createMockLogger();
34-
const container = {
35-
getTelemetryService: () => service,
36-
getLogger: () => logger,
37-
getPathResolver: () => ({}),
38-
getMementoManager: () => mementoManager,
39-
getSecretsManager: () => ({}),
40-
getCliManager: () => ({}),
41-
getLoginCoordinator: () => ({}),
42-
getDuplicateWorkspaceIpc: () => ({}),
43-
getSpeedtestPanelFactory: () => ({}),
44-
getNetcheckPanelFactory: () => ({}),
45-
} as unknown as ServiceContainer;
46-
const commands = new Commands(
47-
container,
48-
{} as CoderApi,
49-
{} as DeploymentManager,
50-
);
28+
const commands = createTestCommands({
29+
services: { getTelemetryService: service },
30+
});
5131
commands.workspace = workspace({ outdated: true });
5232
commands.remoteWorkspaceClient = {
5333
getAxiosInstance: () => ({
@@ -63,67 +43,57 @@ describe("Commands.updateWorkspace", () => {
6343
vi.mocked(preflightWorkspaceUpdate).mockResolvedValue(undefined);
6444
});
6545

66-
it("records an aborted update confirmation when the prompt is dismissed", async () => {
67-
const { commands, sink } = setup();
68-
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined);
69-
70-
await commands.updateWorkspace();
71-
72-
expect(sink.expectOne("workspace.update.prompted")).toMatchObject({
73-
properties: {
74-
prompt: "confirmation",
75-
result: "aborted",
76-
},
77-
});
78-
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
79-
});
46+
it.each([
47+
{ choice: undefined, result: "aborted", properties: {} },
48+
{
49+
choice: UPDATE_ACTION,
50+
result: "success",
51+
properties: { action: "update" },
52+
},
53+
])(
54+
"records $result when confirmation returns $choice",
55+
async ({ choice, result, properties }) => {
56+
const { commands, sink } = setup();
57+
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(
58+
choice as never,
59+
);
60+
await commands.updateWorkspace();
61+
expect(sink.expectOne("workspace.update.prompted")).toMatchObject({
62+
properties: { prompt: "confirmation", result, ...properties },
63+
});
64+
if (choice) {
65+
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
66+
"workbench.action.reloadWindow",
67+
);
68+
} else {
69+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
70+
}
71+
},
72+
);
8073

81-
it("records success and reloads when the update confirmation is accepted", async () => {
74+
it("refuses an unauthorized pinned update before confirmation or reload", async () => {
8275
const { commands, sink } = setup();
83-
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(
84-
UPDATE_ACTION as never,
76+
vi.mocked(preflightWorkspaceUpdate).mockRejectedValueOnce(
77+
new WorkspaceUpdateRefusedError("Cannot pin this update"),
8578
);
86-
79+
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined);
8780
await commands.updateWorkspace();
88-
89-
expect(sink.expectOne("workspace.update.prompted")).toMatchObject({
90-
properties: {
91-
action: "update",
92-
prompt: "confirmation",
93-
result: "success",
94-
},
95-
});
96-
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
97-
"workbench.action.reloadWindow",
81+
expect(vscode.window.showWarningMessage).toHaveBeenLastCalledWith(
82+
"Cannot pin this update",
83+
"Open in Dashboard",
9884
);
85+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
86+
expect(sink.events).toHaveLength(0);
9987
});
100-
});
101-
102-
it("refuses an unauthorized pinned update before confirmation or reload", async () => {
103-
const { commands, sink } = setup();
104-
vi.mocked(preflightWorkspaceUpdate).mockRejectedValueOnce(
105-
new WorkspaceUpdateRefusedError("Cannot pin this update"),
106-
);
107-
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined);
108-
vi.mocked(vscode.commands.executeCommand).mockClear();
109-
await commands.updateWorkspace();
110-
expect(vscode.window.showWarningMessage).toHaveBeenLastCalledWith(
111-
"Cannot pin this update",
112-
"Open in Dashboard",
113-
);
114-
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
115-
expect(sink.events).toHaveLength(0);
116-
});
11788

118-
it("does not continue after disconnect during preflight", async () => {
119-
const { commands } = setup();
120-
vi.mocked(preflightWorkspaceUpdate).mockImplementationOnce(() => {
121-
commands.workspace = undefined;
122-
return Promise.resolve();
89+
it("does not continue after disconnect during preflight", async () => {
90+
const { commands } = setup();
91+
vi.mocked(preflightWorkspaceUpdate).mockImplementationOnce(() => {
92+
commands.workspace = undefined;
93+
return Promise.resolve();
94+
});
95+
await commands.updateWorkspace();
96+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
97+
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled();
12398
});
124-
vi.mocked(vscode.commands.executeCommand).mockClear();
125-
vi.mocked(vscode.window.showWarningMessage).mockClear();
126-
await commands.updateWorkspace();
127-
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
128-
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled();
12999
});

0 commit comments

Comments
 (0)