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..6002e1644 --- /dev/null +++ b/extensions/vscode/src/utils/window.test.ts @@ -0,0 +1,168 @@ +// 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, + }, +}; + +const cancelTokenMock = { + onCancellationRequested: vi.fn(), +}; + +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(_, cancelTokenMock); + }), + 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", + ); + }); + + describe("taskWithProgressMsg", () => { + test("not cancellable", () => { + 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(); + expect(cancelTokenMock.onCancellationRequested).not.toHaveBeenCalled(); + }); + + test("cancellable", () => { + 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: true, + }, + expect.any(Function), + ); + // Cancel listener is registered + expect(taskMock).toHaveBeenCalled(); + expect(cancelTokenMock.onCancellationRequested).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..41e1af322 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,62 @@ 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, onCancel?: () => void): taskFunc => + (progress, token) => { + if (onCancel) { + token.onCancellationRequested(() => { + onCancel(); + }); + } + return task(progress, token); + }; + +export function taskWithProgressMsg( + msg: string, + task: taskFunc, + onCancel?: () => void, +) { + return window.withProgress( + { + location: ProgressLocation.Notification, + title: msg, + cancellable: onCancel !== undefined, + }, + progressCallbackHandlerFactory(task, 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; diff --git a/internal/inspect/dependencies/renv/manifest_packages.go b/internal/inspect/dependencies/renv/manifest_packages.go index 23fecacee..fc87640b4 100644 --- a/internal/inspect/dependencies/renv/manifest_packages.go +++ b/internal/inspect/dependencies/renv/manifest_packages.go @@ -6,11 +6,13 @@ import ( "errors" "fmt" "io/fs" + "os" "slices" "strconv" "strings" "github.com/posit-dev/publisher/internal/bundles" + "github.com/posit-dev/publisher/internal/interpreters" "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/types" "github.com/posit-dev/publisher/internal/util" @@ -21,16 +23,23 @@ type PackageMapper interface { GetManifestPackages(base util.AbsolutePath, lockfilePath util.AbsolutePath, log logging.Logger) (bundles.PackageMap, error) } +type rInterpreterFactory = func() (interpreters.RInterpreter, error) + type defaultPackageMapper struct { - lister AvailablePackagesLister + rInterpreterFactory rInterpreterFactory + rExecutable util.Path + lister AvailablePackagesLister } func NewPackageMapper(base util.AbsolutePath, rExecutable util.Path, log logging.Logger) (*defaultPackageMapper, error) { - lister, err := NewAvailablePackageLister(base, rExecutable, log, nil, nil) return &defaultPackageMapper{ - lister: lister, + rInterpreterFactory: func() (interpreters.RInterpreter, error) { + return interpreters.NewRInterpreter(base, rExecutable, log, nil, nil, nil) + }, + rExecutable: rExecutable, + lister: lister, }, err } @@ -185,6 +194,9 @@ func (m *defaultPackageMapper) GetManifestPackages( lockfile, err := ReadLockfile(lockfilePath) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, m.renvEnvironmentCheck(log) + } return nil, err } @@ -246,3 +258,17 @@ func (m *defaultPackageMapper) GetManifestPackages( } return manifestPackages, nil } + +func (m *defaultPackageMapper) renvEnvironmentCheck( + log logging.Logger, +) *types.AgentError { + rInterpreter, err := m.rInterpreterFactory() + if err != nil { + log.Error("R interpreter failed to be instantiated while verifying renv environment", "error", err.Error()) + verifyErr := types.NewAgentError(types.ErrorUnknown, err, nil) + verifyErr.Message = "Unable to determine if renv is installed" + return verifyErr + } + + return rInterpreter.RenvEnvironmentErrorCheck() +} diff --git a/internal/inspect/dependencies/renv/manifest_packages_test.go b/internal/inspect/dependencies/renv/manifest_packages_test.go index f39ea135a..f1a48609e 100644 --- a/internal/inspect/dependencies/renv/manifest_packages_test.go +++ b/internal/inspect/dependencies/renv/manifest_packages_test.go @@ -4,9 +4,11 @@ package renv import ( "encoding/json" + "errors" "testing" "github.com/posit-dev/publisher/internal/bundles" + "github.com/posit-dev/publisher/internal/interpreters" "github.com/posit-dev/publisher/internal/logging" "github.com/posit-dev/publisher/internal/types" "github.com/posit-dev/publisher/internal/util" @@ -256,3 +258,28 @@ func (s *ManifestPackagesSuite) TestMissingDescriptionFile() { s.ErrorIs(err, errPackageNotFound) s.Nil(manifestPackages) } + +func (s *ManifestPackagesSuite) TestMissingLockfile_BubblesUpRenvError() { + base := s.testdata.Join("cran_project") + lockfilePath := base.Join("does-not-exist.lock") + + mapper, err := NewPackageMapper(base, util.Path{}, s.log) + s.NoError(err) + + // Override interpeter factory to use a mock + renvAgentErr := types.NewAgentError( + types.ErrorRenvPackageNotInstalled, + errors.New("package renv is not installed. An renv lockfile is needed for deployment"), + nil) + rIntprMock := interpreters.NewMockRInterpreter() + rIntprMock.On("RenvEnvironmentErrorCheck").Return(renvAgentErr) + mapper.rInterpreterFactory = func() (interpreters.RInterpreter, error) { + return rIntprMock, nil + } + + _, err = mapper.GetManifestPackages(base, lockfilePath, logging.New()) + s.NotNil(err) + aerr, isAgentErr := types.IsAgentError(err) + s.Equal(isAgentErr, true) + s.Equal(aerr.Code, types.ErrorRenvPackageNotInstalled) +} diff --git a/internal/interpreters/r.go b/internal/interpreters/r.go index 3f2dade5e..fc2c5b896 100644 --- a/internal/interpreters/r.go +++ b/internal/interpreters/r.go @@ -29,11 +29,27 @@ func newInvalidRError(desc string, err error) *types.AgentError { type ExistsFunc func(p util.Path) (bool, error) +type RenvAction = string + +const ( + RenvSetup RenvAction = "renvsetup" + RenvInit RenvAction = "renvinit" + RenvSnapshot RenvAction = "renvsnapshot" + RenvStatus RenvAction = "renvstatus" +) + +type renvCommandObj struct { + Action RenvAction `json:"action"` + ActionLabel string `json:"actionLabel"` + Command string `json:"command"` +} + type RInterpreter interface { GetRExecutable() (util.AbsolutePath, error) GetRVersion() (string, error) GetLockFilePath() (util.RelativePath, bool, error) CreateLockfile(util.AbsolutePath) error + RenvEnvironmentErrorCheck() *types.AgentError } type defaultRInterpreter struct { @@ -173,6 +189,111 @@ func (i *defaultRInterpreter) IsRExecutableValid() bool { return i.rExecutable.Path.String() != "" && i.version != "" } +func (i *defaultRInterpreter) RenvEnvironmentErrorCheck() *types.AgentError { + rExec, err := i.GetRExecutable() + if err != nil { + i.log.Error("Could not get an R executable while determining if renv is installed", "error", err.Error()) + return i.cannotVerifyRenvErr(err) + } + + aerr := i.isRenvInstalled(rExec.String()) + if aerr != nil { + return aerr + } + + renvStatusOutput, aerr := i.renvStatus(rExec.String()) + if aerr != nil { + return aerr + } + + return i.prepRenvActionCommand(rExec.String(), renvStatusOutput) +} + +func (i *defaultRInterpreter) isRenvInstalled(rexecPath string) *types.AgentError { + args := []string{"-s", "-e", "cat(system.file(package = \"renv\"))"} + output, _, err := i.cmdExecutor.RunCommand(rexecPath, args, i.base, i.log) + if err != nil { + i.log.Error("Unable to determine if renv is installed", "error", err.Error()) + return types.NewAgentError( + types.ErrorUnknown, + errors.Join( + errors.New("unable to determine if renv is installed"), + err, + ), + nil) + } + + // If renv package is not installed, prep and send the terminal command that'll help the user + renvLibFile := string(output) + if renvLibFile == "" { + return types.NewAgentError( + types.ErrorRenvPackageNotInstalled, + errors.New("package renv is not installed. An renv lockfile is needed for deployment"), + renvCommandObj{ + Action: RenvSetup, + ActionLabel: "Setup renv", + Command: fmt.Sprintf(`%s -s -e "install.packages(\"renv\"); renv::init();"`, rexecPath), + }) + } + + return nil +} + +func (i *defaultRInterpreter) renvStatus(rexecPath string) (string, *types.AgentError) { + args := []string{"-s", "-e", "renv::status()"} + output, _, err := i.cmdExecutor.RunCommand(rexecPath, args, i.base, i.log) + if err != nil { + i.log.Error("Error attempting to run renv::status()", "error", err.Error()) + return "", types.NewAgentError( + types.ErrorUnknown, + errors.Join( + errors.New("error attempting to run renv::status()"), + err, + ), + nil) + } + + return string(output), nil +} + +func (i *defaultRInterpreter) prepRenvActionCommand(rexecPath string, renvStatus string) *types.AgentError { + // The default command suggested is renv::status() + renvDescError := errors.New("the renv environment for this project is not in a healthy state. Run renv::status() for more details") + commandObj := renvCommandObj{ + Action: RenvStatus, + ActionLabel: "Run and show renv::status()", + Command: fmt.Sprintf(`%s -s -e "renv::status()"`, rexecPath), + } + + if strings.Contains(renvStatus, "renv::init()") { + // Renv suggests to init() the project + renvDescError = errors.New(`project requires renv initialization "renv::init()" to be deployed`) + commandObj.Action = RenvInit + commandObj.ActionLabel = "Setup renv" + commandObj.Command = fmt.Sprintf(`%s -s -e "renv::init()"`, rexecPath) + } else if strings.Contains(renvStatus, "renv::snapshot()") { + // Renv suggests to snapshot(), only the lockfile is missing + renvDescError = errors.New(`project requires renv to update the lockfile to be deployed`) + commandObj.Action = RenvSnapshot + commandObj.ActionLabel = "Setup lockfile" + commandObj.Command = fmt.Sprintf(`%s -s -e "renv::snapshot()"`, rexecPath) + } + + return types.NewAgentError( + types.ErrorRenvActionRequired, + renvDescError, + commandObj) +} + +func (i *defaultRInterpreter) cannotVerifyRenvErr(err error) *types.AgentError { + if aerr, isAgentErr := types.IsAgentError(err); isAgentErr { + return aerr + } + verifyErr := types.NewAgentError(types.ErrorUnknown, err, nil) + verifyErr.Message = "Unable to determine if renv is installed" + return verifyErr +} + // Determine which R Executable to use by: // 1. If provided by user, (This is used to pass in selected versions from Positron and Extensions, // as well as from CLI). diff --git a/internal/interpreters/r_mock.go b/internal/interpreters/r_mock.go index 9510fdffe..e64687dc1 100644 --- a/internal/interpreters/r_mock.go +++ b/internal/interpreters/r_mock.go @@ -3,6 +3,7 @@ package interpreters // Copyright (C) 2024 by Posit Software, PBC. import ( + "github.com/posit-dev/publisher/internal/types" "github.com/posit-dev/publisher/internal/util" "github.com/stretchr/testify/mock" ) @@ -78,3 +79,8 @@ func (m *MockRInterpreter) CreateLockfile(lockfilePath util.AbsolutePath) error args := m.Called(lockfilePath) return args.Error(0) } + +func (m *MockRInterpreter) RenvEnvironmentErrorCheck() *types.AgentError { + args := m.Called() + return args.Error(0).(*types.AgentError) +} diff --git a/internal/interpreters/r_test.go b/internal/interpreters/r_test.go index f33d4c507..8381582af 100644 --- a/internal/interpreters/r_test.go +++ b/internal/interpreters/r_test.go @@ -595,3 +595,119 @@ func (s *RSuite) TestCreateLockfileWithEmptyPath() { err := i.CreateLockfile(util.AbsolutePath{}) s.NoError(err) } + +func (s *RSuite) TestRenvEnvironmentErrorCheck_renvNotInstalled() { + log := logging.New() + + executor := executortest.NewMockExecutor() + executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil) + executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte(""), nil, nil) + + i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil) + interpreter := i.(*defaultRInterpreter) + interpreter.rExecutable = util.NewAbsolutePath("/usr/bin/R", s.cwd.Fs()) + interpreter.version = "1.2.3" + interpreter.fs = s.cwd.Fs() + + err := i.RenvEnvironmentErrorCheck() + s.Error(err) + s.Equal(err.GetCode(), types.ErrorRenvPackageNotInstalled) + s.Equal(err.Message, "Package renv is not installed. An renv lockfile is needed for deployment.") + s.Equal(err.Data["Action"], "renvsetup") + s.Equal(err.Data["ActionLabel"], "Setup renv") + s.Contains(err.Data["Command"], "install.packages") + s.Contains(err.Data["Command"], "renv::init()") +} + +func (s *RSuite) TestRenvEnvironmentErrorCheck_renvInstallCheckErr() { + log := logging.New() + + renvCmdErr := errors.New("renv command errrz") + executor := executortest.NewMockExecutor() + executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil) + executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte(""), nil, renvCmdErr) + + i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil) + interpreter := i.(*defaultRInterpreter) + interpreter.rExecutable = util.NewAbsolutePath("/usr/bin/R", s.cwd.Fs()) + interpreter.version = "1.2.3" + interpreter.fs = s.cwd.Fs() + + err := i.RenvEnvironmentErrorCheck() + s.Error(err) + s.Equal(err.GetCode(), types.ErrorUnknown) + s.Equal(err.Message, "Unable to determine if renv is installed\nrenv command errrz.") + s.Equal(err.Data, types.ErrorData{}) +} + +func (s *RSuite) TestRenvEnvironmentErrorCheck_renvRequiresInit() { + log := logging.New() + + renvStatusOutput := []byte("Use `renv::init()` to initialize the project.") + executor := executortest.NewMockExecutor() + executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil) + executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil) + executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::status()"}, mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil) + + i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil) + interpreter := i.(*defaultRInterpreter) + interpreter.rExecutable = util.NewAbsolutePath("/usr/bin/R", s.cwd.Fs()) + interpreter.version = "1.2.3" + interpreter.fs = s.cwd.Fs() + + err := i.RenvEnvironmentErrorCheck() + s.Error(err) + s.Equal(err.GetCode(), types.ErrorRenvActionRequired) + s.Equal(err.Message, `Project requires renv initialization "renv::init()" to be deployed.`) + s.Equal(err.Data["Action"], "renvinit") + s.Equal(err.Data["ActionLabel"], "Setup renv") + s.Contains(err.Data["Command"], "renv::init()") +} + +func (s *RSuite) TestRenvEnvironmentErrorCheck_lockfileMissing() { + log := logging.New() + + renvStatusOutput := []byte("Use `renv::snapshot()` to create a lockfile.") + executor := executortest.NewMockExecutor() + executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil) + executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil) + executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::status()"}, mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil) + + i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil) + interpreter := i.(*defaultRInterpreter) + interpreter.rExecutable = util.NewAbsolutePath("/usr/bin/R", s.cwd.Fs()) + interpreter.version = "1.2.3" + interpreter.fs = s.cwd.Fs() + + err := i.RenvEnvironmentErrorCheck() + s.Error(err) + s.Equal(err.GetCode(), types.ErrorRenvActionRequired) + s.Equal(err.Message, `Project requires renv to update the lockfile to be deployed.`) + s.Equal(err.Data["Action"], "renvsnapshot") + s.Equal(err.Data["ActionLabel"], "Setup lockfile") + s.Contains(err.Data["Command"], "renv::snapshot()") +} + +func (s *RSuite) TestRenvEnvironmentErrorCheck_unknownRenvStatus() { + log := logging.New() + + renvStatusOutput := []byte("- The project is out-of-sync -- use `renv::status()` for details.") + executor := executortest.NewMockExecutor() + executor.On("RunCommand", mock.Anything, []string{"--version"}, mock.Anything, mock.Anything).Return([]byte("R version 4.3.0 (2023-04-21)"), nil, nil) + executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "cat(system.file(package = \"renv\"))"}, mock.Anything, mock.Anything).Return([]byte("/usr/dir/lib/R/x86_64/4.4/library/renv"), nil, nil) + executor.On("RunCommand", mock.Anything, []string{"-s", "-e", "renv::status()"}, mock.Anything, mock.Anything).Return(renvStatusOutput, nil, nil) + + i, _ := NewRInterpreter(s.cwd, util.Path{}, log, executor, nil, nil) + interpreter := i.(*defaultRInterpreter) + interpreter.rExecutable = util.NewAbsolutePath("/usr/bin/R", s.cwd.Fs()) + interpreter.version = "1.2.3" + interpreter.fs = s.cwd.Fs() + + err := i.RenvEnvironmentErrorCheck() + s.Error(err) + s.Equal(err.GetCode(), types.ErrorRenvActionRequired) + s.Equal(err.Message, `The renv environment for this project is not in a healthy state. Run renv::status() for more details.`) + s.Equal(err.Data["Action"], "renvstatus") + s.Equal(err.Data["ActionLabel"], "Run and show renv::status()") + s.Contains(err.Data["Command"], "renv::status()") +} diff --git a/internal/publish/r_package_descriptions.go b/internal/publish/r_package_descriptions.go index 28bbbc285..d15b83a28 100644 --- a/internal/publish/r_package_descriptions.go +++ b/internal/publish/r_package_descriptions.go @@ -3,9 +3,7 @@ package publish import ( - "errors" "fmt" - "os" "github.com/posit-dev/publisher/internal/bundles" "github.com/posit-dev/publisher/internal/events" @@ -21,8 +19,6 @@ type lockfileErrDetails struct { Lockfile string } -const lockfileMissing = `Missing dependency lockfile %s. This file must be included in the deployment.` - func (p *defaultPublisher) getRPackages() (bundles.PackageMap, error) { op := events.PublishGetRPackageDescriptionsOp log := p.log.WithArgs(logging.LogKeyOp, op) @@ -45,9 +41,6 @@ func (p *defaultPublisher) getRPackages() (bundles.PackageMap, error) { } agentErr := types.NewAgentError(types.ErrorRenvLockPackagesReading, err, lockfileErrDetails{Lockfile: lockfilePath.String()}) agentErr.Message = fmt.Sprintf("Could not scan R packages from lockfile: %s, %s", lockfileString, err.Error()) - if errors.Is(err, os.ErrNotExist) { - agentErr.Message = fmt.Sprintf(lockfileMissing, lockfileString) - } return nil, agentErr } log.Info("Done collecting R package descriptions") diff --git a/internal/publish/r_package_descriptions_test.go b/internal/publish/r_package_descriptions_test.go index 8e84ab96a..2c6e3f75c 100644 --- a/internal/publish/r_package_descriptions_test.go +++ b/internal/publish/r_package_descriptions_test.go @@ -4,7 +4,6 @@ package publish import ( "errors" - "os" "testing" "github.com/posit-dev/publisher/internal/bundles" @@ -158,22 +157,3 @@ func (s *RPackageDescSuite) TestGetRPackages_ScanPackagesKnownAgentError() { s.Equal(err.(*types.AgentError).Message, "Bad package version, this is a known failure.") s.log.AssertExpectations(s.T()) } - -func (s *RPackageDescSuite) TestGetRPackages_PackageFileNotFound() { - expectedLockfilePath := s.dirPath.Join("custom.lock") - - // With a package file - s.stateStore.Config = &config.Config{ - R: &config.R{ - PackageFile: "custom.lock", - }, - } - - s.packageMapper.On("GetManifestPackages", s.dirPath, expectedLockfilePath).Return(bundles.PackageMap{}, os.ErrNotExist) - - publisher := s.makePublisher() - _, err := publisher.getRPackages() - s.NotNil(err) - s.Contains(err.(*types.AgentError).Message, "Missing dependency lockfile custom.lock") - s.log.AssertExpectations(s.T()) -} diff --git a/internal/types/error.go b/internal/types/error.go index d1a7f2104..9935a04cb 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -25,6 +25,8 @@ const ( ErrorRenvPackageVersionMismatch ErrorCode = "renvPackageVersionMismatch" ErrorRenvPackageSourceMissing ErrorCode = "renvPackageSourceMissing" ErrorRenvLockPackagesReading ErrorCode = "renvlockPackagesReadingError" + ErrorRenvPackageNotInstalled ErrorCode = "renvPackageNotInstalledError" + ErrorRenvActionRequired ErrorCode = "renvActionRequiredError" ErrorRequirementsFileReading ErrorCode = "requirementsFileReadingError" ErrorDeployedContentNotRunning ErrorCode = "deployedContentNotRunning" ErrorUnknown ErrorCode = "unknown" @@ -53,7 +55,8 @@ type AgentError struct { // Normalize punctuation on messages derived from errors func normalizeAgentErrorMsg(errMsg string) string { spChars := []string{"?", "!", ")", "."} - msg := strings.ToUpper(errMsg[:1]) + errMsg[1:] + msg := strings.TrimSpace(errMsg) + msg = strings.ToUpper(msg[:1]) + msg[1:] msgLastChar := msg[len(msg)-1:] if slices.Contains(spChars, msgLastChar) { diff --git a/internal/types/error_test.go b/internal/types/error_test.go index 25a1889db..8b8b03b75 100644 --- a/internal/types/error_test.go +++ b/internal/types/error_test.go @@ -86,8 +86,8 @@ func (s *ErrorSuite) TestIsAgentError() { } func (s *ErrorSuite) TestNewAgentError_MessagePunctuation() { - // Sentence case and period ending - originalError := errors.New("oh sorry, my mistake") + // Sentence case, period ending, space trimming + originalError := errors.New(" oh sorry, my mistake ") aerr := NewAgentError(ErrorResourceNotFound, originalError, nil) s.Equal(aerr, &AgentError{ Message: "Oh sorry, my mistake.",