From eb00b691c484dccf18d34924f9d72e3fe879c9f7 Mon Sep 17 00:00:00 2001 From: Marcos Navarro Date: Tue, 28 Jan 2025 09:31:00 -0600 Subject: [PATCH] Handle renv issues and suggested commands on the UI --- extensions/vscode/src/eventErrors.ts | 21 ++ extensions/vscode/src/utils/errorTypes.ts | 2 + extensions/vscode/src/utils/window.test.ts | 141 ++++++++++++ extensions/vscode/src/utils/window.ts | 66 +++++- .../vscode/src/views/deployHandlers.test.ts | 209 ++++++++++++++++++ extensions/vscode/src/views/deployHandlers.ts | 69 ++++++ extensions/vscode/src/views/logs.ts | 6 + 7 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 extensions/vscode/src/utils/window.test.ts create mode 100644 extensions/vscode/src/views/deployHandlers.test.ts create mode 100644 extensions/vscode/src/views/deployHandlers.ts diff --git a/extensions/vscode/src/eventErrors.ts b/extensions/vscode/src/eventErrors.ts index 99288aff2..14e6363b7 100644 --- a/extensions/vscode/src/eventErrors.ts +++ b/extensions/vscode/src/eventErrors.ts @@ -36,6 +36,18 @@ type renvPackageEvtErr = baseEvtErr & { libraryVersion: string; }; +export type RenvAction = + | "renvsetup" + | "renvinit" + | "renvsnapshot" + | "renvstatus"; + +export type renvSetupEvtErr = baseEvtErr & { + command: string; + action: RenvAction; + actionLabel: string; +}; + export const isEvtErrDeploymentFailed = ( emsg: EventStreamMessageErrorCoded, ): emsg is EventStreamMessageErrorCoded => { @@ -60,6 +72,15 @@ export const isEvtErrRenvPackageSourceMissing = ( return emsg.errCode === "renvPackageSourceMissing"; }; +export const isEvtErrRenvEnvironmentSetup = ( + emsg: EventStreamMessageErrorCoded, +): emsg is EventStreamMessageErrorCoded => { + return ( + emsg.errCode === "renvPackageNotInstalledError" || + emsg.errCode === "renvActionRequiredError" + ); +}; + export const isEvtErrRequirementsReadingFailed = ( emsg: EventStreamMessageErrorCoded, ): emsg is EventStreamMessageErrorCoded => { diff --git a/extensions/vscode/src/utils/errorTypes.ts b/extensions/vscode/src/utils/errorTypes.ts index a19727b4f..ed7634cd7 100644 --- a/extensions/vscode/src/utils/errorTypes.ts +++ b/extensions/vscode/src/utils/errorTypes.ts @@ -13,6 +13,8 @@ export type ErrorCode = | "renvPackageVersionMismatch" | "renvPackageSourceMissing" | "renvlockPackagesReadingError" + | "renvPackageNotInstalledError" + | "renvActionRequiredError" | "requirementsFileReadingError" | "deployedContentNotRunning" | "tomlValidationError" diff --git a/extensions/vscode/src/utils/window.test.ts b/extensions/vscode/src/utils/window.test.ts new file mode 100644 index 000000000..7d2401bc6 --- /dev/null +++ b/extensions/vscode/src/utils/window.test.ts @@ -0,0 +1,141 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { describe, expect, beforeEach, test, vi } from "vitest"; +import { window } from "vscode"; +import { + showErrorMessageWithTroubleshoot, + showInformationMsg, + taskWithProgressMsg, + runTerminalCommand, +} from "./window"; + +const terminalMock = { + sendText: vi.fn(), + show: vi.fn(), + exitStatus: { + code: 0, + }, +}; + +vi.mock("vscode", () => { + // mock Disposable + const disposableMock = vi.fn(); + disposableMock.prototype.dispose = vi.fn(); + + // mock window + const windowMock = { + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + withProgress: vi.fn().mockImplementation((_, progressCallback) => { + progressCallback(); + }), + createTerminal: vi.fn().mockImplementation(() => { + terminalMock.sendText = vi.fn(); + terminalMock.show = vi.fn(); + return terminalMock; + }), + onDidCloseTerminal: vi.fn().mockImplementation((fn) => { + setTimeout(() => fn(terminalMock), 100); + return new disposableMock(); + }), + }; + + return { + Disposable: disposableMock, + window: windowMock, + ProgressLocation: { + SourceControl: 1, + Window: 10, + Notification: 15, + }, + }; +}); + +describe("Consumers of vscode window", () => { + beforeEach(() => { + terminalMock.exitStatus.code = 0; + }); + + test("showErrorMessageWithTroubleshoot", () => { + showErrorMessageWithTroubleshoot("Bad things happened"); + expect(window.showErrorMessage).toHaveBeenCalledWith( + "Bad things happened. See [Troubleshooting docs](https://github.com/posit-dev/publisher/blob/main/docs/troubleshooting.md) for help.", + ); + + showErrorMessageWithTroubleshoot( + "Bad things happened.", + "one", + "two", + "three", + ); + expect(window.showErrorMessage).toHaveBeenCalledWith( + "Bad things happened. See [Troubleshooting docs](https://github.com/posit-dev/publisher/blob/main/docs/troubleshooting.md) for help.", + "one", + "two", + "three", + ); + }); + + test("showInformationMsg", () => { + showInformationMsg("Good thing happened"); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "Good thing happened", + ); + + showInformationMsg("Good thing happened", "one", "two", "three"); + expect(window.showInformationMessage).toHaveBeenCalledWith( + "Good thing happened", + "one", + "two", + "three", + ); + }); + + test("taskWithProgressMsg", () => { + const taskMock = vi.fn(); + taskWithProgressMsg( + "Running a task with a progress notification", + taskMock, + ); + expect(window.withProgress).toHaveBeenCalledWith( + { + location: 15, + title: "Running a task with a progress notification", + cancellable: false, + }, + expect.any(Function), + ); + expect(taskMock).toHaveBeenCalled(); + }); + + describe("runTerminalCommand", () => { + test("showing the terminal", async () => { + await runTerminalCommand("stat somefile.txt", true); + expect(terminalMock.sendText).toHaveBeenCalledWith("stat somefile.txt"); + expect(terminalMock.show).toHaveBeenCalled(); + // For terminals that we open, we don't track close events + expect(window.onDidCloseTerminal).not.toHaveBeenCalled(); + }); + + test("NOT showing the terminal", async () => { + await runTerminalCommand("stat somefile.txt"); + expect(terminalMock.sendText).toHaveBeenCalledWith("stat somefile.txt"); + // For terminals that we DO NOT open, we DO track close events + expect(terminalMock.show).not.toHaveBeenCalled(); + expect(window.onDidCloseTerminal).toHaveBeenCalled(); + }); + + test("catch non zero exit status", async () => { + terminalMock.exitStatus.code = 1; + try { + await runTerminalCommand("stat somefile.txt"); + } catch (_) { + expect(terminalMock.sendText).toHaveBeenCalledWith("stat somefile.txt"); + // For terminals that we DO NOT open, we DO track close events + expect(terminalMock.show).not.toHaveBeenCalled(); + expect(window.onDidCloseTerminal).toHaveBeenCalled(); + } + }); + }); +}); diff --git a/extensions/vscode/src/utils/window.ts b/extensions/vscode/src/utils/window.ts index e0387a3d2..1a3a39f11 100644 --- a/extensions/vscode/src/utils/window.ts +++ b/extensions/vscode/src/utils/window.ts @@ -1,6 +1,6 @@ // Copyright (C) 2025 by Posit Software, PBC. -import { window } from "vscode"; +import { window, ProgressLocation, Progress, CancellationToken } from "vscode"; export function showErrorMessageWithTroubleshoot( message: string, @@ -14,3 +14,67 @@ export function showErrorMessageWithTroubleshoot( " See [Troubleshooting docs](https://github.com/posit-dev/publisher/blob/main/docs/troubleshooting.md) for help."; return window.showErrorMessage(msg, ...items); } + +export function showInformationMsg(msg: string, ...items: string[]) { + return window.showInformationMessage(msg, ...items); +} + +type taskFunc = (p: Progress, t: CancellationToken) => Promise; +const progressCallbackHandlerFactory = + ( + task: taskFunc, + cancellable: boolean = false, + onCancel?: () => void, + ): taskFunc => + (progress, token) => { + if (cancellable && onCancel) { + token.onCancellationRequested(() => { + onCancel(); + }); + } + return task(progress, token); + }; + +export function taskWithProgressMsg( + msg: string, + task: taskFunc, + cancellable: boolean = false, + onCancel?: () => void, +) { + return window.withProgress( + { + location: ProgressLocation.Notification, + title: msg, + cancellable, + }, + progressCallbackHandlerFactory(task, cancellable, onCancel), + ); +} + +export function runTerminalCommand( + cmd: string, + show: boolean = false, +): Promise { + const term = window.createTerminal(); + term.sendText(cmd); + + // If terminal is shown, there is no need to track exit status for it + // everything will be visible on it. + if (show) { + term.show(); + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const disposeToken = window.onDidCloseTerminal((closedTerminal) => { + if (closedTerminal === term) { + disposeToken.dispose(); + if (term.exitStatus && term.exitStatus.code === 0) { + resolve(); + } else { + reject(); + } + } + }); + }); +} diff --git a/extensions/vscode/src/views/deployHandlers.test.ts b/extensions/vscode/src/views/deployHandlers.test.ts new file mode 100644 index 000000000..72173f39c --- /dev/null +++ b/extensions/vscode/src/views/deployHandlers.test.ts @@ -0,0 +1,209 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { EventStreamMessage } from "src/api"; +import { + EventStreamMessageErrorCoded, + RenvAction, + renvSetupEvtErr, +} from "src/eventErrors"; +import { DeploymentFailureRenvHandler } from "./deployHandlers"; + +const windowMethodsMocks = { + showErrorMessageWithTroubleshoot: vi.fn(), + showInformationMsg: vi.fn(), + runTerminalCommand: vi.fn(), + taskWithProgressMsg: vi.fn((_: string, cb: () => void) => cb()), +}; + +vi.mock("src/utils/window", () => { + return { + runTerminalCommand: (c: string, show: boolean = false) => + windowMethodsMocks.runTerminalCommand(c, show), + showInformationMsg: (m: string) => windowMethodsMocks.showInformationMsg(m), + taskWithProgressMsg: (m: string, cb: () => void) => + windowMethodsMocks.taskWithProgressMsg(m, cb), + showErrorMessageWithTroubleshoot: (m: string, l: string) => + windowMethodsMocks.showErrorMessageWithTroubleshoot(m, l), + }; +}); + +describe("Deploy Handlers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("DeploymentFailureRenvHandler", () => { + describe("shouldHandleEventMsg", () => { + test("not an renv setup error event", () => { + const handler = new DeploymentFailureRenvHandler(); + const shouldHandle = handler.shouldHandleEventMsg({ + type: "publish/failure", + time: new Date().toISOString(), + data: {}, + errCode: "unknown", + }); + expect(shouldHandle).toBe(false); + }); + + test("a true renv setup error event", () => { + const errPayload: EventStreamMessage = { + type: "publish/failure", + time: new Date().toISOString(), + data: { + message: "Renv is not setup, click the button and smile", + command: "renv::init()", + action: "renvsetup", + actionLabel: "Set it up", + }, + errCode: "renvPackageNotInstalledError", + }; + + const handler = new DeploymentFailureRenvHandler(); + let shouldHandle = handler.shouldHandleEventMsg(errPayload); + expect(shouldHandle).toBe(true); + + errPayload.errCode = "renvActionRequiredError"; + shouldHandle = handler.shouldHandleEventMsg(errPayload); + expect(shouldHandle).toBe(true); + }); + }); + + describe("handle", () => { + test("when suggested action is renv::status, we call to open the terminal without progress indicators", async () => { + const errData: renvSetupEvtErr = { + message: "Renv is not setup, click the button and smile", + command: "/user/lib/R renv::status()", + action: "renvstatus", + actionLabel: "Set it up", + dashboardUrl: "", + localId: "", + error: "", + }; + const errPayload: EventStreamMessageErrorCoded = { + type: "publish/failure", + time: new Date().toISOString(), + data: errData, + errCode: "renvActionRequiredError", + }; + + // Fake user picks option to run setup command + windowMethodsMocks.showErrorMessageWithTroubleshoot.mockResolvedValue( + errData.actionLabel, + ); + + const handler = new DeploymentFailureRenvHandler(); + await handler.handle(errPayload); + expect( + windowMethodsMocks.showErrorMessageWithTroubleshoot, + ).toHaveBeenCalledWith(errData.message, errData.actionLabel); + expect(windowMethodsMocks.runTerminalCommand).toHaveBeenCalledWith( + "/user/lib/R renv::status();", + true, + ); + + // No progrss indicator is shown when we open the terminal + expect(windowMethodsMocks.taskWithProgressMsg).not.toHaveBeenCalled(); + }); + + test.each([ + ["renvsetup", `/user/lib/R install.packages("renv"); renv::init();`], + ["renvinit", "/user/lib/R renv::init()"], + ["renvsnapshot", "/user/lib/R renv::snapshot()"], + ])( + "for action %s, the provided command runs and notifies success", + async (action: string, command: string) => { + const errData: renvSetupEvtErr = { + message: "Renv is not setup, click the button and smile", + command, + action: action as RenvAction, + actionLabel: "Set it up", + dashboardUrl: "", + localId: "", + error: "", + }; + const errPayload: EventStreamMessageErrorCoded = { + type: "publish/failure", + time: new Date().toISOString(), + data: errData, + errCode: "renvActionRequiredError", + }; + + // Fake user picks option to run setup command + windowMethodsMocks.showErrorMessageWithTroubleshoot.mockResolvedValueOnce( + errData.actionLabel, + ); + + const handler = new DeploymentFailureRenvHandler(); + await handler.handle(errPayload); + expect( + windowMethodsMocks.showErrorMessageWithTroubleshoot, + ).toHaveBeenCalledWith(errData.message, errData.actionLabel); + expect(windowMethodsMocks.taskWithProgressMsg).toHaveBeenCalledWith( + "Setting up renv for this project...", + expect.any(Function), + ); + // Terminal command executed + expect(windowMethodsMocks.runTerminalCommand).toHaveBeenCalledWith( + `${command}; exit $?`, + false, + ); + expect(windowMethodsMocks.showInformationMsg).toHaveBeenCalledWith( + "Finished setting up renv.", + ); + }, + ); + + test.each([ + ["renvsetup", `/user/lib/R install.packages("renv"); renv::init();`], + ["renvinit", "/user/lib/R renv::init()"], + ["renvsnapshot", "/user/lib/R renv::snapshot()"], + ])( + "terminal run failure for action %s, notifies of error", + async (action: string, command: string) => { + const errData: renvSetupEvtErr = { + message: "Renv is not setup, click the button and smile", + command, + action: action as RenvAction, + actionLabel: "Set it up", + dashboardUrl: "", + localId: "", + error: "", + }; + const errPayload: EventStreamMessageErrorCoded = { + type: "publish/failure", + time: new Date().toISOString(), + data: errData, + errCode: "renvActionRequiredError", + }; + + // Fake user picks option to run setup command + windowMethodsMocks.showErrorMessageWithTroubleshoot.mockResolvedValueOnce( + errData.actionLabel, + ); + + // Mock a rejection from terminal run + windowMethodsMocks.taskWithProgressMsg.mockRejectedValueOnce( + new Error("oh no"), + ); + + const handler = new DeploymentFailureRenvHandler(); + await handler.handle(errPayload); + expect( + windowMethodsMocks.showErrorMessageWithTroubleshoot, + ).toHaveBeenCalledWith(errData.message, errData.actionLabel); + expect(windowMethodsMocks.taskWithProgressMsg).toHaveBeenCalled(); + + // The message shown is a troubleshoot error one + expect(windowMethodsMocks.showInformationMsg).not.toHaveBeenCalled(); + expect( + windowMethodsMocks.showErrorMessageWithTroubleshoot, + ).toHaveBeenCalledWith( + `Something went wrong while running renv command. Command used ${command}`, + undefined, + ); + }, + ); + }); + }); +}); diff --git a/extensions/vscode/src/views/deployHandlers.ts b/extensions/vscode/src/views/deployHandlers.ts new file mode 100644 index 000000000..58ec6e597 --- /dev/null +++ b/extensions/vscode/src/views/deployHandlers.ts @@ -0,0 +1,69 @@ +// Copyright (C) 2025 by Posit Software, PBC. + +import { EventStreamMessage } from "src/api"; +import { + EventStreamMessageErrorCoded, + isEvtErrRenvEnvironmentSetup, + isCodedEventErrorMessage, + renvSetupEvtErr, +} from "src/eventErrors"; +import { + showErrorMessageWithTroubleshoot, + showInformationMsg, + runTerminalCommand, + taskWithProgressMsg, +} from "src/utils/window"; + +export interface DeploymentFailureHandler { + /** + * Determine if the deployment failure handler should do something with an incoming event stream message. + * + * @param msg The EventStreamMessage to determine if the deployment failure handler should do something about it. + */ + shouldHandleEventMsg(msg: EventStreamMessage): boolean; + + /** + * Handle the incoming event stream message. + * + * @param msg The EventStreamMessage with the details for the given deployment failure handler to act upon it. + */ + handle(msg: EventStreamMessage): Promise; +} + +export class DeploymentFailureRenvHandler implements DeploymentFailureHandler { + shouldHandleEventMsg( + msg: EventStreamMessage, + ): msg is EventStreamMessageErrorCoded { + return isCodedEventErrorMessage(msg) && isEvtErrRenvEnvironmentSetup(msg); + } + + async handle( + msg: EventStreamMessageErrorCoded, + ): Promise { + const { message, command, action, actionLabel } = msg.data; + const selection = await showErrorMessageWithTroubleshoot( + message, + actionLabel, + ); + + if (selection !== actionLabel) { + return; + } + + // If renv status is the action, then run the command and open the terminal + if (action === "renvstatus") { + return runTerminalCommand(`${command};`, true); + } + + try { + await taskWithProgressMsg("Setting up renv for this project...", () => + runTerminalCommand(`${command}; exit $?`), + ); + showInformationMsg("Finished setting up renv."); + } catch (_) { + showErrorMessageWithTroubleshoot( + `Something went wrong while running renv command. Command used ${command}`, + ); + } + } +} diff --git a/extensions/vscode/src/views/logs.ts b/extensions/vscode/src/views/logs.ts index 7a809c202..cb4adc5f4 100644 --- a/extensions/vscode/src/views/logs.ts +++ b/extensions/vscode/src/views/logs.ts @@ -34,6 +34,7 @@ import { findErrorMessageSplitOption, } from "src/utils/errorEnhancer"; import { showErrorMessageWithTroubleshoot } from "src/utils/window"; +import { DeploymentFailureRenvHandler } from "src/views/deployHandlers"; enum LogStageStatus { notStarted, @@ -184,6 +185,11 @@ export class LogsTreeDataProvider implements TreeDataProvider { }); this.stream.register("publish/failure", async (msg: EventStreamMessage) => { + const deploymentFailureRenvHandler = new DeploymentFailureRenvHandler(); + if (deploymentFailureRenvHandler.shouldHandleEventMsg(msg)) { + return deploymentFailureRenvHandler.handle(msg).then(this.refresh); + } + const failedOrCanceledStatus = msg.data.canceled ? LogStageStatus.canceled : LogStageStatus.failed;