Skip to content

Commit fc263e7

Browse files
committed
refactor: make confirm dialog update-aware and improve test quality
Make the stopped-workspace dialog offer "Update and Start" when the workspace is outdated, so users can update without going through a separate command. Inline the wantsUpdate getter since it was just hiding a simple equality check on startupMode. Rework the test file to use MockUserInteraction (instead of mocking vscodeProposed directly), MockTerminalSession with content capture, proper agent/resource factories from the shared workspace mock, and a setup() function that returns all test fixtures. Add getMessageCalls() to MockUserInteraction so tests can inspect which buttons were offered in dialogs. Add edge case coverage for agent timeout, created lifecycle, start_timeout, all shutdown states, and getAgentId.
1 parent 519d514 commit fc263e7

File tree

4 files changed

+310
-119
lines changed

4 files changed

+310
-119
lines changed

src/remote/workspaceStateMachine.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class WorkspaceStateMachine implements vscode.Disposable {
6161
switch (workspace.latest_build.status) {
6262
case "running":
6363
this.buildLogStream.close();
64-
if (this.wantsUpdate) {
64+
if (this.startupMode === "update") {
6565
await this.triggerUpdate(workspace, workspaceName, progress);
6666
// Agent IDs may have changed after an update.
6767
this.agent = undefined;
@@ -72,14 +72,18 @@ export class WorkspaceStateMachine implements vscode.Disposable {
7272
case "failed": {
7373
this.buildLogStream.close();
7474

75-
if (
76-
this.startupMode === "none" &&
77-
!(await this.confirmStart(workspaceName))
78-
) {
79-
throw new Error(`Workspace start cancelled`);
75+
if (this.startupMode === "none") {
76+
const choice = await this.confirmStartOrUpdate(
77+
workspaceName,
78+
workspace.outdated,
79+
);
80+
if (!choice) {
81+
throw new Error(`Workspace start cancelled`);
82+
}
83+
this.startupMode = choice;
8084
}
8185

82-
if (this.wantsUpdate) {
86+
if (this.startupMode === "update") {
8387
await this.triggerUpdate(workspace, workspaceName, progress);
8488
} else {
8589
await this.triggerStart(workspace, workspaceName, progress);
@@ -90,7 +94,7 @@ export class WorkspaceStateMachine implements vscode.Disposable {
9094
case "pending":
9195
case "starting":
9296
case "stopping": {
93-
// Clear the agent since it's ID could change after a restart
97+
// Clear the agent since its ID could change after a restart
9498
this.agent = undefined;
9599
this.agentLogStream.close();
96100
progress.report({
@@ -219,10 +223,6 @@ export class WorkspaceStateMachine implements vscode.Disposable {
219223
}
220224
}
221225

222-
private get wantsUpdate(): boolean {
223-
return this.startupMode === "update";
224-
}
225-
226226
private buildCliContext(workspace: Workspace) {
227227
return {
228228
restClient: this.workspaceClient,
@@ -264,16 +264,22 @@ export class WorkspaceStateMachine implements vscode.Disposable {
264264
this.logger.info(`${workspaceName} update initiated`);
265265
}
266266

267-
private async confirmStart(workspaceName: string): Promise<boolean> {
267+
private async confirmStartOrUpdate(
268+
workspaceName: string,
269+
outdated: boolean,
270+
): Promise<"start" | "update" | undefined> {
271+
const buttons = outdated ? ["Start", "Update and Start"] : ["Start"];
268272
const action = await vscodeProposed.window.showInformationMessage(
269-
`Unable to connect to the workspace ${workspaceName} because it is not running. Start the workspace?`,
273+
`The workspace ${workspaceName} is not running. How would you like to proceed?`,
270274
{
271275
useCustom: true,
272276
modal: true,
273277
},
274-
"Start",
278+
...buttons,
275279
);
276-
return action === "Start";
280+
if (action === "Start") return "start";
281+
if (action === "Update and Start") return "update";
282+
return undefined;
277283
}
278284

279285
public getAgentId(): string | undefined {

test/mocks/testHelpers.ts

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,20 @@ export class MockProgressReporter {
173173
}
174174
}
175175

176+
/** A recorded call to one of the vscode.window.show*Message methods. */
177+
export interface MessageCall {
178+
level: "information" | "warning" | "error";
179+
message: string;
180+
items: string[];
181+
}
182+
176183
/**
177184
* Mock user interaction that integrates with vscode.window message dialogs and input boxes.
178-
* Use this to control user responses in tests.
185+
* Use this to control user responses and inspect dialog calls in tests.
179186
*/
180187
export class MockUserInteraction {
181188
private readonly responses = new Map<string, string | undefined>();
189+
private readonly _messageCalls: MessageCall[] = [];
182190
private inputBoxValue: string | undefined;
183191
private inputBoxValidateInput: ((value: string) => Promise<void>) | undefined;
184192
private externalUrls: string[] = [];
@@ -194,6 +202,13 @@ export class MockUserInteraction {
194202
this.responses.set(message, response);
195203
}
196204

205+
/**
206+
* Get all message dialog calls that were made (across all levels).
207+
*/
208+
getMessageCalls(): readonly MessageCall[] {
209+
return this._messageCalls;
210+
}
211+
197212
/**
198213
* Set the value to return from showInputBox.
199214
* Pass undefined to simulate user cancelling.
@@ -229,6 +244,7 @@ export class MockUserInteraction {
229244
*/
230245
clear(): void {
231246
this.responses.clear();
247+
this._messageCalls.length = 0;
232248
this.inputBoxValue = undefined;
233249
this.inputBoxValidateInput = undefined;
234250
this.externalUrls = [];
@@ -242,20 +258,27 @@ export class MockUserInteraction {
242258
return this.responses.get(message);
243259
};
244260

245-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
246-
const handleMessage = (message: string): Thenable<any> => {
247-
const response = getResponse(message);
248-
return Promise.resolve(response);
249-
};
250-
251-
vi.mocked(vscode.window.showErrorMessage).mockImplementation(handleMessage);
261+
const handleMessage =
262+
(level: MessageCall["level"]) =>
263+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- serves all show*Message overloads
264+
(message: string, ...rest: unknown[]): Thenable<any> => {
265+
const items = rest.filter(
266+
(arg): arg is string => typeof arg === "string",
267+
);
268+
this._messageCalls.push({ level, message, items });
269+
return Promise.resolve(getResponse(message));
270+
};
271+
272+
vi.mocked(vscode.window.showErrorMessage).mockImplementation(
273+
handleMessage("error"),
274+
);
252275

253276
vi.mocked(vscode.window.showWarningMessage).mockImplementation(
254-
handleMessage,
277+
handleMessage("warning"),
255278
);
256279

257280
vi.mocked(vscode.window.showInformationMessage).mockImplementation(
258-
handleMessage,
281+
handleMessage("information"),
259282
);
260283

261284
vi.mocked(vscode.env.openExternal).mockImplementation(
@@ -922,3 +945,44 @@ export class MockContextManager {
922945

923946
readonly dispose = vi.fn();
924947
}
948+
949+
/**
950+
* Mock TerminalSession that captures all content written to the terminal.
951+
* Use `lastInstance` to get the most recently created instance (set in the constructor),
952+
* which is useful when the real TerminalSession is created inside the class under test.
953+
*/
954+
export class MockTerminalSession {
955+
static lastInstance: MockTerminalSession | undefined;
956+
957+
private readonly _lines: string[] = [];
958+
959+
readonly writeEmitter = {
960+
fire: vi.fn((data: string) => {
961+
this._lines.push(data);
962+
}),
963+
event: vi.fn(),
964+
dispose: vi.fn(),
965+
};
966+
readonly terminal = { show: vi.fn(), dispose: vi.fn() };
967+
readonly dispose = vi.fn();
968+
969+
constructor(_name?: string) {
970+
MockTerminalSession.lastInstance = this;
971+
}
972+
973+
/** All lines written via writeEmitter.fire(). */
974+
get lines(): readonly string[] {
975+
return this._lines;
976+
}
977+
978+
/** Concatenated terminal content. */
979+
get content(): string {
980+
return this._lines.join("");
981+
}
982+
983+
/** Reset captured content and mock call history. */
984+
clear(): void {
985+
this._lines.length = 0;
986+
this.writeEmitter.fire.mockClear();
987+
}
988+
}

test/mocks/workspace.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/**
2-
* Test factory for Coder SDK Workspace type.
2+
* Test factories for Coder SDK workspace types.
33
*/
44

55
import type {
66
Workspace,
7+
WorkspaceAgent,
78
WorkspaceBuild,
9+
WorkspaceResource,
810
} from "coder/site/src/api/typesGenerated";
911

1012
const defaultBuild: WorkspaceBuild = {
@@ -92,3 +94,54 @@ export function workspace(
9294
...rest,
9395
};
9496
}
97+
98+
/** Create a WorkspaceAgent with sensible defaults for a connected, ready agent. */
99+
export function agent(overrides: Partial<WorkspaceAgent> = {}): WorkspaceAgent {
100+
return {
101+
id: "agent-1",
102+
parent_id: null,
103+
created_at: "2024-01-01T00:00:00Z",
104+
updated_at: "2024-01-01T00:00:00Z",
105+
status: "connected",
106+
lifecycle_state: "ready",
107+
name: "main",
108+
resource_id: "resource-1",
109+
architecture: "amd64",
110+
environment_variables: {},
111+
operating_system: "linux",
112+
logs_length: 0,
113+
logs_overflowed: false,
114+
version: "2.25.0",
115+
api_version: "1.0",
116+
apps: [],
117+
connection_timeout_seconds: 120,
118+
troubleshooting_url: "",
119+
subsystems: [],
120+
health: { healthy: true },
121+
display_apps: [],
122+
log_sources: [],
123+
scripts: [],
124+
startup_script_behavior: "non-blocking",
125+
...overrides,
126+
};
127+
}
128+
129+
/** Create a WorkspaceResource with sensible defaults. */
130+
export function resource(
131+
overrides: Partial<WorkspaceResource> = {},
132+
): WorkspaceResource {
133+
return {
134+
id: "resource-1",
135+
created_at: "2024-01-01T00:00:00Z",
136+
job_id: "job-1",
137+
workspace_transition: "start",
138+
type: "docker_container",
139+
name: "main",
140+
hide: false,
141+
icon: "",
142+
agents: [],
143+
metadata: [],
144+
daily_cost: 0,
145+
...overrides,
146+
};
147+
}

0 commit comments

Comments
 (0)