diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 32e0aaf77d3..62d94b10bd2 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -35,6 +35,7 @@ words: - grpcbroker - msiexec - nosec + - npx - oneof - idxs # Looks like the protogen has a spelling error for panics @@ -54,6 +55,7 @@ words: - structpb - syncmap - syscall + - tsx - Retryable - runcontext - surveyterm diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index a50503adfdf..920be836ddc 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -820,6 +820,8 @@ func registerCommonDependencies(container *ioc.NestedContainer) { language.HookKindBash: bash.NewExecutor, language.HookKindPowerShell: powershell.NewExecutor, language.HookKindPython: language.NewPythonExecutor, + language.HookKindJavaScript: language.NewJavaScriptExecutor, + language.HookKindTypeScript: language.NewTypeScriptExecutor, } for kind, constructor := range hookExecutorMap { diff --git a/cli/azd/docs/language-hooks.md b/cli/azd/docs/language-hooks.md index 094c748273c..24eea5ee59f 100644 --- a/cli/azd/docs/language-hooks.md +++ b/cli/azd/docs/language-hooks.md @@ -11,8 +11,8 @@ unified lifecycle regardless of its executor: **Prepare → Execute → Cleanup* | Bash | `sh` | `.sh` | ✅ Stable | | PowerShell | `pwsh` | `.ps1` | ✅ Stable | | Python | `python` | `.py` | ✅ Phase 1 | -| JavaScript | `js` | `.js` | 🔜 Planned | -| TypeScript | `ts` | `.ts` | 🔜 Planned | +| JavaScript | `js` | `.js` | ✅ Phase 2 | +| TypeScript | `ts` | `.ts` | ✅ Phase 3 | | .NET (C#) | `dotnet` | `.cs` | 🔜 Planned | ## Configuration @@ -123,6 +123,95 @@ hooks: DB_CONNECTION_STRING: DATABASE_URL ``` +### JavaScript hook — auto-detected from .js extension + +The simplest way to use a JavaScript hook. The executor is inferred from the `.js` +extension. Dependencies are installed automatically if a `package.json` is found +in the script's directory (or a parent directory up to the project root). + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.js +``` + +### JavaScript hook with package.json + +When a `package.json` exists near the script, `npm install` runs automatically +before execution. + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.js + # package.json in ./hooks/ → npm install runs automatically +``` + +### JavaScript hook — explicit kind + +```yaml +hooks: + postprovision: + run: ./hooks/setup + kind: js +``` + +### JavaScript hook with working directory override + +```yaml +hooks: + postprovision: + run: ./tools/scripts/seed.js + dir: ./tools # package.json is in ./tools, not ./tools/scripts +``` + +### JavaScript hook with platform overrides + +```yaml +hooks: + postprovision: + windows: + run: ./hooks/setup.ps1 + shell: pwsh + posix: + run: ./hooks/setup.js + kind: js +``` + +### TypeScript hook — auto-detected from .ts extension + +TypeScript hooks use `npx tsx` for zero-config execution. `tsx` handles +TypeScript natively without requiring a separate compilation step, and +supports both ESM and CommonJS modules automatically. + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.ts +``` + +### TypeScript hook with package.json + +When a `package.json` is found, dependencies are installed before execution. +If `tsx` is listed as a dependency, the local version is used; otherwise +`npx` downloads it on demand. + +```yaml +hooks: + postprovision: + run: ./hooks/seed-database.ts + # package.json with tsx dependency → uses local tsx +``` + +### TypeScript hook — explicit kind + +```yaml +hooks: + postprovision: + run: ./hooks/setup + kind: ts +``` + ### Bash hook (existing behavior, unchanged) Bash hooks continue to work exactly as before. The `kind` field is @@ -164,7 +253,12 @@ Every hook follows the unified **Prepare → Execute → Cleanup** lifecycle: - **Inline scripts** are only supported for Bash and PowerShell hooks. All other executor types must reference a file path. -- **Phase 1** supports only Python as a non-shell executor. JavaScript, - TypeScript, and .NET support is planned for future phases. -- **Virtual environments** are created in the project directory alongside the - dependency file, following the naming convention `{dirName}_env`. +- **Phase 1** supports Python as a non-shell executor. + **Phase 2** adds JavaScript and **Phase 3** adds TypeScript. + .NET support is planned for a future phase. +- **Virtual environments** (Python) are created in the project directory alongside + the dependency file, following the naming convention `{dirName}_env`. +- **TypeScript** hooks require Node.js 18+ and use `npx tsx` for execution. + If `tsx` is not installed locally, `npx` will download it automatically. +- **Package manager** for JS/TS hooks currently uses npm for dependency + installation. Support for pnpm and yarn may be added in a future release. diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 3e91495a9b6..7c0de50ade6 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -198,7 +198,7 @@ func (h *HooksRunner) execHook( hookConfig.Kind, hookConfig.Name, ), - Suggestion: "Supported hook kinds: sh, pwsh, python.", + Suggestion: "Supported hook kinds: sh, pwsh, python, js, ts.", Links: []errorhandler.ErrorLink{ { Title: "Hook documentation", diff --git a/cli/azd/pkg/ext/js_hooks_e2e_test.go b/cli/azd/pkg/ext/js_hooks_e2e_test.go new file mode 100644 index 00000000000..86736217003 --- /dev/null +++ b/cli/azd/pkg/ext/js_hooks_e2e_test.go @@ -0,0 +1,924 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ext + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/ostest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// JS E2E test helpers +// --------------------------------------------------------------------------- + +// newJSTestFixture creates a temp directory with the script +// file and optional package.json. +func newJSTestFixture( + t *testing.T, + scriptRelPath string, + withPackageJSON bool, +) string { + t.Helper() + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + absScript := filepath.Join(cwd, scriptRelPath) + require.NoError(t, os.MkdirAll( + filepath.Dir(absScript), osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + absScript, nil, osutil.PermissionExecutableFile, + )) + + if withPackageJSON { + pkgPath := filepath.Join( + filepath.Dir(absScript), "package.json", + ) + require.NoError(t, os.WriteFile( + pkgPath, + []byte(`{"name":"test","version":"1.0.0"}`), + osutil.PermissionFile, + )) + } + + return cwd +} + +// stubNodeVersionCheck registers a mock for the Node.js +// --version call that always succeeds. +func stubNodeVersionCheck( + mockCtx *mocks.MockContext, +) { + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "--version") && + strings.Contains(command, "node") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + 0, "v20.11.0", "", + ), nil + }) +} + +// stubNpmInstall registers a mock for npm install. +func stubNpmInstall( + mockCtx *mocks.MockContext, + callLog *[]string, +) { + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "npm") && + strings.Contains(command, "install") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + if callLog != nil { + *callLog = append(*callLog, "npm-install") + } + return exec.NewRunResult(0, "", ""), nil + }) +} + +// --------------------------------------------------------------------------- +// E2E JavaScript hook tests +// --------------------------------------------------------------------------- + +func TestJsHook_AutoDetectFromExtension(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.js") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var executedScript string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executedScript = args.Args[0] + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Contains( + t, executedScript, "predeploy.js", + "auto-detected JS hook should execute the .js script", + ) + + hookCfg := hooksMap["predeploy"][0] + assert.Equal( + t, language.HookKindJavaScript, hookCfg.Kind, + ) + assert.False(t, hookCfg.Kind.IsShell()) +} + +func TestJsHook_ExplicitKind(t *testing.T) { + scriptRel := filepath.Join("hooks", "myscript") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Kind: language.HookKindJavaScript, + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + executed := false + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "myscript") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executed = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.True( + t, executed, + "explicit kind: js should use JS executor", + ) +} + +func TestJsHook_WithPackageJSON(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.js") + cwd := newJSTestFixture(t, scriptRel, true) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + callLog := []string{} + stubNpmInstall(mockCtx, &callLog) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + callLog = append(callLog, "execute") + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Contains(t, callLog, "npm-install") + assert.Contains(t, callLog, "execute") +} + +func TestJsHook_NodeBinaryResolution(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.js") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var capturedCmd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCmd = args.Cmd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Equal(t, "node", capturedCmd, + "JS executor should use 'node' command") +} + +func TestJsHook_NonZeroExitCode(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.js") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error output"), + fmt.Errorf("process exited with code 1") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "'predeploy' hook failed") + assert.Contains(t, err.Error(), "exit code: '1'") +} + +func TestJsHook_ContinueOnError(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.js") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + ContinueOnError: true, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error"), + fmt.Errorf("script error") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) +} + +func TestJsHook_ServiceLevel(t *testing.T) { + serviceDir := filepath.Join("src", "api") + scriptRel := filepath.Join( + serviceDir, "hooks", "postdeploy.js", + ) + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "postdeploy": {{ + Name: "postdeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var capturedCwd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "postdeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCwd = args.Cwd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePost, nil, "deploy", + ) + + require.NoError(t, err) + expectedCwd := filepath.Join( + cwd, serviceDir, "hooks", + ) + assert.Equal(t, expectedCwd, capturedCwd) +} + +func TestJsHook_EnvVarsPassthrough(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.js") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues("test", map[string]string{ + "AZURE_ENV_NAME": "dev", + "MY_SETTING": "value", + }) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var capturedEnv []string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedEnv = args.Env + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.NotEmpty(t, capturedEnv) + + envMap := envSliceToMap(capturedEnv) + assert.Equal(t, "dev", envMap["AZURE_ENV_NAME"]) + assert.Equal(t, "value", envMap["MY_SETTING"]) +} + +// TestJsHook_NodeMissing verifies that an appropriate error is +// returned when Node.js is not installed. +func TestJsHook_NodeMissing(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.js") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + + // Register all executors but make node --version fail. + registerHookExecutors(mockCtx) + + // Override node --version to fail. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "node") && + strings.Contains(command, "--version") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + 1, "", "not found", + ), fmt.Errorf("node not found") + }) + + // Also make npm/node ToolInPath check fail. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "npm") && + strings.Contains(command, "--version") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + 1, "", "not found", + ), fmt.Errorf("npm not found") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + + var sugErr *errorhandler.ErrorWithSuggestion + require.ErrorAs(t, err, &sugErr) + assert.Contains(t, sugErr.Message, "Node.js is required") +} + +// --------------------------------------------------------------------------- +// Table-driven pipeline tests +// --------------------------------------------------------------------------- + +func TestJsHook_ExecutionPipeline(t *testing.T) { + tests := []struct { + name string + scriptRel string + kind language.HookKind + continueOnError bool + exitCode int + execErr error + wantErr bool + errContains string + }{ + { + name: "SuccessAutoDetect", + scriptRel: filepath.Join("hooks", "hook.js"), + exitCode: 0, + wantErr: false, + }, + { + name: "SuccessExplicitKind", + scriptRel: filepath.Join("hooks", "run"), + kind: language.HookKindJavaScript, + exitCode: 0, + wantErr: false, + }, + { + name: "FailWithExitCode2", + scriptRel: filepath.Join("hooks", "fail.js"), + exitCode: 2, + execErr: fmt.Errorf("exit code 2"), + wantErr: true, + errContains: "exit code: '2'", + }, + { + name: "FailSuppressedByContinueOnError", + scriptRel: filepath.Join("hooks", "warn.js"), + continueOnError: true, + exitCode: 1, + execErr: fmt.Errorf("exit code 1"), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cwd := newJSTestFixture( + t, tt.scriptRel, false, + ) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hookCfg := &HookConfig{ + Name: "predeploy", + Run: tt.scriptRel, + ContinueOnError: tt.continueOnError, + } + if tt.kind != language.HookKindUnknown { + hookCfg.Kind = tt.kind + } + + hooksMap := map[string][]*HookConfig{ + "predeploy": {hookCfg}, + } + + mockCtx := mocks.NewMockContext( + context.Background(), + ) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + scriptBase := filepath.Base(tt.scriptRel) + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains( + command, scriptBase, + ) + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + tt.exitCode, "", "", + ), tt.execErr + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, + HookTypePre, nil, "deploy", + ) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains( + t, err.Error(), tt.errContains, + ) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestJsHook_InlineScriptRejected(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Kind: language.HookKindJavaScript, + Run: "console.log('hello')", + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + assert.Contains( + t, err.Error(), "inline scripts are not supported", + ) +} + +// TestJsHook_StdoutCapture verifies a JS hook that produces +// stdout completes without error. +func TestJsHook_StdoutCapture(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.js") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + // Simulate the script producing stdout. + return exec.NewRunResult( + 0, "Hello from JS hook!", "", + ), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) +} + +// TestJsHook_ShellHookUnaffected verifies that a Bash (.sh) +// hook runs through the shell executor even when JS hooks are +// present in the same configuration. +func TestJsHook_ShellHookUnaffected(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + // Create both shell and JS scripts. + shScript := filepath.Join(cwd, "hooks", "prebuild.sh") + jsScript := filepath.Join(cwd, "hooks", "predeploy.js") + for _, p := range []string{shScript, jsScript} { + require.NoError(t, os.MkdirAll( + filepath.Dir(p), osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + p, nil, osutil.PermissionExecutableFile, + )) + } + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "prebuild": {{ + Name: "prebuild", + Shell: string(language.HookKindBash), + Run: filepath.Join( + "hooks", "prebuild.sh", + ), + }}, + "predeploy": {{ + Name: "predeploy", + Run: filepath.Join( + "hooks", "predeploy.js", + ), + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + shellRan := false + jsRan := false + + // Shell hook mock. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "prebuild.sh") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + shellRan = true + require.Contains( + t, args.Args[0], "prebuild.sh", + ) + return exec.NewRunResult(0, "", ""), nil + }) + + // JS hook mock. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + jsRan = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + + // Run the shell hook. + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "build", + ) + require.NoError(t, err) + require.True( + t, shellRan, + "shell hook should execute via shell pipeline", + ) + + // Run the JS hook. + err = runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) + require.True( + t, jsRan, + "JS hook should execute via non-shell pipeline", + ) +} + +// TestJsHook_ExplicitDirOverridesCwd verifies that the Dir +// field in HookConfig overrides the default working directory +// for JS hook execution. +func TestJsHook_ExplicitDirOverridesCwd(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + // Create script and a custom directory. + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll( + scriptDir, osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + filepath.Join(scriptDir, "predeploy.js"), + nil, osutil.PermissionExecutableFile, + )) + + customDir := filepath.Join(cwd, "custom_workdir") + require.NoError(t, os.MkdirAll( + customDir, osutil.PermissionDirectory, + )) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.js"), + Dir: customDir, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var capturedCwd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCwd = args.Cwd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Equal( + t, customDir, capturedCwd, + "Dir should override the default working directory", + ) +} + +// TestJsHook_ProjectLevel verifies a JS hook registered at the +// project level (preprovision) executes through the hook +// executor pipeline. +func TestJsHook_ProjectLevel(t *testing.T) { + scriptRel := filepath.Join("hooks", "preprovision.js") + cwd := newJSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "preprovision": {{ + Name: "preprovision", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + executed := false + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "preprovision.js") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executed = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "provision", + ) + + require.NoError(t, err) + require.True(t, executed, "project-level JS hook should execute") +} diff --git a/cli/azd/pkg/ext/ts_hooks_e2e_test.go b/cli/azd/pkg/ext/ts_hooks_e2e_test.go new file mode 100644 index 00000000000..13ac3c3c9b3 --- /dev/null +++ b/cli/azd/pkg/ext/ts_hooks_e2e_test.go @@ -0,0 +1,843 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ext + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/ostest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// TS E2E test helpers +// --------------------------------------------------------------------------- + +// newTSTestFixture creates a temp directory with the script +// file and optional package.json. +func newTSTestFixture( + t *testing.T, + scriptRelPath string, + withPackageJSON bool, +) string { + t.Helper() + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + absScript := filepath.Join(cwd, scriptRelPath) + require.NoError(t, os.MkdirAll( + filepath.Dir(absScript), osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + absScript, nil, osutil.PermissionExecutableFile, + )) + + if withPackageJSON { + pkgPath := filepath.Join( + filepath.Dir(absScript), "package.json", + ) + require.NoError(t, os.WriteFile( + pkgPath, + []byte(`{"name":"test","version":"1.0.0"}`), + osutil.PermissionFile, + )) + } + + return cwd +} + +// --------------------------------------------------------------------------- +// E2E TypeScript hook tests +// --------------------------------------------------------------------------- + +func TestTsHook_AutoDetectFromExtension(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.ts") + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var capturedCmd string + var capturedArgs []string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCmd = args.Cmd + capturedArgs = args.Args + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + + // Should invoke via npx tsx. + assert.Equal(t, "npx", capturedCmd) + require.GreaterOrEqual(t, len(capturedArgs), 4) + assert.Equal(t, "--yes", capturedArgs[0]) + assert.Equal(t, "tsx", capturedArgs[1]) + assert.Equal(t, "--", capturedArgs[2]) + assert.Contains(t, capturedArgs[3], "predeploy.ts") + + hookCfg := hooksMap["predeploy"][0] + assert.Equal( + t, language.HookKindTypeScript, hookCfg.Kind, + ) + assert.False(t, hookCfg.Kind.IsShell()) +} + +func TestTsHook_ExplicitKind(t *testing.T) { + scriptRel := filepath.Join("hooks", "myscript") + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Kind: language.HookKindTypeScript, + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + executed := false + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "myscript") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executed = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.True( + t, executed, + "explicit kind: ts should use TS executor", + ) +} + +func TestTsHook_WithPackageJSON(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.ts") + cwd := newTSTestFixture(t, scriptRel, true) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + callLog := []string{} + stubNpmInstall(mockCtx, &callLog) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + callLog = append(callLog, "execute") + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Contains(t, callLog, "npm-install") + assert.Contains(t, callLog, "execute") +} + +func TestTsHook_NonZeroExitCode(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.ts") + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error output"), + fmt.Errorf("process exited with code 1") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "'predeploy' hook failed") + assert.Contains(t, err.Error(), "exit code: '1'") +} + +func TestTsHook_ContinueOnError(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.ts") + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + ContinueOnError: true, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "error"), + fmt.Errorf("script error") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) +} + +func TestTsHook_ServiceLevel(t *testing.T) { + serviceDir := filepath.Join("src", "api") + scriptRel := filepath.Join( + serviceDir, "hooks", "postdeploy.ts", + ) + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "postdeploy": {{ + Name: "postdeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var capturedCwd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "postdeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCwd = args.Cwd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePost, nil, "deploy", + ) + + require.NoError(t, err) + expectedCwd := filepath.Join( + cwd, serviceDir, "hooks", + ) + assert.Equal(t, expectedCwd, capturedCwd) +} + +func TestTsHook_NodeMissing(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.ts") + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + + // Make node --version fail. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "node") && + strings.Contains(command, "--version") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + 1, "", "not found", + ), fmt.Errorf("node not found") + }) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "npm") && + strings.Contains(command, "--version") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + 1, "", "not found", + ), fmt.Errorf("npm not found") + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + + var sugErr *errorhandler.ErrorWithSuggestion + require.ErrorAs(t, err, &sugErr) + assert.Contains(t, sugErr.Message, "Node.js is required") +} + +func TestTsHook_EnvVarsPassthrough(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.ts") + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues("test", map[string]string{ + "AZURE_ENV_NAME": "dev", + "MY_SETTING": "value", + }) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var capturedEnv []string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedEnv = args.Env + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + require.NotEmpty(t, capturedEnv) + + envMap := envSliceToMap(capturedEnv) + assert.Equal(t, "dev", envMap["AZURE_ENV_NAME"]) + assert.Equal(t, "value", envMap["MY_SETTING"]) +} + +// --------------------------------------------------------------------------- +// Table-driven pipeline tests +// --------------------------------------------------------------------------- + +func TestTsHook_ExecutionPipeline(t *testing.T) { + tests := []struct { + name string + scriptRel string + kind language.HookKind + continueOnError bool + exitCode int + execErr error + wantErr bool + errContains string + }{ + { + name: "SuccessAutoDetect", + scriptRel: filepath.Join("hooks", "hook.ts"), + exitCode: 0, + wantErr: false, + }, + { + name: "SuccessExplicitKind", + scriptRel: filepath.Join("hooks", "run"), + kind: language.HookKindTypeScript, + exitCode: 0, + wantErr: false, + }, + { + name: "FailWithExitCode2", + scriptRel: filepath.Join("hooks", "fail.ts"), + exitCode: 2, + execErr: fmt.Errorf("exit code 2"), + wantErr: true, + errContains: "exit code: '2'", + }, + { + name: "FailSuppressedByContinueOnError", + scriptRel: filepath.Join("hooks", "warn.ts"), + continueOnError: true, + exitCode: 1, + execErr: fmt.Errorf("exit code 1"), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cwd := newTSTestFixture( + t, tt.scriptRel, false, + ) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hookCfg := &HookConfig{ + Name: "predeploy", + Run: tt.scriptRel, + ContinueOnError: tt.continueOnError, + } + if tt.kind != language.HookKindUnknown { + hookCfg.Kind = tt.kind + } + + hooksMap := map[string][]*HookConfig{ + "predeploy": {hookCfg}, + } + + mockCtx := mocks.NewMockContext( + context.Background(), + ) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + scriptBase := filepath.Base(tt.scriptRel) + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains( + command, scriptBase, + ) + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + return exec.NewRunResult( + tt.exitCode, "", "", + ), tt.execErr + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, + HookTypePre, nil, "deploy", + ) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains( + t, err.Error(), tt.errContains, + ) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestTsHook_InlineScriptRejected(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Kind: language.HookKindTypeScript, + Run: "console.log('hello')", + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.Error(t, err) + assert.Contains( + t, err.Error(), "inline scripts are not supported", + ) +} + +// TestTsHook_StdoutCapture verifies a TS hook that produces +// stdout completes without error. +func TestTsHook_StdoutCapture(t *testing.T) { + scriptRel := filepath.Join("hooks", "predeploy.ts") + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + // Simulate the script producing stdout. + return exec.NewRunResult( + 0, "Hello from TS hook!", "", + ), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) +} + +// TestTsHook_ShellHookUnaffected verifies that a Bash (.sh) +// hook runs through the shell executor even when TS hooks are +// present in the same configuration. +func TestTsHook_ShellHookUnaffected(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + // Create both shell and TS scripts. + shScript := filepath.Join(cwd, "hooks", "prebuild.sh") + tsScript := filepath.Join(cwd, "hooks", "predeploy.ts") + for _, p := range []string{shScript, tsScript} { + require.NoError(t, os.MkdirAll( + filepath.Dir(p), osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + p, nil, osutil.PermissionExecutableFile, + )) + } + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "prebuild": {{ + Name: "prebuild", + Shell: string(language.HookKindBash), + Run: filepath.Join( + "hooks", "prebuild.sh", + ), + }}, + "predeploy": {{ + Name: "predeploy", + Run: filepath.Join( + "hooks", "predeploy.ts", + ), + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + shellRan := false + tsRan := false + + // Shell hook mock. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "prebuild.sh") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + shellRan = true + require.Contains( + t, args.Args[0], "prebuild.sh", + ) + return exec.NewRunResult(0, "", ""), nil + }) + + // TS hook mock. + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + tsRan = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + + // Run the shell hook. + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "build", + ) + require.NoError(t, err) + require.True( + t, shellRan, + "shell hook should execute via shell pipeline", + ) + + // Run the TS hook. + err = runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + require.NoError(t, err) + require.True( + t, tsRan, + "TS hook should execute via non-shell pipeline", + ) +} + +// TestTsHook_ExplicitDirOverridesCwd verifies that the Dir +// field in HookConfig overrides the default working directory +// for TS hook execution. +func TestTsHook_ExplicitDirOverridesCwd(t *testing.T) { + cwd := t.TempDir() + ostest.Chdir(t, cwd) + + // Create script and a custom directory. + scriptDir := filepath.Join(cwd, "hooks") + require.NoError(t, os.MkdirAll( + scriptDir, osutil.PermissionDirectory, + )) + require.NoError(t, os.WriteFile( + filepath.Join(scriptDir, "predeploy.ts"), + nil, osutil.PermissionExecutableFile, + )) + + customDir := filepath.Join(cwd, "custom_workdir") + require.NoError(t, os.MkdirAll( + customDir, osutil.PermissionDirectory, + )) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "predeploy": {{ + Name: "predeploy", + Run: filepath.Join("hooks", "predeploy.ts"), + Dir: customDir, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + var capturedCwd string + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "predeploy.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + capturedCwd = args.Cwd + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "deploy", + ) + + require.NoError(t, err) + assert.Equal( + t, customDir, capturedCwd, + "Dir should override the default working directory", + ) +} + +// TestTsHook_ProjectLevel verifies a TS hook registered at the +// project level (preprovision) executes through the hook +// executor pipeline. +func TestTsHook_ProjectLevel(t *testing.T) { + scriptRel := filepath.Join("hooks", "preprovision.ts") + cwd := newTSTestFixture(t, scriptRel, false) + + env := environment.NewWithValues( + "test", map[string]string{}, + ) + + hooksMap := map[string][]*HookConfig{ + "preprovision": {{ + Name: "preprovision", + Run: scriptRel, + }}, + } + + mockCtx := mocks.NewMockContext(context.Background()) + registerHookExecutors(mockCtx) + stubNodeVersionCheck(mockCtx) + + executed := false + mockCtx.CommandRunner.When(func( + args exec.RunArgs, command string, + ) bool { + return strings.Contains(command, "preprovision.ts") + }).RespondFn(func( + args exec.RunArgs, + ) (exec.RunResult, error) { + executed = true + return exec.NewRunResult(0, "", ""), nil + }) + + runner := buildRunner( + t, mockCtx, cwd, hooksMap, env, + ) + err := runner.RunHooks( + *mockCtx.Context, HookTypePre, nil, "provision", + ) + + require.NoError(t, err) + require.True(t, executed, "project-level TS hook should execute") +} diff --git a/cli/azd/pkg/tools/language/executor.go b/cli/azd/pkg/tools/language/executor.go index 4b3f45165bc..0c3323e8564 100644 --- a/cli/azd/pkg/tools/language/executor.go +++ b/cli/azd/pkg/tools/language/executor.go @@ -22,10 +22,8 @@ const ( // HookKindPowerShell identifies PowerShell scripts (.ps1 files). HookKindPowerShell HookKind = "pwsh" // HookKindJavaScript identifies JavaScript scripts (.js files). - // Not yet supported — IoC resolution will fail with a descriptive error. HookKindJavaScript HookKind = "js" // HookKindTypeScript identifies TypeScript scripts (.ts files). - // Not yet supported — IoC resolution will fail with a descriptive error. HookKindTypeScript HookKind = "ts" // HookKindPython identifies Python scripts (.py files). HookKindPython HookKind = "python" diff --git a/cli/azd/pkg/tools/language/js_executor.go b/cli/azd/pkg/tools/language/js_executor.go new file mode 100644 index 00000000000..2c6d79b88f3 --- /dev/null +++ b/cli/azd/pkg/tools/language/js_executor.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/azure/azure-dev/cli/azd/pkg/tools/node" +) + +// jsExecutor implements [tools.HookExecutor] for JavaScript +// scripts. It manages package.json discovery and dependency +// installation via the shared [prepareNodeProject] helper, then +// executes scripts using `node`. +type jsExecutor struct { + commandRunner exec.CommandRunner + nodeCli nodeTools +} + +// NewJavaScriptExecutor creates a JavaScript HookExecutor. +// Takes only IoC-injectable deps. +func NewJavaScriptExecutor( + commandRunner exec.CommandRunner, + nodeCli node.Cli, +) tools.HookExecutor { + return newJSExecutorInternal(commandRunner, nodeCli) +} + +// newJSExecutorInternal creates a jsExecutor using the +// nodeTools interface. This allows tests to inject mocks. +func newJSExecutorInternal( + commandRunner exec.CommandRunner, + nodeCli nodeTools, +) *jsExecutor { + return &jsExecutor{ + commandRunner: commandRunner, + nodeCli: nodeCli, + } +} + +// Prepare verifies that Node.js is installed and, when a +// package.json is found near the script, installs dependencies +// using npm. +func (e *jsExecutor) Prepare( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) error { + _, err := prepareNodeProject( + ctx, e.nodeCli, scriptPath, execCtx, + ) + return err +} + +// Execute runs the JavaScript script at the given path using +// `node scriptPath`. +func (e *jsExecutor) Execute( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) (exec.RunResult, error) { + runArgs := buildNodeRunArgs( + "node", nil, scriptPath, execCtx, + ) + return e.commandRunner.Run(ctx, runArgs) +} + +// Cleanup is a no-op — no temporary resources are created. +func (e *jsExecutor) Cleanup(_ context.Context) error { + return nil +} diff --git a/cli/azd/pkg/tools/language/js_executor_test.go b/cli/azd/pkg/tools/language/js_executor_test.go new file mode 100644 index 00000000000..401f8e941fc --- /dev/null +++ b/cli/azd/pkg/tools/language/js_executor_test.go @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// JavaScript executor unit tests +// --------------------------------------------------------------------------- + +func TestJsExecute_HappyPath(t *testing.T) { + runner := &mockCommandRunner{} + e := newJSExecutorInternal(runner, &mockNodeTools{}) + + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.js") + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + Cwd: dir, + EnvVars: []string{"A=1"}, + } + + _, err := e.Execute(t.Context(), scriptPath, execCtx) + require.NoError(t, err) + + assert.Equal(t, "node", runner.lastRunArgs.Cmd) + require.Len(t, runner.lastRunArgs.Args, 1) + assert.Equal(t, scriptPath, runner.lastRunArgs.Args[0]) + assert.Equal(t, dir, runner.lastRunArgs.Cwd) + assert.Equal(t, []string{"A=1"}, runner.lastRunArgs.Env) +} + +func TestJsExecute_FallbackCwd(t *testing.T) { + runner := &mockCommandRunner{} + e := newJSExecutorInternal(runner, &mockNodeTools{}) + + scriptDir := filepath.Join(t.TempDir(), "hooks") + scriptPath := filepath.Join(scriptDir, "hook.js") + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + // Cwd intentionally empty + } + + _, err := e.Execute(t.Context(), scriptPath, execCtx) + require.NoError(t, err) + + assert.Equal(t, scriptDir, runner.lastRunArgs.Cwd, + "should fall back to script directory") +} + +func TestJsExecute_InteractiveMode(t *testing.T) { + runner := &mockCommandRunner{} + e := newJSExecutorInternal(runner, &mockNodeTools{}) + + interactive := true + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + Interactive: &interactive, + } + scriptPath := filepath.Join(t.TempDir(), "hook.js") + + _, err := e.Execute(t.Context(), scriptPath, execCtx) + require.NoError(t, err) + + assert.True(t, runner.lastRunArgs.Interactive) +} + +func TestJsExecute_ScriptError(t *testing.T) { + runner := &mockCommandRunner{ + runResult: exec.NewRunResult(1, "", "error output"), + runErr: errors.New("exit code 1"), + } + e := newJSExecutorInternal(runner, &mockNodeTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + scriptPath := filepath.Join(t.TempDir(), "hook.js") + + res, err := e.Execute(t.Context(), scriptPath, execCtx) + require.Error(t, err) + assert.Equal(t, 1, res.ExitCode) +} + +func TestJsPrepare_WithPackageJSON(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "project") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile(t, + filepath.Join(projectDir, "package.json"), + `{"name": "test"}`, + ) + + mock := &mockNodeTools{} + e := newJSExecutorInternal( + &mockCommandRunner{}, mock, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + } + scriptPath := filepath.Join(projectDir, "hook.js") + + err := e.Prepare(t.Context(), scriptPath, execCtx) + require.NoError(t, err) + assert.True(t, mock.installCalled) +} + +func TestJsPrepare_NodeMissing(t *testing.T) { + mock := &mockNodeTools{ + checkInstalledErr: errors.New("not found"), + } + e := newJSExecutorInternal( + &mockCommandRunner{}, mock, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + + err := e.Prepare( + t.Context(), "/any/hook.js", execCtx, + ) + require.Error(t, err) + + var sugErr *errorhandler.ErrorWithSuggestion + require.ErrorAs(t, err, &sugErr) + assert.Contains(t, sugErr.Message, "Node.js is required") +} + +func TestJsCleanup_NoOp(t *testing.T) { + e := newJSExecutorInternal( + &mockCommandRunner{}, &mockNodeTools{}, + ) + require.NoError(t, e.Cleanup(t.Context())) +} + +// --------------------------------------------------------------------------- +// Table-driven comprehensive tests +// --------------------------------------------------------------------------- + +func TestJsExecutor_TableDriven(t *testing.T) { + tests := []struct { + name string + withPkgJSON bool + nodeMissing bool + installErr error + runErr error + runExitCode int + wantPrepErr bool + wantExecErr bool + }{ + { + name: "StandaloneScript", + withPkgJSON: false, + wantPrepErr: false, + wantExecErr: false, + }, + { + name: "WithPackageJSON", + withPkgJSON: true, + wantPrepErr: false, + wantExecErr: false, + }, + { + name: "NodeMissing", + nodeMissing: true, + wantPrepErr: true, + }, + { + name: "InstallFails", + withPkgJSON: true, + installErr: errors.New("install failed"), + wantPrepErr: true, + }, + { + name: "ScriptNonZeroExit", + runErr: errors.New("exit 1"), + runExitCode: 1, + wantExecErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "proj") + require.NoError(t, + os.MkdirAll(projectDir, 0o700), + ) + + if tt.withPkgJSON { + writeFile(t, + filepath.Join( + projectDir, "package.json", + ), + `{"name":"test"}`, + ) + } + + var checkErr error + if tt.nodeMissing { + checkErr = errors.New("node not found") + } + + mock := &mockNodeTools{ + checkInstalledErr: checkErr, + installErr: tt.installErr, + } + + runner := &mockCommandRunner{ + runResult: exec.NewRunResult( + tt.runExitCode, "", "", + ), + runErr: tt.runErr, + } + + e := newJSExecutorInternal(runner, mock) + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + } + scriptPath := filepath.Join( + projectDir, "hook.js", + ) + + err := e.Prepare( + t.Context(), scriptPath, execCtx, + ) + if tt.wantPrepErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + _, err = e.Execute( + t.Context(), scriptPath, execCtx, + ) + if tt.wantExecErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cli/azd/pkg/tools/language/node_helpers.go b/cli/azd/pkg/tools/language/node_helpers.go new file mode 100644 index 00000000000..2b44b51eed8 --- /dev/null +++ b/cli/azd/pkg/tools/language/node_helpers.go @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "slices" + + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" +) + +// nodeTools abstracts the Node.js CLI operations needed by +// the JS and TS executors, decoupling them from the concrete +// [node.Cli] for testability. [node.Cli] satisfies this +// interface. +type nodeTools interface { + CheckInstalled(ctx context.Context) error + Install( + ctx context.Context, + projectPath string, + env []string, + ) error +} + +// prepareNodeProject handles the shared Prepare phase for +// Node.js-based executors (JavaScript and TypeScript): +// 1. Verify Node.js is installed. +// 2. Discover package.json via [DiscoverNodeProject]. +// 3. If found, run the package manager's install command. +// +// Returns the project context (may be nil for standalone +// scripts that have no package.json). +func prepareNodeProject( + ctx context.Context, + nodeCli nodeTools, + scriptPath string, + execCtx tools.ExecutionContext, +) (*ProjectContext, error) { + // 1. Verify Node.js is installed. + if err := nodeCli.CheckInstalled(ctx); err != nil { + // If the error already carries rich context (e.g. + // from the error-handling pipeline), pass it through + // rather than wrapping with a generic message. + if sugErr, ok := errors.AsType[*errorhandler.ErrorWithSuggestion](err); ok { + return nil, sugErr + } + + // For other errors (missing from PATH, version + // mismatch, etc.), provide install guidance. + return nil, &errorhandler.ErrorWithSuggestion{ + Err: err, + Message: "Node.js is required to run " + + "JavaScript/TypeScript hooks.", + Suggestion: "Install Node.js 18.0.0 or " + + "later from https://nodejs.org/", + Links: []errorhandler.ErrorLink{{ + Title: "Download Node.js", + URL: "https://nodejs.org/en/download/", + }}, + } + } + + // 2. Discover Node.js project context (package.json only). + // Uses DiscoverNodeProject instead of the generic + // DiscoverProjectFile to avoid Python/DotNet project files + // shadowing package.json in mixed-language directories. + projCtx, err := DiscoverNodeProject( + scriptPath, execCtx.BoundaryDir, + ) + if err != nil { + return nil, fmt.Errorf( + "discovering Node.js project file: %w", err, + ) + } + + // No package.json found — standalone script. + if projCtx == nil { + return nil, nil + } + + // 3. Install dependencies. + if err := nodeCli.Install( + ctx, projCtx.ProjectDir, execCtx.EnvVars, + ); err != nil { + return nil, fmt.Errorf( + "installing Node.js dependencies in %s: %w", + projCtx.ProjectDir, err, + ) + } + + return projCtx, nil +} + +// buildNodeRunArgs constructs the [exec.RunArgs] for running a +// Node.js script with the correct cwd, environment, interactive +// mode, and stdout configuration. Used by both JS and TS +// executors to avoid duplicating the same argument assembly. +func buildNodeRunArgs( + cmd string, + args []string, + scriptPath string, + execCtx tools.ExecutionContext, +) exec.RunArgs { + allArgs := slices.Concat(args, []string{scriptPath}) + runArgs := exec. + NewRunArgs(cmd, allArgs...). + WithEnv(execCtx.EnvVars) + + // Prefer configured cwd; fall back to script's directory. + cwd := execCtx.Cwd + if cwd == "" { + cwd = filepath.Dir(scriptPath) + } + runArgs = runArgs.WithCwd(cwd) + + if execCtx.Interactive != nil { + runArgs = runArgs.WithInteractive( + *execCtx.Interactive, + ) + } + if execCtx.StdOut != nil { + runArgs = runArgs.WithStdOut(execCtx.StdOut) + } + + return runArgs +} diff --git a/cli/azd/pkg/tools/language/node_helpers_test.go b/cli/azd/pkg/tools/language/node_helpers_test.go new file mode 100644 index 00000000000..bb44943cfa0 --- /dev/null +++ b/cli/azd/pkg/tools/language/node_helpers_test.go @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// mockNodeTools — test double for the nodeTools interface +// --------------------------------------------------------------------------- + +type mockNodeTools struct { + checkInstalledErr error + installErr error + + installCalled bool + installDir string + installEnv []string +} + +func (m *mockNodeTools) CheckInstalled( + _ context.Context, +) error { + return m.checkInstalledErr +} + +func (m *mockNodeTools) Install( + _ context.Context, + projectPath string, + env []string, +) error { + m.installCalled = true + m.installDir = projectPath + m.installEnv = env + return m.installErr +} + +// --------------------------------------------------------------------------- +// prepareNodeProject tests +// --------------------------------------------------------------------------- + +func TestNodePrepare_NodeNotInstalled(t *testing.T) { + mock := &mockNodeTools{ + checkInstalledErr: errors.New("node not found"), + } + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + + projCtx, err := prepareNodeProject( + t.Context(), mock, "/any/hook.js", execCtx, + ) + + require.Error(t, err) + assert.Nil(t, projCtx) + + // Should be an ErrorWithSuggestion. + var sugErr *errorhandler.ErrorWithSuggestion + require.ErrorAs(t, err, &sugErr) + assert.Contains( + t, sugErr.Message, "Node.js is required", + ) + assert.Contains( + t, sugErr.Suggestion, "nodejs.org", + ) + assert.False(t, mock.installCalled) +} + +func TestNodePrepare_CheckInstalledSuggestionPassthrough( + t *testing.T, +) { + // When CheckInstalled already returns an ErrorWithSuggestion + // (e.g. from middleware), prepareNodeProject must pass it + // through without re-wrapping. + origErr := &errorhandler.ErrorWithSuggestion{ + Err: errors.New("Node.js 16.3.0 is too old"), + Message: "Node.js version is too old.", + Suggestion: "Upgrade to Node.js 18.0.0 or later.", + Links: []errorhandler.ErrorLink{{ + Title: "Download Node.js", + URL: "https://nodejs.org/en/download/", + }}, + } + mock := &mockNodeTools{checkInstalledErr: origErr} + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + + projCtx, err := prepareNodeProject( + t.Context(), mock, "/any/hook.js", execCtx, + ) + + require.Error(t, err) + assert.Nil(t, projCtx) + + // The returned error should be the SAME instance, + // not a new wrapper. + assert.Same(t, origErr, err, + "ErrorWithSuggestion should be passed through") + assert.False(t, mock.installCalled) +} + +func TestNodePrepare_WithPackageJSON(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + hooksDir := filepath.Join(projectDir, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "package.json"), + `{"name": "test"}`, + ) + + mock := &mockNodeTools{} + envVars := []string{"FOO=bar"} + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + EnvVars: envVars, + } + scriptPath := filepath.Join(hooksDir, "deploy.js") + + projCtx, err := prepareNodeProject( + t.Context(), mock, scriptPath, execCtx, + ) + + require.NoError(t, err) + require.NotNil(t, projCtx) + assert.Equal(t, projectDir, projCtx.ProjectDir) + assert.True(t, mock.installCalled) + assert.Equal(t, projectDir, mock.installDir) + assert.Equal(t, envVars, mock.installEnv) +} + +func TestNodePrepare_NoPackageJSON(t *testing.T) { + dir := t.TempDir() + mock := &mockNodeTools{} + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + } + scriptPath := filepath.Join(dir, "hook.js") + + projCtx, err := prepareNodeProject( + t.Context(), mock, scriptPath, execCtx, + ) + + require.NoError(t, err) + assert.Nil(t, projCtx) + assert.False(t, mock.installCalled) +} + +func TestNodePrepare_InstallFails(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "package.json"), + `{"name": "test"}`, + ) + + mock := &mockNodeTools{ + installErr: errors.New("npm install failed"), + } + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + } + scriptPath := filepath.Join(projectDir, "hook.js") + + projCtx, err := prepareNodeProject( + t.Context(), mock, scriptPath, execCtx, + ) + + require.Error(t, err) + assert.Nil(t, projCtx) + assert.Contains(t, err.Error(), "installing Node.js dependencies") + assert.True(t, mock.installCalled) +} + +func TestNodePrepare_PythonProjectIgnored(t *testing.T) { + // When only requirements.txt is present (no package.json), + // the Node executor should not try to install anything. + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "requirements.txt"), + "flask\n", + ) + + mock := &mockNodeTools{} + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + } + scriptPath := filepath.Join(projectDir, "hook.js") + + projCtx, err := prepareNodeProject( + t.Context(), mock, scriptPath, execCtx, + ) + + require.NoError(t, err) + assert.Nil(t, projCtx, + "should not return a project context when only "+ + "Python files exist") + assert.False(t, mock.installCalled, + "should not install deps when only Python files exist") +} + +func TestNodePrepare_MixedLanguageFindsPackageJSON( + t *testing.T, +) { + // When both requirements.txt and package.json exist in the + // same directory, the Node executor should find and install + // from package.json (not be shadowed by Python priority). + root := t.TempDir() + projectDir := filepath.Join(root, "myproject") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile( + t, + filepath.Join(projectDir, "requirements.txt"), + "flask\n", + ) + writeFile( + t, + filepath.Join(projectDir, "package.json"), + `{"name": "test"}`, + ) + + mock := &mockNodeTools{} + envVars := []string{"FOO=bar"} + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + EnvVars: envVars, + } + scriptPath := filepath.Join(projectDir, "hook.js") + + projCtx, err := prepareNodeProject( + t.Context(), mock, scriptPath, execCtx, + ) + + require.NoError(t, err) + require.NotNil(t, projCtx, + "should find package.json alongside Python files") + assert.Equal(t, projectDir, projCtx.ProjectDir) + assert.True(t, mock.installCalled, + "should install Node.js deps in mixed-language dir") + assert.Equal(t, projectDir, mock.installDir) +} + +// --------------------------------------------------------------------------- +// buildNodeRunArgs tests +// --------------------------------------------------------------------------- + +func TestBuildNodeRunArgs_StdOutWriter(t *testing.T) { + var buf bytes.Buffer + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + StdOut: &buf, + } + + scriptPath := filepath.Join(t.TempDir(), "hook.js") + runArgs := buildNodeRunArgs( + "node", nil, scriptPath, execCtx, + ) + + assert.NotNil(t, runArgs.StdOut, + "StdOut should be set when execCtx.StdOut is non-nil") + assert.Equal(t, &buf, runArgs.StdOut) +} diff --git a/cli/azd/pkg/tools/language/project_discovery.go b/cli/azd/pkg/tools/language/project_discovery.go index 5a5caf40cdc..22413fd9442 100644 --- a/cli/azd/pkg/tools/language/project_discovery.go +++ b/cli/azd/pkg/tools/language/project_discovery.go @@ -135,6 +135,63 @@ func discoverInDirectory(dir string) (*ProjectContext, error) { return nil, nil } +// DiscoverNodeProject walks up the directory tree from the directory +// containing scriptPath, looking specifically for package.json. +// +// Unlike [DiscoverProjectFile] which searches for all known project +// files in priority order, this function only matches package.json. +// This avoids false negatives in mixed-language directories where a +// Python project file (higher priority in the generic list) would +// shadow the Node.js project file. +// +// The search stops at boundaryDir. Returns nil without error when +// no package.json is found. +func DiscoverNodeProject( + scriptPath string, boundaryDir string, +) (*ProjectContext, error) { + scriptDir := filepath.Dir(scriptPath) + + absScript, err := filepath.Abs(scriptDir) + if err != nil { + return nil, fmt.Errorf( + "resolving script directory %q: %w", scriptDir, err, + ) + } + + absBoundary, err := filepath.Abs(boundaryDir) + if err != nil { + return nil, fmt.Errorf( + "resolving boundary directory %q: %w", + boundaryDir, err, + ) + } + + current := absScript + for { + candidate := filepath.Join(current, "package.json") + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() { + return &ProjectContext{ + ProjectDir: current, + DependencyFile: candidate, + Language: HookKindJavaScript, + }, nil + } + + // Stop when we've reached the boundary directory. + if pathsEqual(current, absBoundary) { + return nil, nil + } + + parent := filepath.Dir(current) + // Stop at filesystem root (parent == current). + if parent == current { + return nil, nil + } + current = parent + } +} + // pathsEqual compares two cleaned absolute paths for equality. // On Windows the comparison is case-insensitive to match the // filesystem behavior. diff --git a/cli/azd/pkg/tools/language/project_discovery_test.go b/cli/azd/pkg/tools/language/project_discovery_test.go index 615463efc21..07eb41fd38f 100644 --- a/cli/azd/pkg/tools/language/project_discovery_test.go +++ b/cli/azd/pkg/tools/language/project_discovery_test.go @@ -244,6 +244,101 @@ func TestDiscoverProjectFile_ClosestWins(t *testing.T) { "closer package.json should win over farther requirements.txt") } +// --------------------------------------------------------------------------- +// DiscoverNodeProject tests +// --------------------------------------------------------------------------- + +func TestDiscoverNodeProject_FindsPackageJson(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.js") + projectFile := filepath.Join(dir, "package.json") + + writeFile(t, projectFile, "{}\n") + + result, err := DiscoverNodeProject(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, dir, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) + assert.Equal(t, HookKindJavaScript, result.Language) +} + +func TestDiscoverNodeProject_IgnoresPythonFiles(t *testing.T) { + // When both requirements.txt and package.json exist, + // DiscoverNodeProject must return package.json. + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.js") + packageFile := filepath.Join(dir, "package.json") + + writeFile(t, filepath.Join(dir, "requirements.txt"), "flask\n") + writeFile(t, filepath.Join(dir, "pyproject.toml"), "[project]\n") + writeFile(t, packageFile, "{}\n") + + result, err := DiscoverNodeProject(scriptPath, dir) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, packageFile, result.DependencyFile, + "should find package.json even when Python project files exist") + assert.Equal(t, HookKindJavaScript, result.Language) +} + +func TestDiscoverNodeProject_RespectsUpperBoundary(t *testing.T) { + // package.json above the boundary must not be found. + // + // root/ + // package.json <- above boundary + // child/ <- boundary + // hook.js + root := t.TempDir() + child := filepath.Join(root, "child") + require.NoError(t, os.MkdirAll(child, 0o700)) + + writeFile(t, filepath.Join(root, "package.json"), "{}\n") + + scriptPath := filepath.Join(child, "hook.js") + + result, err := DiscoverNodeProject(scriptPath, child) + + require.NoError(t, err) + assert.Nil(t, result, + "package.json above boundary must not be found") +} + +func TestDiscoverNodeProject_WalksUpToPackageJson(t *testing.T) { + // package.json in parent, script in child subdirectory. + root := t.TempDir() + hooksDir := filepath.Join(root, "hooks") + require.NoError(t, os.MkdirAll(hooksDir, 0o700)) + + projectFile := filepath.Join(root, "package.json") + writeFile(t, projectFile, "{}\n") + + scriptPath := filepath.Join(hooksDir, "hook.js") + + result, err := DiscoverNodeProject(scriptPath, root) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, root, result.ProjectDir) + assert.Equal(t, projectFile, result.DependencyFile) +} + +func TestDiscoverNodeProject_NoPackageJson(t *testing.T) { + // Directory with only Python files — no package.json. + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "requirements.txt"), "flask\n") + + scriptPath := filepath.Join(dir, "hook.js") + + result, err := DiscoverNodeProject(scriptPath, dir) + + require.NoError(t, err) + assert.Nil(t, result, + "expected nil when no package.json exists") +} + // writeFile is a test helper that creates a file with the given content. func writeFile(t *testing.T, path, content string) { t.Helper() diff --git a/cli/azd/pkg/tools/language/ts_executor.go b/cli/azd/pkg/tools/language/ts_executor.go new file mode 100644 index 00000000000..1658b9c53d0 --- /dev/null +++ b/cli/azd/pkg/tools/language/ts_executor.go @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/azure/azure-dev/cli/azd/pkg/tools/node" +) + +// tsExecutor implements [tools.HookExecutor] for TypeScript +// scripts. It uses `npx tsx` for zero-config TypeScript +// execution — tsx handles ESM/CJS and TypeScript natively +// without requiring a separate compilation step. +type tsExecutor struct { + commandRunner exec.CommandRunner + nodeCli nodeTools +} + +// NewTypeScriptExecutor creates a TypeScript HookExecutor. +// Takes only IoC-injectable deps. +func NewTypeScriptExecutor( + commandRunner exec.CommandRunner, + nodeCli node.Cli, +) tools.HookExecutor { + return newTSExecutorInternal(commandRunner, nodeCli) +} + +// newTSExecutorInternal creates a tsExecutor using the +// nodeTools interface. This allows tests to inject mocks. +func newTSExecutorInternal( + commandRunner exec.CommandRunner, + nodeCli nodeTools, +) *tsExecutor { + return &tsExecutor{ + commandRunner: commandRunner, + nodeCli: nodeCli, + } +} + +// Prepare verifies that Node.js is installed and, when a +// package.json is found near the script, installs dependencies +// using npm. tsx is resolved via npx at execution time, so no +// separate installation check is needed here. +func (e *tsExecutor) Prepare( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) error { + _, err := prepareNodeProject( + ctx, e.nodeCli, scriptPath, execCtx, + ) + return err +} + +// Execute runs the TypeScript script using `npx tsx scriptPath`. +// tsx is a zero-config TypeScript executor that handles TS +// natively without a separate compile step. When the project +// has tsx as a dependency, npx uses that version; otherwise it +// downloads tsx on demand. +func (e *tsExecutor) Execute( + ctx context.Context, + scriptPath string, + execCtx tools.ExecutionContext, +) (exec.RunResult, error) { + // npx --yes tsx -- scriptPath + // --yes: auto-confirm download if tsx is not installed + runArgs := buildNodeRunArgs( + "npx", []string{"--yes", "tsx", "--"}, scriptPath, execCtx, + ) + return e.commandRunner.Run(ctx, runArgs) +} + +// Cleanup is a no-op — no temporary resources are created. +func (e *tsExecutor) Cleanup(_ context.Context) error { + return nil +} diff --git a/cli/azd/pkg/tools/language/ts_executor_test.go b/cli/azd/pkg/tools/language/ts_executor_test.go new file mode 100644 index 00000000000..9037f4dc8ff --- /dev/null +++ b/cli/azd/pkg/tools/language/ts_executor_test.go @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package language + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// TypeScript executor unit tests +// --------------------------------------------------------------------------- + +func TestTsExecute_Standalone(t *testing.T) { + runner := &mockCommandRunner{} + e := newTSExecutorInternal(runner, &mockNodeTools{}) + + dir := t.TempDir() + scriptPath := filepath.Join(dir, "hook.ts") + + execCtx := tools.ExecutionContext{ + BoundaryDir: dir, + Cwd: dir, + EnvVars: []string{"A=1"}, + } + + _, err := e.Execute(t.Context(), scriptPath, execCtx) + require.NoError(t, err) + + // Should invoke npx --yes tsx -- scriptPath. + assert.Equal(t, "npx", runner.lastRunArgs.Cmd) + require.Len(t, runner.lastRunArgs.Args, 4) + assert.Equal(t, "--yes", runner.lastRunArgs.Args[0]) + assert.Equal(t, "tsx", runner.lastRunArgs.Args[1]) + assert.Equal(t, "--", runner.lastRunArgs.Args[2]) + assert.Equal(t, scriptPath, runner.lastRunArgs.Args[3]) + assert.Equal(t, dir, runner.lastRunArgs.Cwd) + assert.Equal(t, []string{"A=1"}, runner.lastRunArgs.Env) +} + +func TestTsExecute_FallbackCwd(t *testing.T) { + runner := &mockCommandRunner{} + e := newTSExecutorInternal(runner, &mockNodeTools{}) + + scriptDir := filepath.Join(t.TempDir(), "hooks") + scriptPath := filepath.Join(scriptDir, "hook.ts") + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + + _, err := e.Execute(t.Context(), scriptPath, execCtx) + require.NoError(t, err) + + assert.Equal(t, scriptDir, runner.lastRunArgs.Cwd, + "should fall back to script directory") +} + +func TestTsExecute_InteractiveMode(t *testing.T) { + runner := &mockCommandRunner{} + e := newTSExecutorInternal(runner, &mockNodeTools{}) + + interactive := true + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + Interactive: &interactive, + } + scriptPath := filepath.Join(t.TempDir(), "hook.ts") + + _, err := e.Execute(t.Context(), scriptPath, execCtx) + require.NoError(t, err) + + assert.True(t, runner.lastRunArgs.Interactive) +} + +func TestTsExecute_ScriptError(t *testing.T) { + runner := &mockCommandRunner{ + runResult: exec.NewRunResult(1, "", "error output"), + runErr: errors.New("exit code 1"), + } + e := newTSExecutorInternal(runner, &mockNodeTools{}) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + scriptPath := filepath.Join(t.TempDir(), "hook.ts") + + res, err := e.Execute(t.Context(), scriptPath, execCtx) + require.Error(t, err) + assert.Equal(t, 1, res.ExitCode) +} + +func TestTsPrepare_WithPackageJSON(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "project") + require.NoError(t, os.MkdirAll(projectDir, 0o700)) + writeFile(t, + filepath.Join(projectDir, "package.json"), + `{"name": "test"}`, + ) + + mock := &mockNodeTools{} + e := newTSExecutorInternal( + &mockCommandRunner{}, mock, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + } + scriptPath := filepath.Join(projectDir, "hook.ts") + + err := e.Prepare(t.Context(), scriptPath, execCtx) + require.NoError(t, err) + assert.True(t, mock.installCalled) +} + +func TestTsPrepare_NodeMissing(t *testing.T) { + mock := &mockNodeTools{ + checkInstalledErr: errors.New("not found"), + } + e := newTSExecutorInternal( + &mockCommandRunner{}, mock, + ) + + execCtx := tools.ExecutionContext{ + BoundaryDir: t.TempDir(), + } + + err := e.Prepare( + t.Context(), "/any/hook.ts", execCtx, + ) + require.Error(t, err) + + var sugErr *errorhandler.ErrorWithSuggestion + require.ErrorAs(t, err, &sugErr) + assert.Contains(t, sugErr.Message, "Node.js is required") +} + +func TestTsCleanup_NoOp(t *testing.T) { + e := newTSExecutorInternal( + &mockCommandRunner{}, &mockNodeTools{}, + ) + require.NoError(t, e.Cleanup(t.Context())) +} + +// --------------------------------------------------------------------------- +// Table-driven comprehensive tests +// --------------------------------------------------------------------------- + +func TestTsExecutor_TableDriven(t *testing.T) { + tests := []struct { + name string + withPkgJSON bool + nodeMissing bool + installErr error + runErr error + runExitCode int + wantPrepErr bool + wantExecErr bool + }{ + { + name: "StandaloneScript", + withPkgJSON: false, + wantPrepErr: false, + wantExecErr: false, + }, + { + name: "WithPackageJSON", + withPkgJSON: true, + wantPrepErr: false, + wantExecErr: false, + }, + { + name: "NodeMissing", + nodeMissing: true, + wantPrepErr: true, + }, + { + name: "InstallFails", + withPkgJSON: true, + installErr: errors.New("install failed"), + wantPrepErr: true, + }, + { + name: "ScriptNonZeroExit", + runErr: errors.New("exit 1"), + runExitCode: 1, + wantExecErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "proj") + require.NoError(t, + os.MkdirAll(projectDir, 0o700), + ) + + if tt.withPkgJSON { + writeFile(t, + filepath.Join( + projectDir, "package.json", + ), + `{"name":"test"}`, + ) + } + + var checkErr error + if tt.nodeMissing { + checkErr = errors.New("node not found") + } + + mock := &mockNodeTools{ + checkInstalledErr: checkErr, + installErr: tt.installErr, + } + + runner := &mockCommandRunner{ + runResult: exec.NewRunResult( + tt.runExitCode, "", "", + ), + runErr: tt.runErr, + } + + e := newTSExecutorInternal(runner, mock) + execCtx := tools.ExecutionContext{ + BoundaryDir: root, + } + scriptPath := filepath.Join( + projectDir, "hook.ts", + ) + + err := e.Prepare( + t.Context(), scriptPath, execCtx, + ) + if tt.wantPrepErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + _, err = e.Execute( + t.Context(), scriptPath, execCtx, + ) + if tt.wantExecErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cli/azd/test/mocks/mocktools/hook_executors.go b/cli/azd/test/mocks/mocktools/hook_executors.go index 381515bb7ab..64158e4b631 100644 --- a/cli/azd/test/mocks/mocktools/hook_executors.go +++ b/cli/azd/test/mocks/mocktools/hook_executors.go @@ -6,6 +6,7 @@ package mocktools import ( "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" "github.com/azure/azure-dev/cli/azd/pkg/tools/language" + "github.com/azure/azure-dev/cli/azd/pkg/tools/node" "github.com/azure/azure-dev/cli/azd/pkg/tools/powershell" "github.com/azure/azure-dev/cli/azd/pkg/tools/python" "github.com/azure/azure-dev/cli/azd/test/mocks" @@ -25,4 +26,13 @@ func RegisterHookExecutors(mockCtx *mocks.MockContext) { mockCtx.Container.MustRegisterNamedTransient( string(language.HookKindPython), language.NewPythonExecutor, ) + mockCtx.Container.MustRegisterSingleton(node.NewCli) + mockCtx.Container.MustRegisterNamedTransient( + string(language.HookKindJavaScript), + language.NewJavaScriptExecutor, + ) + mockCtx.Container.MustRegisterNamedTransient( + string(language.HookKindTypeScript), + language.NewTypeScriptExecutor, + ) }