diff --git a/cli/azd/magefile.go b/cli/azd/magefile.go index 3ed58c5d2cc..bc5ee7994b2 100644 --- a/cli/azd/magefile.go +++ b/cli/azd/magefile.go @@ -7,11 +7,16 @@ package main import ( "bytes" + "errors" "fmt" + "io/fs" + "maps" "os" "os/exec" "path/filepath" + "regexp" "runtime" + "slices" "strings" "sync" @@ -108,22 +113,15 @@ func (Dev) Uninstall() error { } // Preflight runs all pre-commit quality checks: formatting, copyright headers, linting, -// spell checking, compilation, and unit tests. Reports a summary of all results at the end. +// spell checking, compilation, unit tests, and playback functional tests. +// Reports a summary of all results at the end. // // Usage: mage preflight func Preflight() error { // Disable Go workspace mode so preflight mirrors CI, which has no go.work file. // Without this, a local go.work can silently resolve different module versions // than go.mod alone, masking build failures that only appear in CI. - origGowork, hadGowork := os.LookupEnv("GOWORK") - os.Setenv("GOWORK", "off") - defer func() { - if hadGowork { - os.Setenv("GOWORK", origGowork) - } else { - os.Unsetenv("GOWORK") - } - }() + defer setEnvScoped("GOWORK", "off")() repoRoot, err := findRepoRoot() if err != nil { @@ -131,6 +129,18 @@ func Preflight() error { } azdDir := filepath.Join(repoRoot, "cli", "azd") + // Pin GOTOOLCHAIN to the version declared in go.mod when it isn't already + // set. When the system Go is older (e.g. 1.25) and go.mod says 1.26, + // parallel compilations can race the auto-download, producing "compile: + // version X does not match go tool version Y" errors. Pinning upfront + // avoids this. We skip the override when GOTOOLCHAIN is already set so + // that a user's explicit choice (or a newer Go) is respected. + if _, hasToolchain := os.LookupEnv("GOTOOLCHAIN"); !hasToolchain { + if ver, err := goModVersion(azdDir); err == nil && ver != "" { + defer setEnvScoped("GOTOOLCHAIN", "go"+ver)() + } + } + type result struct { name string status string // "pass" or "fail" @@ -231,6 +241,14 @@ func Preflight() error { record("test", "pass", "") } + // 8. Functional tests in playback mode (no Azure credentials needed). + fmt.Println("══ Playback tests (functional) ══") + if err := runPlaybackTests(azdDir); err != nil { + record("playback tests", "fail", err.Error()) + } else { + record("playback tests", "pass", "") + } + // Summary fmt.Println("\n══════════════════════════") fmt.Println(" Preflight Summary") @@ -251,6 +269,129 @@ func Preflight() error { return nil } +// PlaybackTests runs functional tests that have recordings in playback mode. +// No Azure credentials are required — tests replay from recorded HTTP +// interactions stored in test/functional/testdata/recordings. +// +// Usage: mage playbackTests +func PlaybackTests() error { + defer setEnvScoped("GOWORK", "off")() + + repoRoot, err := findRepoRoot() + if err != nil { + return err + } + azdDir := filepath.Join(repoRoot, "cli", "azd") + + // Pin GOTOOLCHAIN (see Preflight for rationale). + if _, hasToolchain := os.LookupEnv("GOTOOLCHAIN"); !hasToolchain { + if ver, err := goModVersion(azdDir); err == nil && ver != "" { + defer setEnvScoped("GOTOOLCHAIN", "go"+ver)() + } + } + + return runPlaybackTests(azdDir) +} + +// runPlaybackTests discovers test recordings and runs matching functional +// tests in playback mode (AZURE_RECORD_MODE=playback). +func runPlaybackTests(azdDir string) error { + recordingsDir := filepath.Join( + azdDir, "test", "functional", "testdata", "recordings", + ) + names, err := discoverPlaybackTests(recordingsDir) + if err != nil { + return err + } + if len(names) == 0 { + fmt.Println("No recording files found — skipping playback tests.") + return nil + } + + escaped := make([]string, len(names)) + for i, name := range names { + escaped[i] = regexp.QuoteMeta(name) + } + pattern := "^(" + strings.Join(escaped, "|") + ")(/|$)" + fmt.Printf("Running %d tests in playback mode...\n", len(names)) + + return runStreamingWithEnv( + azdDir, + []string{"AZURE_RECORD_MODE=playback"}, + "go", "test", "-run", pattern, + "./test/functional", "-timeout", "30m", "-count=1", + ) +} + +// excludedPlaybackTests lists tests whose recordings are known to be stale. +// These are excluded from automatic playback so they don't block preflight. +// Re-record the test to remove it from this list. +var excludedPlaybackTests = map[string]string{ + "Test_CLI_Deploy_SlotDeployment": "stale recording - re-record to include", +} + +// discoverPlaybackTests scans the recordings directory for .yaml files and +// subdirectories, returning unique top-level Go test function names. +func discoverPlaybackTests(recordingsDir string) ([]string, error) { + entries, err := os.ReadDir(recordingsDir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("reading recordings directory: %w", err) + } + + seen := map[string]bool{} + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + // Only include directories named like Go test functions. + if strings.HasPrefix(name, "Test") { + seen[name] = true + } + continue + } + if !strings.HasSuffix(name, ".yaml") { + continue + } + // Strip .yaml, then take everything before the first "." + // to get the top-level test function name. + // Example: Test_CLI_Aspire_Deploy.dotnet.yaml + // → Test_CLI_Aspire_Deploy + cassette := strings.TrimSuffix(name, ".yaml") + if idx := strings.Index(cassette, "."); idx >= 0 { + cassette = cassette[:idx] + } + seen[cassette] = true + } + + // Remove tests with known stale recordings. + for name := range excludedPlaybackTests { + delete(seen, name) + } + + if len(seen) == 0 { + return nil, nil + } + + return slices.Sorted(maps.Keys(seen)), nil +} + +// goModVersion reads the "go X.Y.Z" directive from go.mod in the given dir. +func goModVersion(dir string) (string, error) { + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "go ") { + return strings.TrimSpace(strings.TrimPrefix(line, "go ")), nil + } + } + return "", nil +} + // runCapture runs a command and returns its combined stdout/stderr. func runCapture(dir string, name string, args ...string) (string, error) { cmd := exec.Command(name, args...) @@ -264,8 +405,19 @@ func runCapture(dir string, name string, args ...string) (string, error) { // runStreaming runs a command with stdout/stderr connected to the terminal. func runStreaming(dir string, name string, args ...string) error { + return runStreamingWithEnv(dir, nil, name, args...) +} + +// runStreamingWithEnv runs a command with stdout/stderr connected to the +// terminal and additional environment variables set. +func runStreamingWithEnv( + dir string, env []string, name string, args ...string, +) error { cmd := exec.Command(name, args...) cmd.Dir = dir + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() @@ -336,6 +488,22 @@ func requireTool(name, installCmd string) error { return nil } +// setEnvScoped sets an environment variable and returns a function that restores +// the original value. Use with defer: defer setEnvScoped("KEY", "value")() +// NOTE: os.Setenv is process-global and not goroutine-safe. This is safe +// because mage targets run sequentially (no parallel deps). +func setEnvScoped(key, value string) func() { + orig, had := os.LookupEnv(key) + os.Setenv(key, value) + return func() { + if had { + os.Setenv(key, orig) + } else { + os.Unsetenv(key) + } + } +} + // shellQuote wraps s in single quotes and escapes embedded single quotes for POSIX shells. func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" @@ -631,15 +799,7 @@ func runPwshScript(dir, script string, args ...string) error { return err } - origGowork, hadGowork := os.LookupEnv("GOWORK") - os.Setenv("GOWORK", "off") - defer func() { - if hadGowork { - os.Setenv("GOWORK", origGowork) - } else { - os.Unsetenv("GOWORK") - } - }() + defer setEnvScoped("GOWORK", "off")() cmdArgs := append( []string{"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script}, diff --git a/cli/azd/pkg/grpcbroker/message_broker_test.go b/cli/azd/pkg/grpcbroker/message_broker_test.go index 65dc54ec5e0..f55da0e03ed 100644 --- a/cli/azd/pkg/grpcbroker/message_broker_test.go +++ b/cli/azd/pkg/grpcbroker/message_broker_test.go @@ -615,7 +615,7 @@ func TestRun_ContextCancellation(t *testing.T) { select { case err := <-done: assert.Equal(t, context.Canceled, err) - case <-time.After(1 * time.Second): + case <-time.After(5 * time.Second): t.Fatal("Run did not exit after context cancellation") } } diff --git a/cli/azd/pkg/rzip/rzip_test.go b/cli/azd/pkg/rzip/rzip_test.go index 537a8d63186..0b6b1bdec1a 100644 --- a/cli/azd/pkg/rzip/rzip_test.go +++ b/cli/azd/pkg/rzip/rzip_test.go @@ -20,24 +20,19 @@ import ( "github.com/stretchr/testify/require" ) +// symlinkAvailable reports whether the OS supports creating symlinks. +// On Windows without Developer Mode, os.Symlink fails with a privilege error. +func symlinkAvailable(t *testing.T) bool { + t.Helper() + tmp := t.TempDir() + err := os.Symlink(".", filepath.Join(tmp, "testlink")) + return err == nil +} + func TestCreateFromDirectory(t *testing.T) { require := require.New(t) tempDir := t.TempDir() - - // ASCII representation of the filesystem structure inside tempDir - /* - tempDir/ - ├── file1.txt - ├── a/b/c/d/deep.txt - ├── empty/ - ├── subdir/ - │ ├── file2.txt - │ └── file3.txt - ├── symlink_to_file1.txt -> file1.txt - ├── symlink_to_subdir -> subdir/ - ├── symlink_to_symlink_to_file1.txt -> symlink_to_file1.txt - └── symlink_to_symlink_to_subdir -> symlink_to_subdir/ - */ + hasSymlinks := symlinkAvailable(t) files := map[string]string{ "file1.txt": "Content of file1", @@ -58,19 +53,24 @@ func TestCreateFromDirectory(t *testing.T) { err := os.Mkdir(filepath.Join(tempDir, "empty"), 0755) require.NoError(err) - // Create symlinks -- both relative and absolute links - err = os.Symlink(filepath.Join(".", "file1.txt"), filepath.Join(tempDir, "symlink_to_file1.txt")) - require.NoError(err) - //nolint:lll - err = os.Symlink( - filepath.Join(tempDir, "symlink_to_file1.txt"), - filepath.Join(tempDir, "symlink_to_symlink_to_file1.txt"), - ) - require.NoError(err) - err = os.Symlink(filepath.Join(".", "subdir"), filepath.Join(tempDir, "symlink_to_subdir")) - require.NoError(err) - err = os.Symlink(filepath.Join(tempDir, "symlink_to_subdir"), filepath.Join(tempDir, "symlink_to_symlink_to_subdir")) - require.NoError(err) + // Create symlinks when available -- both relative and absolute links. + if hasSymlinks { + err = os.Symlink(filepath.Join(".", "file1.txt"), filepath.Join(tempDir, "symlink_to_file1.txt")) + require.NoError(err) + //nolint:lll + err = os.Symlink( + filepath.Join(tempDir, "symlink_to_file1.txt"), + filepath.Join(tempDir, "symlink_to_symlink_to_file1.txt"), + ) + require.NoError(err) + err = os.Symlink(filepath.Join(".", "subdir"), filepath.Join(tempDir, "symlink_to_subdir")) + require.NoError(err) + err = os.Symlink( + filepath.Join(tempDir, "symlink_to_subdir"), + filepath.Join(tempDir, "symlink_to_symlink_to_subdir"), + ) + require.NoError(err) + } // Create zip file zipFile, err := os.CreateTemp("", "test_archive_*.zip") @@ -82,23 +82,25 @@ func TestCreateFromDirectory(t *testing.T) { err = rzip.CreateFromDirectory(tempDir, zipFile, nil) require.NoError(err) - // Check zip contents + // Check zip contents — expected files depend on symlink availability. expectedFiles := map[string]string{ - "file1.txt": "Content of file1", - "a/b/c/d/deep.txt": "Content of deep", - "subdir/file2.txt": "Content of file2", - "subdir/file3.txt": "Content of file3", - "symlink_to_file1.txt": "Content of file1", - "symlink_to_symlink_to_file1.txt": "Content of file1", - "symlink_to_subdir/file2.txt": "Content of file2", - "symlink_to_subdir/file3.txt": "Content of file3", - "symlink_to_symlink_to_subdir/file2.txt": "Content of file2", - "symlink_to_symlink_to_subdir/file3.txt": "Content of file3", + "file1.txt": "Content of file1", + "a/b/c/d/deep.txt": "Content of deep", + "subdir/file2.txt": "Content of file2", + "subdir/file3.txt": "Content of file3", + } + if hasSymlinks { + expectedFiles["symlink_to_file1.txt"] = "Content of file1" + expectedFiles["symlink_to_symlink_to_file1.txt"] = "Content of file1" + expectedFiles["symlink_to_subdir/file2.txt"] = "Content of file2" + expectedFiles["symlink_to_subdir/file3.txt"] = "Content of file3" + expectedFiles["symlink_to_symlink_to_subdir/file2.txt"] = "Content of file2" + expectedFiles["symlink_to_symlink_to_subdir/file3.txt"] = "Content of file3" } checkFiles(t, expectedFiles, zipFile) - // skip specific files and directories + // Test with filter callback — skip specific files and directories. onZip := func(src string, info os.FileInfo) (bool, error) { name := info.Name() isDir := info.IsDir() @@ -130,15 +132,17 @@ func TestCreateFromDirectory(t *testing.T) { err = rzip.CreateFromDirectory(tempDir, zipFile, onZip) require.NoError(err) - // Check zip contents + // Check zip contents with filter applied. expectedFiles = map[string]string{ - "file1.txt": "Content of file1", - "subdir/file2.txt": "Content of file2", - "subdir/file3.txt": "Content of file3", - "symlink_to_file1.txt": "Content of file1", - "symlink_to_symlink_to_file1.txt": "Content of file1", - "symlink_to_symlink_to_subdir/file2.txt": "Content of file2", - "symlink_to_symlink_to_subdir/file3.txt": "Content of file3", + "file1.txt": "Content of file1", + "subdir/file2.txt": "Content of file2", + "subdir/file3.txt": "Content of file3", + } + if hasSymlinks { + expectedFiles["symlink_to_file1.txt"] = "Content of file1" + expectedFiles["symlink_to_symlink_to_file1.txt"] = "Content of file1" + expectedFiles["symlink_to_symlink_to_subdir/file2.txt"] = "Content of file2" + expectedFiles["symlink_to_symlink_to_subdir/file3.txt"] = "Content of file3" } checkFiles(t, expectedFiles, zipFile) @@ -191,8 +195,14 @@ func TestCreateFromDirectory_SymlinkRecursive(t *testing.T) { err := os.Mkdir(filepath.Join(tmp, "dir"), 0755) require.NoError(t, err) - err = os.Symlink("../", filepath.Join(tmp, "dir", "dir_symlink")) - require.NoError(t, err) + // When symlinks are available, verify that a recursive symlink (dir → + // parent) doesn't cause infinite recursion during zip creation. + // When symlinks aren't available (e.g. Windows without Developer Mode), + // still verify that a plain directory zips correctly. + if symlinkAvailable(t) { + err = os.Symlink("../", filepath.Join(tmp, "dir", "dir_symlink")) + require.NoError(t, err) + } // Create zip file zipFile, err := os.CreateTemp("", "test_archive_*.zip") @@ -200,7 +210,7 @@ func TestCreateFromDirectory_SymlinkRecursive(t *testing.T) { defer os.Remove(zipFile.Name()) defer zipFile.Close() - // zip the directory + // zip the directory — must not hang or error regardless of symlink support err = rzip.CreateFromDirectory(tmp, zipFile, nil) require.NoError(t, err) } diff --git a/cli/azd/pkg/tools/kubectl/kube_config_test.go b/cli/azd/pkg/tools/kubectl/kube_config_test.go index 90b91191fd0..0e8c73a15d7 100644 --- a/cli/azd/pkg/tools/kubectl/kube_config_test.go +++ b/cli/azd/pkg/tools/kubectl/kube_config_test.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "testing" "github.com/azure/azure-dev/cli/azd/pkg/exec" @@ -16,8 +17,13 @@ import ( func Test_MergeKubeConfig(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - commandRunner := exec.NewCommandRunner(nil) - cli := NewCli(commandRunner) + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(command, "kubectl config view") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + // Return a valid merged kube config YAML so MergeConfigs can write it. + return exec.NewRunResult(0, "apiVersion: v1\nkind: Config\nclusters: []\ncontexts: []\nusers: []\n", ""), nil + }) + cli := NewCli(mockContext.CommandRunner) kubeConfigManager, err := NewKubeConfigManager(cli) require.NoError(t, err)