diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 3c23e28f..df8223ce 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -1,8 +1,9 @@ import { type Api } from "coder/site/src/api/api"; import { - type WorkspaceAgentLog, type ProvisionerJobLog, + type TemplateVersionParameter, type Workspace, + type WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; import { spawn } from "node:child_process"; import * as vscode from "vscode"; @@ -54,10 +55,6 @@ interface CliContext { featureSet: FeatureSet; } -/** - * Spawn a Coder CLI subcommand and stream its output. - * Resolves when the process exits successfully; rejects on non-zero exit. - */ function runCliCommand(ctx: CliContext, args: string[]): Promise { return new Promise((resolve, reject) => { const fullArgs = [ @@ -65,18 +62,17 @@ function runCliCommand(ctx: CliContext, args: string[]): Promise { ...args, createWorkspaceIdentifier(ctx.workspace), ]; - const cmd = `${escapeCommandArg(ctx.binPath)} ${fullArgs.join(" ")}`; const proc = spawn(cmd, { shell: true }); proc.stdout.on("data", (data: Buffer) => { - ctx.write(data.toString().replace(/\r?\n/g, "\r\n")); + ctx.write(data.toString()); }); let capturedStderr = ""; proc.stderr.on("data", (data: Buffer) => { const text = data.toString(); - ctx.write(text.replace(/\r?\n/g, "\r\n")); + ctx.write(text); capturedStderr += text; }); @@ -84,11 +80,9 @@ function runCliCommand(ctx: CliContext, args: string[]): Promise { if (code === 0) { resolve(); } else { - let errorText = `"${fullArgs.join(" ")}" exited with code ${code}`; - if (capturedStderr !== "") { - errorText += `: ${capturedStderr}`; - } - reject(new Error(errorText)); + let msg = `"${fullArgs.join(" ")}" exited with code ${code}`; + if (capturedStderr) msg += `: ${capturedStderr}`; + reject(new Error(msg)); } }); }); @@ -113,18 +107,22 @@ export async function startWorkspace(ctx: CliContext): Promise { } /** - * Update a workspace to the latest template version. - * - * Uses `coder update` when the CLI supports it (>= 2.24). - * Falls back to the REST API: stop, wait, then updateWorkspaceVersion. + * Update a workspace to the latest template version. Collects any newly- + * required parameters via VS Code prompts and passes them to the CLI as flags + * (the resolver phase can't render an interactive terminal). Falls back to + * the REST API for CLIs older than 2.24. */ export async function updateWorkspace(ctx: CliContext): Promise { - if (ctx.featureSet.cliUpdate) { - await runCliCommand(ctx, ["update"]); - return ctx.restClient.getWorkspace(ctx.workspace.id); + if (!ctx.featureSet.cliUpdate) { + return updateWorkspaceVersion(ctx); } - // REST API fallback for older CLIs. + const paramArgs = await collectUpdateParameters(ctx); + await runCliCommand(ctx, ["update", ...paramArgs]); + return ctx.restClient.getWorkspace(ctx.workspace.id); +} + +async function updateWorkspaceVersion(ctx: CliContext): Promise { if (ctx.workspace.latest_build.status === "running") { ctx.write("Stopping workspace for update...\r\n"); const stopBuild = await ctx.restClient.stopWorkspace(ctx.workspace.id); @@ -139,6 +137,179 @@ export async function updateWorkspace(ctx: CliContext): Promise { return ctx.restClient.getWorkspace(ctx.workspace.id); } +async function collectUpdateParameters(ctx: CliContext): Promise { + const newParams = await ctx.restClient.getTemplateVersionRichParameters( + ctx.workspace.template_active_version_id, + ); + const candidates = newParams.filter((p) => p.required && !p.default_value); + if (candidates.length === 0) return []; + + const currentValues = await ctx.restClient.getWorkspaceBuildParameters( + ctx.workspace.latest_build.id, + ); + const existing = new Set(currentValues.map((p) => p.name)); + const toPrompt = candidates.filter((p) => !existing.has(p.name)); + + const args: string[] = []; + for (let i = 0; i < toPrompt.length; i++) { + const value = await promptForParameter(toPrompt[i], i + 1, toPrompt.length); + if (value === undefined) { + throw new Error("Workspace update cancelled"); + } + args.push("--parameter", escapeCommandArg(`${toPrompt[i].name}=${value}`)); + } + return args; +} + +function promptForParameter( + param: TemplateVersionParameter, + step: number, + totalSteps: number, +): Promise { + const title = param.display_name || param.name; + const items = quickPickItems(param); + + if (items) { + const multi = param.form_type === "multi-select"; + const qp = vscode.window.createQuickPick<(typeof items)[number]>(); + qp.title = title; + qp.step = step; + qp.totalSteps = totalSteps; + qp.placeholder = param.description_plaintext; + qp.items = items; + qp.canSelectMany = multi; + qp.ignoreFocusOut = true; + return untilHidden(qp, () => { + if (multi) { + return qp.selectedItems.length > 0 + ? JSON.stringify(qp.selectedItems.map((i) => i.value)) + : undefined; + } + return qp.selectedItems[0]?.value; + }); + } + + const input = vscode.window.createInputBox(); + input.title = title; + input.step = step; + input.totalSteps = totalSteps; + input.prompt = param.description_plaintext; + input.placeholder = formatConstraint(param); + input.value = param.default_value; + input.ignoreFocusOut = true; + const validate = makeValidator(param); + const refresh = () => { + input.validationMessage = validate(input.value).message ?? ""; + }; + refresh(); + input.onDidChangeValue(refresh); + return untilHidden(input, () => + validate(input.value).ok ? input.value : undefined, + ); +} + +function untilHidden( + qi: vscode.InputBox | vscode.QuickPick, + onAccept: () => T | undefined, +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: T | undefined) => { + if (done) return; + done = true; + resolve(value); + qi.dispose(); + }; + qi.onDidAccept(() => { + const value = onAccept(); + if (value !== undefined) finish(value); + }); + qi.onDidHide(() => finish(undefined)); + qi.show(); + }); +} + +/** + * Returns picker items if the param needs a chooser, otherwise undefined. + * Anything that falls through gets a free-form text input. + */ +function quickPickItems( + param: TemplateVersionParameter, +): Array | undefined { + if (param.type === "bool") { + return [ + { label: "True", value: "true" }, + { label: "False", value: "false" }, + ]; + } + if (param.options.length > 0) { + return param.options.map((o) => ({ + label: o.name, + description: o.description, + value: o.value, + })); + } + return undefined; +} + +function formatConstraint(param: TemplateVersionParameter): string { + if (param.type === "number") { + const lo = param.validation_min; + const hi = param.validation_max; + if (lo !== undefined && hi !== undefined) return `between ${lo} and ${hi}`; + if (lo !== undefined) return `at least ${lo}`; + if (hi !== undefined) return `at most ${hi}`; + return "a number"; + } + if (param.validation_regex) { + return param.validation_error || `must match ${param.validation_regex}`; + } + return ""; +} + +/** + * Returns `{ ok, message }`: `ok` gates submission, `message` (if any) is + * shown inline. Empty input on a required param blocks submit silently. + * Coder regexes are RE2; on parse failure we defer to server-side validation. + */ +function makeValidator( + param: TemplateVersionParameter, +): (input: string) => { ok: boolean; message?: string } { + let re: RegExp | undefined; + if (param.validation_regex) { + try { + re = new RegExp(param.validation_regex); + } catch { + re = undefined; + } + } + return (input) => { + if (!input) return { ok: !param.required }; + if (param.type === "number") { + const n = Number(input); + if (!Number.isFinite(n)) { + return { ok: false, message: "Must be a number" }; + } + if (param.validation_min !== undefined && n < param.validation_min) { + return { + ok: false, + message: `Must be at least ${param.validation_min}`, + }; + } + if (param.validation_max !== undefined && n > param.validation_max) { + return { + ok: false, + message: `Must be at most ${param.validation_max}`, + }; + } + } + if (re && !re.test(input)) { + return { ok: false, message: param.validation_error || "Invalid format" }; + } + return { ok: true }; + }; +} + /** * Streams build logs in real-time via a callback. * Returns the websocket for lifecycle management. diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 69fdd006..6bb0eea7 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -561,7 +561,7 @@ export class Remote { const isReady = await stateMachine.processWorkspace(w, progress); if (isReady) { subscription.dispose(); - resolve(w); + resolve(stateMachine.getWorkspace() ?? w); return; } } catch (error: unknown) { diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index b533ffb8..c24dc422 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -1,4 +1,10 @@ -import { createWorkspaceIdentifier, extractAgents } from "../api/api-helper"; +import * as vscode from "vscode"; + +import { + createWorkspaceIdentifier, + errToStr, + extractAgents, +} from "../api/api-helper"; import { LazyStream, startWorkspace, @@ -16,7 +22,6 @@ import type { Workspace, WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; -import type * as vscode from "vscode"; import type { CoderApi } from "../api/coderApi"; import type { StartupMode } from "../core/mementoManager"; @@ -35,6 +40,7 @@ export class WorkspaceStateMachine implements vscode.Disposable { private readonly agentLogStream = new LazyStream(); private agent: { id: string; name: string } | undefined; + private workspace: Workspace | undefined; constructor( private readonly parts: AuthorityParts, @@ -56,17 +62,24 @@ export class WorkspaceStateMachine implements vscode.Disposable { workspace: Workspace, progress: vscode.Progress<{ message?: string }>, ): Promise { + this.workspace = workspace; const workspaceName = createWorkspaceIdentifier(workspace); switch (workspace.latest_build.status) { - case "running": + case "running": { this.buildLogStream.close(); - if (this.startupMode === "update") { - await this.triggerUpdate(workspace, workspaceName, progress); + const updated = await this.maybeUpdate( + workspace, + workspaceName, + progress, + ); + if (updated) { + workspace = updated; // Agent IDs may have changed after an update. this.agent = undefined; } break; + } case "stopped": case "failed": { @@ -83,11 +96,18 @@ export class WorkspaceStateMachine implements vscode.Disposable { this.startupMode = choice; } - if (this.startupMode === "update") { - await this.triggerUpdate(workspace, workspaceName, progress); - } else { - await this.triggerStart(workspace, workspaceName, progress); + const updated = await this.maybeUpdate( + workspace, + workspaceName, + progress, + ); + if (updated) { + workspace = updated; + if (workspace.latest_build.status === "running") break; + return false; } + // Either we weren't in update mode, or the update failed: start. + await this.triggerStart(workspace, workspaceName, progress); return false; } @@ -248,20 +268,30 @@ export class WorkspaceStateMachine implements vscode.Disposable { this.logger.info(`${workspaceName} start initiated`); } - private async triggerUpdate( + /** No-op if not in update mode. Falls through to start on failure. */ + private async maybeUpdate( workspace: Workspace, workspaceName: string, progress: vscode.Progress<{ message?: string }>, - ): Promise { + ): Promise { + if (this.startupMode !== "update") return undefined; + // Downgrade up-front so monitor events don't retry the update. + this.startupMode = "start"; progress.report({ message: `updating ${workspaceName}...` }); this.logger.info(`Updating ${workspaceName}`, { - mode: this.startupMode, status: workspace.latest_build.status, }); - await updateWorkspace(this.buildCliContext(workspace)); - // Downgrade so subsequent transitions don't re-trigger the update. - this.startupMode = "start"; - this.logger.info(`${workspaceName} update initiated`); + try { + this.workspace = await updateWorkspace(this.buildCliContext(workspace)); + return this.workspace; + } catch (error) { + const reason = errToStr(error); + this.logger.warn(`Update failed for ${workspaceName}: ${reason}`); + vscode.window.showWarningMessage( + `Workspace update failed: ${reason}. Continuing with the existing version.`, + ); + return undefined; + } } private async confirmStartOrUpdate( @@ -286,6 +316,10 @@ export class WorkspaceStateMachine implements vscode.Disposable { return this.agent?.id; } + public getWorkspace(): Workspace | undefined { + return this.workspace; + } + dispose(): void { this.buildLogStream.close(); this.agentLogStream.close(); diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 7f37b07a..4fca7473 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -269,7 +269,10 @@ export class WorkspaceMonitor implements vscode.Disposable { } private updateStatusBar(workspace: Workspace) { - if (workspace.outdated) { + const status = workspace.latest_build.status; + const settled = + status === "running" || status === "stopped" || status === "failed"; + if (workspace.outdated && settled) { this.statusBarItem.show(); } else { this.statusBarItem.hide(); diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 8def001e..b22a86f0 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -138,6 +138,8 @@ export const window = { dispose: vi.fn(), clear: vi.fn(), })), + createInputBox: vi.fn(), + createQuickPick: vi.fn(), createStatusBarItem: vi.fn(), createWebviewPanel: vi.fn(), registerUriHandler: vi.fn(() => ({ dispose: vi.fn() })), diff --git a/test/unit/api/workspace.test.ts b/test/unit/api/workspace.test.ts index 182ee390..fa78483e 100644 --- a/test/unit/api/workspace.test.ts +++ b/test/unit/api/workspace.test.ts @@ -1,8 +1,37 @@ -import { describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; -import { LazyStream } from "@/api/workspace"; +import { LazyStream, startWorkspace, updateWorkspace } from "@/api/workspace"; +import { type FeatureSet } from "@/featureSet"; import { type UnidirectionalStream } from "@/websocket/eventStreamConnection"; +import { workspace as createWorkspace } from "@repo/mocks"; + +import type { Api } from "coder/site/src/api/api"; +import type { + TemplateVersionParameter, + Workspace, + WorkspaceBuild, +} from "coder/site/src/api/typesGenerated"; + +vi.mock(import("node:child_process"), async (importOriginal) => ({ + ...(await importOriginal()), + spawn: vi.fn(), +})); +const { spawn } = await import("node:child_process"); + +const featureSet: FeatureSet = { + vscodessh: true, + proxyLogDirectory: true, + wildcardSSH: true, + buildReason: true, + cliUpdate: true, + keyringAuth: true, + keyringTokenRead: true, + supportBundle: true, +}; + function mockStream(): UnidirectionalStream { return { url: "ws://test", @@ -28,6 +57,161 @@ function deferredFactory() { }; } +function createUpdateCtx( + overrides: { + workspace?: Omit, "latest_build"> & { + latest_build?: Partial; + }; + featureSet?: Partial; + } = {}, +) { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((_key: string, defaultValue: unknown) => defaultValue), + } as never); + const workspace = createWorkspace({ + outdated: true, + latest_build: { status: "running", transition: "start" }, + ...overrides.workspace, + }); + const finalWorkspace = createWorkspace({ + outdated: false, + latest_build: { status: "running" }, + }); + const restClient = { + getWorkspace: vi.fn().mockResolvedValue(finalWorkspace), + stopWorkspace: vi + .fn() + .mockResolvedValue({ ...workspace.latest_build, status: "stopped" }), + updateWorkspaceVersion: vi.fn().mockResolvedValue(workspace.latest_build), + waitForBuild: vi.fn().mockResolvedValue({ + ...workspace.latest_build.job, + status: "succeeded", + }), + getTemplateVersionRichParameters: vi.fn().mockResolvedValue([]), + getWorkspaceBuildParameters: vi.fn().mockResolvedValue([]), + }; + const ctx = { + restClient: restClient as unknown as Api, + auth: { mode: "url" as const, url: "https://test.coder.com" }, + binPath: "/usr/bin/coder", + workspace, + write: vi.fn<(data: string) => void>(), + featureSet: { ...featureSet, ...overrides.featureSet }, + }; + return { ctx, restClient, finalWorkspace }; +} + +/** Drives mocked spawn() so tests can fire stdout/stderr + close at will. */ +function controlSpawn() { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + let resolveSpawned!: () => void; + const spawned = new Promise((resolve) => { + resolveSpawned = resolve; + }); + vi.mocked(spawn).mockImplementation(() => { + resolveSpawned(); + return proc as never; + }); + return { + spawned, + stderr(data: string) { + proc.stderr.emit("data", Buffer.from(data)); + }, + async close(exitCode: number) { + await spawned; + proc.emit("close", exitCode); + }, + }; +} + +interface QuickInputMock { + mock: Record & { + show: ReturnType; + dispose: ReturnType; + }; + accept: (overrides?: Record) => void; + hide: () => void; +} + +function quickInputMock(): QuickInputMock { + let acceptCb: () => void = () => {}; + let hideCb: () => void = () => {}; + let changeCb: (v: string) => void = () => {}; + const mock = { + title: "", + step: 0, + totalSteps: 0, + prompt: "", + placeholder: "", + value: "", + validationMessage: "", + ignoreFocusOut: false, + items: [] as readonly unknown[], + selectedItems: [] as readonly unknown[], + onDidAccept: vi.fn((cb: () => void) => { + acceptCb = cb; + return { dispose: vi.fn() }; + }), + onDidHide: vi.fn((cb: () => void) => { + hideCb = cb; + return { dispose: vi.fn() }; + }), + onDidChangeValue: vi.fn((cb: (v: string) => void) => { + changeCb = cb; + return { dispose: vi.fn() }; + }), + show: vi.fn(), + dispose: vi.fn(), + }; + return { + mock, + accept(overrides) { + Object.assign(mock, overrides ?? {}); + if (overrides && "value" in overrides) changeCb(mock.value); + acceptCb(); + }, + hide() { + hideCb(); + }, + }; +} + +function mockCreateInputBox() { + const qi = quickInputMock(); + vi.mocked(vscode.window.createInputBox).mockReturnValue( + qi.mock as unknown as vscode.InputBox, + ); + return qi; +} + +function mockCreateQuickPick() { + const qi = quickInputMock(); + vi.mocked(vscode.window.createQuickPick).mockReturnValue( + qi.mock as unknown as vscode.QuickPick, + ); + return qi; +} + +async function flushMicrotasks(times = 4) { + for (let i = 0; i < times; i++) await Promise.resolve(); +} + +function setupUpdate( + params: Array> = [], + opts: Parameters[0] = {}, +) { + const ctxBundle = createUpdateCtx(opts); + ctxBundle.restClient.getTemplateVersionRichParameters.mockResolvedValue( + params.map(param), + ); + return { ...ctxBundle, sp: controlSpawn() }; +} + describe("LazyStream", () => { it("opens once and ignores subsequent calls", async () => { const factory: StreamFactory = vi.fn().mockResolvedValue(mockStream()); @@ -86,3 +270,239 @@ describe("LazyStream", () => { expect(factory2).toHaveBeenCalledOnce(); }); }); + +function param(overrides: Partial = {}) { + return { + name: "environment", + display_name: "Environment", + description: "", + description_plaintext: "", + type: "string", + form_type: "input", + mutable: true, + default_value: "", + icon: "", + options: [], + required: true, + ephemeral: false, + ...overrides, + } as TemplateVersionParameter; +} + +describe("updateWorkspace", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { + kind: "text input", + param: { name: "environment" } as Partial, + mock: mockCreateInputBox, + accept: { value: "dev" }, + expected: '--parameter "environment=dev"', + }, + { + kind: "bool quick pick", + param: { name: "enabled", type: "bool" }, + mock: mockCreateQuickPick, + accept: { selectedItems: [{ label: "True", value: "true" }] }, + expected: '--parameter "enabled=true"', + }, + { + kind: "options quick pick", + param: { + name: "size", + options: [ + { name: "Small", description: "", value: "s", icon: "" }, + { name: "Large", description: "", value: "l", icon: "" }, + ], + }, + mock: mockCreateQuickPick, + accept: { selectedItems: [{ value: "l" }] }, + expected: '--parameter "size=l"', + }, + { + kind: "fallback text input for unknown types", + param: { name: "x", type: "list(string)" }, + mock: mockCreateInputBox, + accept: { value: "[]" }, + expected: '--parameter "x=[]"', + }, + ])( + "collects the value via $kind", + async ({ param: p, mock, accept, expected }) => { + const { ctx, sp, finalWorkspace } = setupUpdate([p]); + const qi = mock(); + + const result = updateWorkspace(ctx); + await flushMicrotasks(); + qi.accept(accept); + await sp.close(0); + + await expect(result).resolves.toBe(finalWorkspace); + expect(spawn).toHaveBeenCalledWith( + expect.stringContaining(expected), + expect.objectContaining({ shell: true }), + ); + }, + ); + + it("skips parameters that already have a value or default", async () => { + const { ctx, restClient, sp } = setupUpdate([ + { name: "existing" }, + { name: "with_default", default_value: "foo" }, + { name: "optional", required: false }, + ]); + restClient.getWorkspaceBuildParameters.mockResolvedValue([ + { name: "existing", value: "kept" }, + ]); + + const result = updateWorkspace(ctx); + await sp.close(0); + await result; + + expect(vscode.window.createInputBox).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + expect.not.stringContaining("--parameter"), + expect.anything(), + ); + }); + + it("throws when the user cancels a parameter prompt", async () => { + const { ctx } = setupUpdate([{}]); + const qi = mockCreateInputBox(); + + const result = updateWorkspace(ctx); + await flushMicrotasks(); + qi.hide(); + + await expect(result).rejects.toThrow("Workspace update cancelled"); + expect(spawn).not.toHaveBeenCalled(); + }); + + it("steps the input title across multiple required params", async () => { + const { ctx, sp } = setupUpdate([{ name: "a" }, { name: "b" }]); + const inputs = [quickInputMock(), quickInputMock()]; + vi.mocked(vscode.window.createInputBox) + .mockReturnValueOnce(inputs[0].mock as unknown as vscode.InputBox) + .mockReturnValueOnce(inputs[1].mock as unknown as vscode.InputBox); + + const result = updateWorkspace(ctx); + await flushMicrotasks(); + inputs[0].accept({ value: "first" }); + await flushMicrotasks(); + inputs[1].accept({ value: "second" }); + await sp.close(0); + await result; + + expect(inputs.map((i) => [i.mock.step, i.mock.totalSteps])).toEqual([ + [1, 2], + [2, 2], + ]); + }); + + it("rejects when the process exits non-zero", async () => { + const { ctx, restClient } = createUpdateCtx(); + const sp = controlSpawn(); + + const result = updateWorkspace(ctx); + await sp.spawned; + sp.stderr("auth failed"); + await sp.close(1); + + await expect(result).rejects.toThrow(/exited with code 1.*auth failed/); + expect(restClient.getWorkspace).not.toHaveBeenCalled(); + }); + + it("falls back to the API update path when coder update is unsupported", async () => { + const { ctx, restClient, finalWorkspace } = createUpdateCtx({ + featureSet: { cliUpdate: false }, + }); + + await expect(updateWorkspace(ctx)).resolves.toBe(finalWorkspace); + + expect(spawn).not.toHaveBeenCalled(); + expect(restClient.getTemplateVersionRichParameters).not.toHaveBeenCalled(); + expect(restClient.stopWorkspace).toHaveBeenCalledWith(ctx.workspace.id); + expect(restClient.updateWorkspaceVersion).toHaveBeenCalledWith( + ctx.workspace, + ); + }); + + it("does not stop before API fallback update when the workspace is not running", async () => { + const { ctx, restClient } = createUpdateCtx({ + workspace: { latest_build: { status: "stopped", transition: "stop" } }, + featureSet: { cliUpdate: false }, + }); + + await updateWorkspace(ctx); + + expect(restClient.stopWorkspace).not.toHaveBeenCalled(); + expect(restClient.updateWorkspaceVersion).toHaveBeenCalledWith( + ctx.workspace, + ); + }); + + it("throws before update when the API fallback stop is canceled", async () => { + const { ctx, restClient } = createUpdateCtx({ + featureSet: { cliUpdate: false }, + }); + restClient.waitForBuild.mockResolvedValueOnce({ + ...ctx.workspace.latest_build.job, + status: "canceled", + }); + + await expect(updateWorkspace(ctx)).rejects.toThrow( + "Workspace update canceled during stop", + ); + expect(restClient.updateWorkspaceVersion).not.toHaveBeenCalled(); + }); +}); + +describe("startWorkspace", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("runs coder start when the workspace is stopped", async () => { + const { ctx, restClient, finalWorkspace } = createUpdateCtx({ + workspace: { latest_build: { status: "stopped", transition: "stop" } }, + }); + const sp = controlSpawn(); + + const result = startWorkspace(ctx); + await sp.close(0); + + await expect(result).resolves.toBe(finalWorkspace); + expect(spawn).toHaveBeenCalledWith( + '"/usr/bin/coder" --url "https://test.coder.com" start --yes --reason vscode_connection testuser/test-workspace', + expect.objectContaining({ shell: true }), + ); + expect(restClient.getWorkspace).toHaveBeenCalledWith(ctx.workspace.id); + }); + + it("no-ops when the workspace is already running", async () => { + const { ctx, restClient } = createUpdateCtx(); + await expect(startWorkspace(ctx)).resolves.toBe(ctx.workspace); + expect(spawn).not.toHaveBeenCalled(); + expect(restClient.getWorkspace).not.toHaveBeenCalled(); + }); + + it("omits --reason when buildReason feature is unavailable", async () => { + const { ctx } = createUpdateCtx({ + workspace: { latest_build: { status: "stopped", transition: "stop" } }, + featureSet: { buildReason: false }, + }); + const sp = controlSpawn(); + + const result = startWorkspace(ctx); + await sp.close(0); + await result; + + expect(spawn).toHaveBeenCalledWith( + '"/usr/bin/coder" --url "https://test.coder.com" start --yes testuser/test-workspace', + expect.objectContaining({ shell: true }), + ); + }); +}); diff --git a/test/unit/remote/workspaceStateMachine.test.ts b/test/unit/remote/workspaceStateMachine.test.ts index 1b62a217..46aae11f 100644 --- a/test/unit/remote/workspaceStateMachine.test.ts +++ b/test/unit/remote/workspaceStateMachine.test.ts @@ -94,6 +94,9 @@ describe("WorkspaceStateMachine", () => { beforeEach(() => { vi.clearAllMocks(); MockTerminalOutputChannel.lastInstance = undefined; + vi.mocked(updateWorkspace).mockImplementation((ctx) => + Promise.resolve(ctx.workspace), + ); vi.mocked(maybeAskAgent).mockImplementation((agents) => Promise.resolve(agents.length > 0 ? agents[0] : undefined), ); @@ -182,6 +185,28 @@ describe("WorkspaceStateMachine", () => { expect(updateWorkspace).toHaveBeenCalledOnce(); }); + it("falls through to the agent check after an update completes", async () => { + vi.mocked(updateWorkspace).mockResolvedValueOnce(runningWorkspace()); + const { sm, progress } = setup("update"); + const ws = createWorkspace({ latest_build: { status: "stopped" } }); + + expect(await sm.processWorkspace(ws, progress)).toBe(true); + expect(updateWorkspace).toHaveBeenCalledOnce(); + expect(sm.getWorkspace()?.latest_build.status).toBe("running"); + }); + + it("falls back to start when the update fails", async () => { + vi.mocked(updateWorkspace).mockRejectedValueOnce( + new Error("Workspace update cancelled"), + ); + const { sm, progress } = setup("update"); + const ws = createWorkspace({ latest_build: { status: "stopped" } }); + + expect(await sm.processWorkspace(ws, progress)).toBe(false); + expect(updateWorkspace).toHaveBeenCalledOnce(); + expect(startWorkspace).toHaveBeenCalledOnce(); + }); + it("prompts user when mode is 'none' and user picks 'Start'", async () => { const { sm, progress, userInteraction } = setup("none"); userInteraction.setResponse(CONFIRM_MESSAGE, "Start"); diff --git a/test/unit/workspace/workspaceMonitor.test.ts b/test/unit/workspace/workspaceMonitor.test.ts index 5ceedcad..fb2b9cc7 100644 --- a/test/unit/workspace/workspaceMonitor.test.ts +++ b/test/unit/workspace/workspaceMonitor.test.ts @@ -106,6 +106,19 @@ describe("WorkspaceMonitor", () => { stream.pushMessage(workspaceEvent({ outdated: false })); expect(statusBar.hide).toHaveBeenCalled(); }); + + it("hides status bar while an outdated workspace is building", async () => { + const { stream, statusBar } = await setup(); + + stream.pushMessage( + workspaceEvent({ + outdated: true, + latest_build: { status: "starting" }, + }), + ); + expect(statusBar.show).not.toHaveBeenCalled(); + expect(statusBar.hide).toHaveBeenCalled(); + }); }); describe("notifications", () => {