From 3fc39f8878591522d6066e00f5902231c7496b9f Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 14 Aug 2025 12:16:01 -0700 Subject: [PATCH] Adds warnings for missing powershell --- cli/azd/cmd/hooks.go | 2 +- cli/azd/cmd/middleware/hooks.go | 55 +++- cli/azd/cmd/middleware/hooks_test.go | 234 ++++++++++++++++++ cli/azd/pkg/exec/command_runner.go | 17 ++ cli/azd/pkg/ext/hooks_manager.go | 78 +++++- cli/azd/pkg/ext/hooks_manager_test.go | 16 +- cli/azd/pkg/ext/hooks_runner_test.go | 20 +- cli/azd/pkg/ext/models.go | 26 ++ cli/azd/pkg/helm/cli.go | 3 +- cli/azd/pkg/kubelogin/cli.go | 3 +- cli/azd/pkg/kustomize/cli.go | 3 +- cli/azd/pkg/tools/az/az.go | 3 +- cli/azd/pkg/tools/docker/docker.go | 2 +- cli/azd/pkg/tools/dotnet/dotnet.go | 2 +- cli/azd/pkg/tools/git/git.go | 2 +- cli/azd/pkg/tools/kubectl/kubectl.go | 2 +- cli/azd/pkg/tools/npm/npm.go | 2 +- cli/azd/pkg/tools/powershell/powershell.go | 46 ++-- .../pkg/tools/powershell/powershell_test.go | 77 +++--- cli/azd/pkg/tools/python/python.go | 22 +- cli/azd/pkg/tools/python/python_test.go | 15 +- cli/azd/pkg/tools/swa/swa.go | 2 +- cli/azd/pkg/tools/terraform/terraform.go | 2 +- cli/azd/pkg/tools/tool.go | 18 -- cli/azd/pkg/tools/tool_test.go | 7 - cli/azd/test/mocks/mockexec/mock_runner.go | 22 +- 26 files changed, 521 insertions(+), 160 deletions(-) diff --git a/cli/azd/cmd/hooks.go b/cli/azd/cmd/hooks.go index 906f5366dff..1194b92be2f 100644 --- a/cli/azd/cmd/hooks.go +++ b/cli/azd/cmd/hooks.go @@ -247,7 +247,7 @@ func (hra *hooksRunAction) execHook( hookName: {hook}, } - hooksManager := ext.NewHooksManager(cwd) + hooksManager := ext.NewHooksManager(cwd, hra.commandRunner) hooksRunner := ext.NewHooksRunner( hooksManager, hra.commandRunner, hra.envManager, hra.console, cwd, hooksMap, hra.env, hra.serviceLocator) diff --git a/cli/azd/cmd/middleware/hooks.go b/cli/azd/cmd/middleware/hooks.go index d426a87eba7..0c7eedd7c55 100644 --- a/cli/azd/cmd/middleware/hooks.go +++ b/cli/azd/cmd/middleware/hooks.go @@ -15,6 +15,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" ) @@ -66,6 +67,13 @@ func (m *HooksMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action return next(ctx) } + // Validate hooks and display any warnings + if !m.options.IsChildAction(ctx) { + if err := m.validateHooks(ctx, projectConfig); err != nil { + return nil, fmt.Errorf("failed validating hooks, %w", err) + } + } + if err := m.registerServiceHooks(ctx, env, projectConfig); err != nil { return nil, fmt.Errorf("failed registering service hooks, %w", err) } @@ -93,7 +101,7 @@ func (m *HooksMiddleware) registerCommandHooks( return nil, fmt.Errorf("failed getting environment manager, %w", err) } - hooksManager := ext.NewHooksManager(projectConfig.Path) + hooksManager := ext.NewHooksManager(projectConfig.Path, m.commandRunner) hooksRunner := ext.NewHooksRunner( hooksManager, m.commandRunner, @@ -152,7 +160,7 @@ func (m *HooksMiddleware) registerServiceHooks( continue } - serviceHooksManager := ext.NewHooksManager(service.Path()) + serviceHooksManager := ext.NewHooksManager(service.Path(), m.commandRunner) serviceHooksRunner := ext.NewHooksRunner( serviceHooksManager, m.commandRunner, @@ -198,3 +206,46 @@ func (m *HooksMiddleware) createServiceEventHandler( return hooksRunner.RunHooks(ctx, hookType, nil, hookName) } } + +// validateHooks validates hook configurations and displays any warnings +func (m *HooksMiddleware) validateHooks(ctx context.Context, projectConfig *project.ProjectConfig) error { + // Get service hooks for validation + var serviceHooks []map[string][]*ext.HookConfig + stableServices, err := m.importManager.ServiceStable(ctx, projectConfig) + if err != nil { + return fmt.Errorf("failed getting services for hook validation: %w", err) + } + + for _, service := range stableServices { + serviceHooks = append(serviceHooks, service.Hooks) + } + + // Combine project and service hooks into a single map + allHooks := make(map[string][]*ext.HookConfig) + + // Add project hooks + for hookName, hookConfigs := range projectConfig.Hooks { + allHooks[hookName] = append(allHooks[hookName], hookConfigs...) + } + + // Add service hooks + for _, serviceHookMap := range serviceHooks { + for hookName, hookConfigs := range serviceHookMap { + allHooks[hookName] = append(allHooks[hookName], hookConfigs...) + } + } + + // Create hooks manager and validate + hooksManager := ext.NewHooksManager(projectConfig.Path, m.commandRunner) + validationResult := hooksManager.ValidateHooks(ctx, allHooks) + + // Display any warnings + for _, warning := range validationResult.Warnings { + m.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: warning.Message, + }) + m.console.Message(ctx, "") + } + + return nil +} diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index 6b7494a8181..87ac47d070c 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -6,6 +6,7 @@ package middleware import ( "context" "errors" + osexec "os/exec" "strings" "testing" @@ -411,3 +412,236 @@ func ensureAzdProject(ctx context.Context, azdContext *azdcontext.AzdContext, pr return nil } + +func Test_PowerShellWarning_WithPowerShellHooks(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + azdContext := createAzdContext(t) + + envName := "test" + runOptions := Options{CommandPath: "command"} + + projectConfig := project.ProjectConfig{ + Name: envName, + Hooks: map[string][]*ext.HookConfig{ + "preprovision": { + { + Run: "Write-Host 'hello'", + Shell: ext.ShellTypePowershell, + }, + }, + }, + } + + err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig) + require.NoError(t, err) + + nextFn, actionRan := createNextFn() + setupHookMock(mockContext, 0) + + // Mock toolInPath to simulate pwsh not being available but powershell available + mockContext.CommandRunner.MockToolInPath("pwsh", osexec.ErrNotFound) + mockContext.CommandRunner.MockToolInPath("powershell", nil) // powershell is available + + result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn) + + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, *actionRan) + + // Check that PowerShell warning was displayed (specifically for PowerShell 5.1) + consoleOutput := mockContext.Console.Output() + t.Logf("Console output: %v", consoleOutput) + foundWarning := false + for _, message := range consoleOutput { + if strings.Contains(message, "Your computer only has PowerShell 5.1 (`powershell`) installed") { + foundWarning = true + break + } + } + require.True(t, foundWarning, "Expected PowerShell 5.1 warning to be displayed") +} + +func Test_PowerShellWarning_WithPs1FileHook(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + azdContext := createAzdContext(t) + + envName := "test" + runOptions := Options{CommandPath: "command"} + + projectConfig := project.ProjectConfig{ + Name: envName, + Hooks: map[string][]*ext.HookConfig{ + "preprovision": { + { + Run: "script.ps1", // PowerShell file extension + Shell: ext.ShellTypePowershell, // Explicitly specify shell to avoid detection issues + }, + }, + }, + } + + err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig) + require.NoError(t, err) + + nextFn, actionRan := createNextFn() + setupHookMock(mockContext, 0) + + // Mock toolInPath to simulate pwsh not being available + mockContext.CommandRunner.MockToolInPath("pwsh", osexec.ErrNotFound) + + result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn) + + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, *actionRan) + + // Check that PowerShell warning was displayed + consoleOutput := mockContext.Console.Output() + foundWarning := false + for _, message := range consoleOutput { + if strings.Contains(message, "PowerShell 7 (`pwsh`) commands found in project") { + foundWarning = true + break + } + } + require.True(t, foundWarning, "Expected PowerShell warning to be displayed for .ps1 file") +} + +func Test_PowerShellWarning_WithoutPowerShellHooks(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + azdContext := createAzdContext(t) + + envName := "test" + runOptions := Options{CommandPath: "command"} + + projectConfig := project.ProjectConfig{ + Name: envName, + Hooks: map[string][]*ext.HookConfig{ + "precommand": { + { + Run: "echo 'hello'", + Shell: ext.ShellTypeBash, + }, + }, + }, + } + + err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig) + require.NoError(t, err) + + nextFn, actionRan := createNextFn() + setupHookMock(mockContext, 0) + + // Mock toolInPath to simulate pwsh not being available + + result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn) + + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, *actionRan) + + // Check that no PowerShell warning was displayed + consoleOutput := mockContext.Console.Output() + foundWarning := false + for _, message := range consoleOutput { + if strings.Contains(message, "PowerShell 7 (`pwsh`) commands found in project") { + foundWarning = true + break + } + } + require.False(t, foundWarning, "Expected no PowerShell warning for bash hooks") +} + +func Test_PowerShellWarning_WithPwshAvailable(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + azdContext := createAzdContext(t) + + envName := "test" + runOptions := Options{CommandPath: "command"} + + projectConfig := project.ProjectConfig{ + Name: envName, + Hooks: map[string][]*ext.HookConfig{ + "precommand": { + { + Run: "Write-Host 'hello'", + Shell: ext.ShellTypePowershell, + }, + }, + }, + } + + err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig) + require.NoError(t, err) + + nextFn, actionRan := createNextFn() + setupHookMock(mockContext, 0) + + // Mock toolInPath to simulate pwsh being available + mockContext.CommandRunner.MockToolInPath("pwsh", nil) + + result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn) + + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, *actionRan) + + // Check that no PowerShell warning was displayed + consoleOutput := mockContext.Console.Output() + foundWarning := false + for _, message := range consoleOutput { + if strings.Contains(message, "PowerShell 7 (`pwsh`) commands found in project") { + foundWarning = true + break + } + } + require.False(t, foundWarning, "Expected no PowerShell warning when pwsh is available") +} + +func Test_PowerShellWarning_WithNoPowerShellInstalled(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + azdContext := createAzdContext(t) + + envName := "test" + runOptions := Options{CommandPath: "command"} + + projectConfig := project.ProjectConfig{ + Name: envName, + Hooks: map[string][]*ext.HookConfig{ + "preprovision": { + { + Run: "Write-Host 'hello'", + Shell: ext.ShellTypePowershell, + }, + }, + }, + } + + err := ensureAzdValid(mockContext, azdContext, envName, &projectConfig) + require.NoError(t, err) + + nextFn, actionRan := createNextFn() + setupHookMock(mockContext, 0) + + // Mock toolInPath to simulate neither pwsh nor powershell being available + mockContext.CommandRunner.MockToolInPath("pwsh", osexec.ErrNotFound) + mockContext.CommandRunner.MockToolInPath("powershell", osexec.ErrNotFound) + + result, err := runMiddleware(mockContext, envName, &projectConfig, &runOptions, nextFn) + + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, *actionRan) + + // Check that the correct PowerShell warning was displayed (no PowerShell installation detected) + consoleOutput := mockContext.Console.Output() + t.Logf("Console output: %v", consoleOutput) + foundWarning := false + for _, message := range consoleOutput { + if strings.Contains(message, "No PowerShell installation detected") { + foundWarning = true + break + } + } + require.True(t, foundWarning, "Expected 'No PowerShell installation detected' warning to be displayed") +} diff --git a/cli/azd/pkg/exec/command_runner.go b/cli/azd/pkg/exec/command_runner.go index 560fc837329..06900dddb2a 100644 --- a/cli/azd/pkg/exec/command_runner.go +++ b/cli/azd/pkg/exec/command_runner.go @@ -26,6 +26,7 @@ type CmdTreeOptions struct { type CommandRunner interface { Run(ctx context.Context, args RunArgs) (RunResult, error) RunList(ctx context.Context, commands []string, args RunArgs) (RunResult, error) + ToolInPath(name string) error } type RunnerOptions struct { @@ -253,6 +254,22 @@ func (r *commandRunner) RunList(ctx context.Context, commands []string, args Run return result, err } +// ToolInPath checks to see if a program can be found on the PATH, as exec.LookPath +// does, returns exec.ErrNotFound in the case where os.LookPath would return +// exec.ErrNotFound and other errors. +func (r *commandRunner) ToolInPath(name string) error { + _, err := exec.LookPath(name) + + switch { + case err == nil: + return nil + case errors.Is(err, exec.ErrNotFound): + return exec.ErrNotFound + default: + return fmt.Errorf("failed searching for `%s` on PATH: %w", name, err) + } +} + func appendEnv(env []string) []string { if len(env) > 0 { return append(os.Environ(), env...) diff --git a/cli/azd/pkg/ext/hooks_manager.go b/cli/azd/pkg/ext/hooks_manager.go index 55f5a3cab4c..5c61c1fae98 100644 --- a/cli/azd/pkg/ext/hooks_manager.go +++ b/cli/azd/pkg/ext/hooks_manager.go @@ -4,11 +4,17 @@ package ext import ( + "context" + "errors" "fmt" "log" "os" + osexec "os/exec" "runtime" "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/output" ) type HookFilterPredicateFn func(scriptName string, hookConfig *HookConfig) bool @@ -16,13 +22,15 @@ type HookFilterPredicateFn func(scriptName string, hookConfig *HookConfig) bool // Hooks enable support to invoke integration scripts before & after commands // Scripts can be invoked at the project or service level or type HooksManager struct { - cwd string + cwd string + commandRunner exec.CommandRunner } // NewHooks creates a new instance of CommandHooks // When `cwd` is empty defaults to current shell working directory func NewHooksManager( cwd string, + commandRunner exec.CommandRunner, ) *HooksManager { if cwd == "" { osWd, err := os.Getwd() @@ -34,7 +42,8 @@ func NewHooksManager( } return &HooksManager{ - cwd: cwd, + cwd: cwd, + commandRunner: commandRunner, } } @@ -114,3 +123,68 @@ func (h *HooksManager) filterConfigs( return matchingHooks, nil } + +// HookValidationResult contains warnings found during hook validation +type HookValidationResult struct { + Warnings []HookWarning +} + +// HookWarning represents a validation warning for hooks +type HookWarning struct { + Message string +} + +// ValidateHooks validates hook configurations and returns any warnings +func (h *HooksManager) ValidateHooks(ctx context.Context, allHooks map[string][]*HookConfig) *HookValidationResult { + result := &HookValidationResult{ + Warnings: []HookWarning{}, + } + + hasPowerShellHooks := false + + // Check all hooks + for _, hookConfigs := range allHooks { + for _, hookConfig := range hookConfigs { + if hookConfig.IsPowerShellHook() { + hasPowerShellHooks = true + break + } + } + if hasPowerShellHooks { + break + } + } + + // If we found PowerShell hooks, check if pwsh is available + if hasPowerShellHooks { + if err := h.commandRunner.ToolInPath("pwsh"); errors.Is(err, osexec.ErrNotFound) { + var warningMessage string + + // Check if legacy powershell is available + if powershellErr := h.commandRunner.ToolInPath("powershell"); !errors.Is(powershellErr, osexec.ErrNotFound) { + //nolint:lll + warningMessage = "PowerShell 7 (`pwsh`) commands found in project. Your computer only has PowerShell 5.1 (`powershell`) installed. azd will use `powershell` but errors may occur.\n\nTo resolve warning, install `pwsh`" + } else { + //nolint:lll + warningMessage = "PowerShell 7 (`pwsh`) commands found in project. No PowerShell installation detected. Powershell scripts will fail. \n\nTo resolve warning, install `pwsh`" + } + + // Append install instructions link + warningMessage = fmt.Sprintf( + "%s (%s)", + warningMessage, + output.WithHyperlink( + //nolint:lll + "https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.4", + "Install Instructions", + ), + ) + + result.Warnings = append(result.Warnings, HookWarning{ + Message: warningMessage, + }) + } + } + + return result +} diff --git a/cli/azd/pkg/ext/hooks_manager_test.go b/cli/azd/pkg/ext/hooks_manager_test.go index 278dbfcf683..d567abab70a 100644 --- a/cli/azd/pkg/ext/hooks_manager_test.go +++ b/cli/azd/pkg/ext/hooks_manager_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" "github.com/azure/azure-dev/cli/azd/test/ostest" "github.com/stretchr/testify/require" ) @@ -35,7 +36,8 @@ func Test_GetAllHookConfigs(t *testing.T) { ensureScriptsExist(t, hooksMap) - hooksManager := NewHooksManager(tempDir) + mockCommandRunner := mockexec.NewMockCommandRunner() + hooksManager := NewHooksManager(tempDir, mockCommandRunner) validHooks, err := hooksManager.GetAll(hooksMap) require.Len(t, validHooks, len(hooksMap)) @@ -59,7 +61,8 @@ func Test_GetAllHookConfigs(t *testing.T) { ensureScriptsExist(t, hooksMap) - hooksManager := NewHooksManager(tempDir) + mockCommandRunner := mockexec.NewMockCommandRunner() + hooksManager := NewHooksManager(tempDir, mockCommandRunner) validHooks, err := hooksManager.GetAll(hooksMap) require.Nil(t, validHooks) @@ -72,7 +75,8 @@ func Test_GetAllHookConfigs(t *testing.T) { "preprovision": nil, } - hooksManager := NewHooksManager(tempDir) + mockCommandRunner := mockexec.NewMockCommandRunner() + hooksManager := NewHooksManager(tempDir, mockCommandRunner) validHooks, err := hooksManager.GetAll(hooksMap) require.NoError(t, err) @@ -101,7 +105,8 @@ func Test_GetByParams(t *testing.T) { ensureScriptsExist(t, hooksMap) - hooksManager := NewHooksManager(tempDir) + mockCommandRunner := mockexec.NewMockCommandRunner() + hooksManager := NewHooksManager(tempDir, mockCommandRunner) validHooks, err := hooksManager.GetByParams(hooksMap, HookTypePre, "init") require.Len(t, validHooks, 1) @@ -126,7 +131,8 @@ func Test_GetByParams(t *testing.T) { ensureScriptsExist(t, hooksMap) - hooksManager := NewHooksManager(tempDir) + mockCommandRunner := mockexec.NewMockCommandRunner() + hooksManager := NewHooksManager(tempDir, mockCommandRunner) validHooks, err := hooksManager.GetByParams(hooksMap, HookTypePre, "init") require.Nil(t, validHooks) diff --git a/cli/azd/pkg/ext/hooks_runner_test.go b/cli/azd/pkg/ext/hooks_runner_test.go index 6917e9ce068..f838253853a 100644 --- a/cli/azd/pkg/ext/hooks_runner_test.go +++ b/cli/azd/pkg/ext/hooks_runner_test.go @@ -81,7 +81,7 @@ func Test_Hooks_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -116,7 +116,7 @@ func Test_Hooks_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -151,7 +151,7 @@ func Test_Hooks_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -182,7 +182,7 @@ func Test_Hooks_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -228,7 +228,7 @@ func Test_Hooks_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -304,7 +304,7 @@ func Test_Hooks_GetScript(t *testing.T) { t.Run("Bash", func(t *testing.T) { hookConfig := hooksMap["bash"][0] mockContext := mocks.NewMockContext(context.Background()) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -327,7 +327,7 @@ func Test_Hooks_GetScript(t *testing.T) { t.Run("Powershell", func(t *testing.T) { hookConfig := hooksMap["pwsh"][0] mockContext := mocks.NewMockContext(context.Background()) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -353,7 +353,7 @@ func Test_Hooks_GetScript(t *testing.T) { hookConfig := hooksMap["inline"][0] mockContext := mocks.NewMockContext(context.Background()) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -385,7 +385,7 @@ func Test_Hooks_GetScript(t *testing.T) { hookConfig := hooksMap["inlineWithUrl"][0] mockContext := mocks.NewMockContext(context.Background()) - hooksManager := NewHooksManager(cwd) + hooksManager := NewHooksManager(cwd, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, @@ -436,7 +436,7 @@ func Test_GetScript_Validation(t *testing.T) { envManager := &mockenv.MockEnvManager{} mockContext := mocks.NewMockContext(context.Background()) - hooksManager := NewHooksManager(tempDir) + hooksManager := NewHooksManager(tempDir, mockContext.CommandRunner) runner := NewHooksRunner( hooksManager, mockContext.CommandRunner, diff --git a/cli/azd/pkg/ext/models.go b/cli/azd/pkg/ext/models.go index 531c4631314..307a08e1b49 100644 --- a/cli/azd/pkg/ext/models.go +++ b/cli/azd/pkg/ext/models.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "github.com/azure/azure-dev/cli/azd/pkg/osutil" @@ -138,6 +139,31 @@ func (hc *HookConfig) validate() error { return nil } +// IsPowerShellHook determines if a hook configuration uses PowerShell +func (hc *HookConfig) IsPowerShellHook() bool { + // Check if shell is explicitly set to pwsh + if hc.Shell == ShellTypePowershell { + return true + } + + // Check if shell is unknown but the hook file has .ps1 extension + if hc.Shell == ScriptTypeUnknown && hc.Run != "" { + // For file-based hooks, check the extension + if strings.HasSuffix(strings.ToLower(hc.Run), ".ps1") { + return true + } + } + + // Check OS-specific hook configurations + if runtime.GOOS == "windows" && hc.Windows != nil { + return hc.Windows.IsPowerShellHook() + } else if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") && hc.Posix != nil { + return hc.Posix.IsPowerShellHook() + } + + return false +} + func InferHookType(name string) (HookType, string) { // Validate name length so go doesn't PANIC for string slicing below if len(name) < 4 { diff --git a/cli/azd/pkg/helm/cli.go b/cli/azd/pkg/helm/cli.go index 282ef697197..65791c42dbb 100644 --- a/cli/azd/pkg/helm/cli.go +++ b/cli/azd/pkg/helm/cli.go @@ -11,7 +11,6 @@ import ( "time" "github.com/azure/azure-dev/cli/azd/pkg/exec" - "github.com/azure/azure-dev/cli/azd/pkg/tools" ) type Cli struct { @@ -36,7 +35,7 @@ func (cli *Cli) InstallUrl() string { // Checks whether or not the Helm CLI is installed and available within the PATH func (cli *Cli) CheckInstalled(ctx context.Context) error { - if err := tools.ToolInPath("helm"); err != nil { + if err := cli.commandRunner.ToolInPath("helm"); err != nil { return err } diff --git a/cli/azd/pkg/kubelogin/cli.go b/cli/azd/pkg/kubelogin/cli.go index 274e4933500..53b82791a7e 100644 --- a/cli/azd/pkg/kubelogin/cli.go +++ b/cli/azd/pkg/kubelogin/cli.go @@ -8,7 +8,6 @@ import ( "fmt" "github.com/azure/azure-dev/cli/azd/pkg/exec" - "github.com/azure/azure-dev/cli/azd/pkg/tools" ) // Cli is a wrapper around the kubelogin CLI @@ -35,7 +34,7 @@ func (cli *Cli) InstallUrl() string { // Checks whether or not the kubelogin CLI is installed and available within the PATH func (cli *Cli) CheckInstalled(ctx context.Context) error { - if err := tools.ToolInPath("kubelogin"); err != nil { + if err := cli.commandRunner.ToolInPath("kubelogin"); err != nil { return err } diff --git a/cli/azd/pkg/kustomize/cli.go b/cli/azd/pkg/kustomize/cli.go index 3d84da08d08..5863f9ed225 100644 --- a/cli/azd/pkg/kustomize/cli.go +++ b/cli/azd/pkg/kustomize/cli.go @@ -9,7 +9,6 @@ import ( "log" "github.com/azure/azure-dev/cli/azd/pkg/exec" - "github.com/azure/azure-dev/cli/azd/pkg/tools" ) // Cli is a wrapper around the kustomize cli @@ -36,7 +35,7 @@ func (cli *Cli) InstallUrl() string { // Checks whether or not the Kustomize CLI is installed and available within the PATH func (cli *Cli) CheckInstalled(ctx context.Context) error { - if err := tools.ToolInPath("kustomize"); err != nil { + if err := cli.commandRunner.ToolInPath("kustomize"); err != nil { return err } diff --git a/cli/azd/pkg/tools/az/az.go b/cli/azd/pkg/tools/az/az.go index c8fd36e3401..e66380dcc07 100644 --- a/cli/azd/pkg/tools/az/az.go +++ b/cli/azd/pkg/tools/az/az.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/azure/azure-dev/cli/azd/pkg/exec" - "github.com/azure/azure-dev/cli/azd/pkg/tools" ) // NewCli creates a new AzCli instance with the provided command runner. @@ -40,7 +39,7 @@ type AzCli struct { // It verifies whether the 'az' command can be found and executed from any directory. // Returns nil if the Azure CLI is installed and accessible, or an error if not found. func (az AzCli) CheckInstalled() error { - return tools.ToolInPath("az") + return az.runner.ToolInPath("az") } // AzAccountUser represents a user account in Azure with basic identification details. diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index 2c53ddd7a6c..6ba01fd0081 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -251,7 +251,7 @@ func isSupportedDockerVersion(cliOutput string) (bool, error) { } func (d *Cli) CheckInstalled(ctx context.Context) error { toolName := d.Name() - err := tools.ToolInPath("docker") + err := d.commandRunner.ToolInPath("docker") if err != nil { return err } diff --git a/cli/azd/pkg/tools/dotnet/dotnet.go b/cli/azd/pkg/tools/dotnet/dotnet.go index 9b0f3a07b7f..81aa82e80be 100644 --- a/cli/azd/pkg/tools/dotnet/dotnet.go +++ b/cli/azd/pkg/tools/dotnet/dotnet.go @@ -59,7 +59,7 @@ func (cli *Cli) versionInfo() tools.VersionInfo { } func (cli *Cli) CheckInstalled(ctx context.Context) error { - err := tools.ToolInPath("dotnet") + err := cli.commandRunner.ToolInPath("dotnet") if err != nil { return err } diff --git a/cli/azd/pkg/tools/git/git.go b/cli/azd/pkg/tools/git/git.go index ab01cf58097..46780ab9769 100644 --- a/cli/azd/pkg/tools/git/git.go +++ b/cli/azd/pkg/tools/git/git.go @@ -44,7 +44,7 @@ func (cli *Cli) versionInfo() tools.VersionInfo { } func (cli *Cli) CheckInstalled(ctx context.Context) error { - err := tools.ToolInPath("git") + err := cli.commandRunner.ToolInPath("git") if err != nil { return err } diff --git a/cli/azd/pkg/tools/kubectl/kubectl.go b/cli/azd/pkg/tools/kubectl/kubectl.go index 249dd2c95e2..8d2938d0d95 100644 --- a/cli/azd/pkg/tools/kubectl/kubectl.go +++ b/cli/azd/pkg/tools/kubectl/kubectl.go @@ -69,7 +69,7 @@ func NewCli(commandRunner exec.CommandRunner) *Cli { // Checks whether or not the K8s CLI is installed and available within the PATH func (cli *Cli) CheckInstalled(ctx context.Context) error { - if err := tools.ToolInPath("kubectl"); err != nil { + if err := cli.commandRunner.ToolInPath("kubectl"); err != nil { return err } diff --git a/cli/azd/pkg/tools/npm/npm.go b/cli/azd/pkg/tools/npm/npm.go index 0b178d97317..c7166b7db04 100644 --- a/cli/azd/pkg/tools/npm/npm.go +++ b/cli/azd/pkg/tools/npm/npm.go @@ -35,7 +35,7 @@ func (cli *Cli) versionInfoNode() tools.VersionInfo { } func (cli *Cli) CheckInstalled(ctx context.Context) error { - err := tools.ToolInPath("npm") + err := cli.commandRunner.ToolInPath("npm") if err != nil { return err } diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index d2c35742024..718b6c7ce7b 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -18,44 +18,26 @@ import ( // Creates a new PowershellScript command runner func NewPowershellScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.Script { return &powershellScript{ - commandRunner: commandRunner, - cwd: cwd, - envVars: envVars, - checkInstalled: checkPath, - } -} - -// for testing -func NewPowershellScriptWithMockCheckPath( - commandRunner exec.CommandRunner, - cwd string, - envVars []string, - mockCheckPath checkInstalled) tools.Script { - return &powershellScript{ - commandRunner: commandRunner, - cwd: cwd, - envVars: envVars, - checkInstalled: mockCheckPath, + commandRunner: commandRunner, + cwd: cwd, + envVars: envVars, } } type powershellScript struct { - commandRunner exec.CommandRunner - cwd string - envVars []string - checkInstalled checkInstalled + commandRunner exec.CommandRunner + cwd string + envVars []string } -type checkInstalled func(options tools.ExecOptions) error - -func checkPath(options tools.ExecOptions) (err error) { - return tools.ToolInPath(strings.Split(options.UserPwsh, " ")[0]) +func (ps *powershellScript) checkPath(options tools.ExecOptions) error { + return ps.commandRunner.ToolInPath(strings.Split(options.UserPwsh, " ")[0]) } // Executes the specified powershell script // When interactive is true will attach to stdin, stdout & stderr -func (bs *powershellScript) Execute(ctx context.Context, path string, options tools.ExecOptions) (exec.RunResult, error) { - noPwshError := bs.checkInstalled(options) +func (ps *powershellScript) Execute(ctx context.Context, path string, options tools.ExecOptions) (exec.RunResult, error) { + noPwshError := ps.checkPath(options) if noPwshError != nil { if runtime.GOOS != "windows" { @@ -68,7 +50,7 @@ func (bs *powershellScript) Execute(ctx context.Context, path string, options to } options.UserPwsh = "powershell" - if err := bs.checkInstalled(options); err != nil { + if err := ps.checkPath(options); err != nil { return exec.RunResult{}, &internal.ErrorWithSuggestion{ Err: err, Suggestion: fmt.Sprintf( @@ -79,8 +61,8 @@ func (bs *powershellScript) Execute(ctx context.Context, path string, options to } runArgs := exec.NewRunArgs(options.UserPwsh, path). - WithCwd(bs.cwd). - WithEnv(bs.envVars). + WithCwd(ps.cwd). + WithEnv(ps.envVars). WithShell(true) if options.Interactive != nil { @@ -91,7 +73,7 @@ func (bs *powershellScript) Execute(ctx context.Context, path string, options to runArgs = runArgs.WithStdOut(options.StdOut) } - result, err := bs.commandRunner.Run(ctx, runArgs) + result, err := ps.commandRunner.Run(ctx, runArgs) if err != nil { if noPwshError != nil { err = &internal.ErrorWithSuggestion{ diff --git a/cli/azd/pkg/tools/powershell/powershell_test.go b/cli/azd/pkg/tools/powershell/powershell_test.go index 8f049aa17af..09279017689 100644 --- a/cli/azd/pkg/tools/powershell/powershell_test.go +++ b/cli/azd/pkg/tools/powershell/powershell_test.go @@ -29,6 +29,9 @@ func Test_Powershell_Execute(t *testing.T) { t.Run("Success", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + // Mock ToolInPath to simulate pwsh being available + mockContext.CommandRunner.MockToolInPath("pwsh", nil) + // #nosec G101 userPwsh := "pwsh -NoProfile" mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -42,14 +45,8 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - PowershellScript := NewPowershellScriptWithMockCheckPath( - mockContext.CommandRunner, - workingDir, - env, - func(options tools.ExecOptions) error { - return nil - }) - runResult, err := PowershellScript.Execute( + powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + runResult, err := powershellScript.Execute( *mockContext.Context, scriptPath, tools.ExecOptions{UserPwsh: userPwsh, Interactive: to.Ptr(true)}, @@ -90,17 +87,11 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - PowershellScript := NewPowershellScriptWithMockCheckPath( - mockContext.CommandRunner, - workingDir, - env, - func(options tools.ExecOptions) error { - if strings.Contains(options.UserPwsh, "pwsh") { - return fmt.Errorf("failed to find PowerShell executable") - } - return nil - }) - runResult, err := PowershellScript.Execute( + // Mock ToolInPath to simulate pwsh being available + mockContext.CommandRunner.MockToolInPath("pwsh", fmt.Errorf("failed to find PowerShell executable")) + + powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + runResult, err := powershellScript.Execute( *mockContext.Context, scriptPath, tools.ExecOptions{UserPwsh: userPwsh, Interactive: to.Ptr(true)}, @@ -113,23 +104,20 @@ func Test_Powershell_Execute(t *testing.T) { t.Run("Error", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + // Mock ToolInPath to simulate pwsh being available + mockContext.CommandRunner.MockToolInPath("pwsh", nil) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return true }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { return exec.NewRunResult(1, "", "error message"), errors.New("error message") }) - PowershellScript := NewPowershellScriptWithMockCheckPath( - mockContext.CommandRunner, - workingDir, - env, - func(options tools.ExecOptions) error { - return nil - }) - runResult, err := PowershellScript.Execute( + powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + runResult, err := powershellScript.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{Interactive: to.Ptr(true)}, + tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)}, ) require.Equal(t, 1, runResult.ExitCode) @@ -139,17 +127,15 @@ func Test_Powershell_Execute(t *testing.T) { t.Run("NoPowerShellInstalled", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - PowershellScript := NewPowershellScriptWithMockCheckPath( - mockContext.CommandRunner, - workingDir, - env, - func(options tools.ExecOptions) error { - return fmt.Errorf("failed to find PowerShell executable") - }) - _, err := PowershellScript.Execute( + // Mock ToolInPath to simulate any powershell version not being available + mockContext.CommandRunner.MockToolInPath("pwsh", errors.New("pwsh: command not found")) + mockContext.CommandRunner.MockToolInPath("powershell", errors.New("powershell: command not found")) + + powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + _, err := powershellScript.Execute( *mockContext.Context, scriptPath, - tools.ExecOptions{Interactive: to.Ptr(true)}, + tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)}, ) require.Error(t, err) @@ -159,14 +145,17 @@ func Test_Powershell_Execute(t *testing.T) { name string value tools.ExecOptions }{ - {name: "Interactive", value: tools.ExecOptions{Interactive: to.Ptr(true)}}, - {name: "NonInteractive", value: tools.ExecOptions{Interactive: to.Ptr(false)}}, + {name: "Interactive", value: tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)}}, + {name: "NonInteractive", value: tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(false)}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) + // Mock ToolInPath to simulate pwsh being available + mockContext.CommandRunner.MockToolInPath("pwsh", nil) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { return true }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { @@ -174,14 +163,8 @@ func Test_Powershell_Execute(t *testing.T) { return exec.NewRunResult(0, "", ""), nil }) - PowershellScript := NewPowershellScriptWithMockCheckPath( - mockContext.CommandRunner, - workingDir, - env, - func(options tools.ExecOptions) error { - return nil - }) - runResult, err := PowershellScript.Execute(*mockContext.Context, scriptPath, test.value) + powershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + runResult, err := powershellScript.Execute(*mockContext.Context, scriptPath, test.value) require.NotNil(t, runResult) require.NoError(t, err) diff --git a/cli/azd/pkg/tools/python/python.go b/cli/azd/pkg/tools/python/python.go index 8b3f1b0f628..43a9b5c16a2 100644 --- a/cli/azd/pkg/tools/python/python.go +++ b/cli/azd/pkg/tools/python/python.go @@ -37,7 +37,7 @@ func (cli *Cli) versionInfo() tools.VersionInfo { } func (cli *Cli) CheckInstalled(ctx context.Context) error { - pyString, err := checkPath() + pyString, err := cli.checkPath() if err != nil { return err } @@ -78,7 +78,7 @@ func (cli *Cli) InstallRequirements(ctx context.Context, workingDir, environment } func (cli *Cli) CreateVirtualEnv(ctx context.Context, workingDir, name string) error { - pyString, err := checkPath() + pyString, err := cli.checkPath() if err != nil { return err } @@ -104,7 +104,7 @@ func (cli *Cli) Run( environment string, args ...string, ) (*exec.RunResult, error) { - pyString, err := checkPath() + pyString, err := cli.checkPath() if err != nil { return nil, err } @@ -131,23 +131,25 @@ func (cli *Cli) Run( return &runResult, nil } -func checkPath() (pyString string, err error) { +func (cli *Cli) checkPath() (string, error) { if runtime.GOOS == "windows" { // py for https://peps.python.org/pep-0397 // order is important. we want to resolve 'py', if available, first - pyString := [2]string{"py", "python"} + pyStrings := [2]string{"py", "python"} - for _, py := range pyString { - err = tools.ToolInPath(py) + var lastErr error + for _, py := range pyStrings { + err := cli.commandRunner.ToolInPath(py) if err == nil { return py, nil } + lastErr = err } - return "", err + return "", lastErr } else { - err := tools.ToolInPath("python3") + err := cli.commandRunner.ToolInPath("python3") if err == nil { - return "python3", err + return "python3", nil } return "", err } diff --git a/cli/azd/pkg/tools/python/python_test.go b/cli/azd/pkg/tools/python/python_test.go index 15eed006ad5..d74f30bb750 100644 --- a/cli/azd/pkg/tools/python/python_test.go +++ b/cli/azd/pkg/tools/python/python_test.go @@ -18,7 +18,8 @@ func Test_Python_Run(t *testing.T) { tempDir := t.TempDir() mockContext := mocks.NewMockContext(context.Background()) - pyString, err := checkPath() + cli := NewCli(mockContext.CommandRunner) + pyString, err := cli.checkPath() require.NoError(t, err) require.NotEmpty(t, pyString) @@ -29,8 +30,6 @@ func Test_Python_Run(t *testing.T) { return strings.Contains(command, pyString) }).Respond(exec.NewRunResult(0, "", "")) - cli := NewCli(mockContext.CommandRunner) - runResult, err := cli.Run(*mockContext.Context, tempDir, ".venv", "pf_client.py", "arg1", "arg2", "arg3") require.NoError(t, err) require.NotNil(t, runResult) @@ -46,7 +45,8 @@ func Test_Python_InstallRequirements(t *testing.T) { tempDir := t.TempDir() mockContext := mocks.NewMockContext(context.Background()) - pyString, err := checkPath() + cli := NewCli(mockContext.CommandRunner) + pyString, err := cli.checkPath() require.NoError(t, err) require.NotEmpty(t, pyString) @@ -57,8 +57,6 @@ func Test_Python_InstallRequirements(t *testing.T) { return strings.Contains(command, "requirements.txt") }).Respond(exec.NewRunResult(0, "", "")) - cli := NewCli(mockContext.CommandRunner) - err = cli.InstallRequirements(*mockContext.Context, tempDir, ".venv", "requirements.txt") require.NoError(t, err) require.NotNil(t, runArgs) @@ -72,7 +70,8 @@ func Test_Python_CreateVirtualEnv(t *testing.T) { tempDir := t.TempDir() mockContext := mocks.NewMockContext(context.Background()) - pyString, err := checkPath() + cli := NewCli(mockContext.CommandRunner) + pyString, err := cli.checkPath() require.NoError(t, err) require.NotEmpty(t, pyString) @@ -83,8 +82,6 @@ func Test_Python_CreateVirtualEnv(t *testing.T) { return strings.Contains(command, "-m venv .venv") }).Respond(exec.NewRunResult(0, "", "")) - cli := NewCli(mockContext.CommandRunner) - err = cli.CreateVirtualEnv(*mockContext.Context, tempDir, ".venv") require.NoError(t, err) require.NotNil(t, runArgs) diff --git a/cli/azd/pkg/tools/swa/swa.go b/cli/azd/pkg/tools/swa/swa.go index 3b0ad53bc37..9ef6951e9f5 100644 --- a/cli/azd/pkg/tools/swa/swa.go +++ b/cli/azd/pkg/tools/swa/swa.go @@ -104,7 +104,7 @@ func (cli *Cli) Deploy( func (cli *Cli) CheckInstalled(_ context.Context) error { - return tools.ToolInPath("npx") + return cli.commandRunner.ToolInPath("npx") } func (cli *Cli) Name() string { diff --git a/cli/azd/pkg/tools/terraform/terraform.go b/cli/azd/pkg/tools/terraform/terraform.go index 4ea03edb4b8..02bd09b7c84 100644 --- a/cli/azd/pkg/tools/terraform/terraform.go +++ b/cli/azd/pkg/tools/terraform/terraform.go @@ -47,7 +47,7 @@ func (cli *Cli) versionInfo() tools.VersionInfo { } func (cli *Cli) CheckInstalled(ctx context.Context) error { - err := tools.ToolInPath("terraform") + err := cli.commandRunner.ToolInPath("terraform") if err != nil { return err } diff --git a/cli/azd/pkg/tools/tool.go b/cli/azd/pkg/tools/tool.go index 380f7271fac..98d5b5b0b3e 100644 --- a/cli/azd/pkg/tools/tool.go +++ b/cli/azd/pkg/tools/tool.go @@ -5,9 +5,7 @@ package tools import ( "context" - "errors" "fmt" - osexec "os/exec" "regexp" "strconv" @@ -36,22 +34,6 @@ func (err *ErrSemver) Error() string { err.VersionInfo.MinimumVersion.String(), err.ToolName, err.VersionInfo.UpdateCommand, err.ToolName) } -// toolInPath checks to see if a program can be found on the PATH, as exec.LookPath -// does, returns exec.ErrNotFound in the case where os.LookPath would return -// exec.ErrNotFound and other errors. -func ToolInPath(name string) error { - _, err := osexec.LookPath(name) - - switch { - case err == nil: - return nil - case errors.Is(err, osexec.ErrNotFound): - return osexec.ErrNotFound - default: - return fmt.Errorf("failed searching for `%s` on PATH: %w", name, err) - } -} - func ExecuteCommand(ctx context.Context, commandRunner exec.CommandRunner, cmd string, args ...string) (string, error) { runResult, err := commandRunner.Run(ctx, exec.RunArgs{ Cmd: cmd, diff --git a/cli/azd/pkg/tools/tool_test.go b/cli/azd/pkg/tools/tool_test.go index ca0165fc5cf..06d9401ccc7 100644 --- a/cli/azd/pkg/tools/tool_test.go +++ b/cli/azd/pkg/tools/tool_test.go @@ -13,13 +13,6 @@ import ( "github.com/stretchr/testify/assert" ) -func Test_toolInPath(t *testing.T) { - t.Run("Missing", func(t *testing.T) { - err := ToolInPath("somethingThatNeverExists") - assert.Equal(t, err, osexec.ErrNotFound) - }) -} - func Test_Unique(t *testing.T) { toolOne := &mockTool{ name: "Installed One", diff --git a/cli/azd/test/mocks/mockexec/mock_runner.go b/cli/azd/test/mocks/mockexec/mock_runner.go index 33366c0d98e..23ca0b3f251 100644 --- a/cli/azd/test/mocks/mockexec/mock_runner.go +++ b/cli/azd/test/mocks/mockexec/mock_runner.go @@ -6,6 +6,7 @@ package mockexec import ( "context" "fmt" + osexec "os/exec" "strings" "github.com/azure/azure-dev/cli/azd/pkg/exec" @@ -17,13 +18,15 @@ type ResponseFn func(args exec.RunArgs) (exec.RunResult, error) // MockCommandRunner is used to register and implement mock calls and responses out to dependent CLI applications type MockCommandRunner struct { - expressions []*CommandExpression + expressions []*CommandExpression + toolInPathMocks map[string]error } // Creates a new instance of a mock exec func NewMockCommandRunner() *MockCommandRunner { return &MockCommandRunner{ - expressions: []*CommandExpression{}, + expressions: []*CommandExpression{}, + toolInPathMocks: make(map[string]error), } } @@ -81,6 +84,21 @@ func (m *MockCommandRunner) RunList(ctx context.Context, commands []string, args return match.response, match.error } +// ToolInPath implements the ToolInPath method of CommandRunner interface for mocking +func (m *MockCommandRunner) ToolInPath(name string) error { + if err, exists := m.toolInPathMocks[name]; exists { + return err + } + // Fallback to real implementation using osexec.LookPath + _, err := osexec.LookPath(name) + return err +} + +// MockToolInPath allows setting up mock responses for ToolInPath calls +func (m *MockCommandRunner) MockToolInPath(name string, err error) { + m.toolInPathMocks[name] = err +} + // Registers a mock expression against the mock exec func (m *MockCommandRunner) When(predicate CommandWhenPredicate) *CommandExpression { expr := CommandExpression{