From 707307e1fc4a10e16a3e8f464f4657b164ecf019 Mon Sep 17 00:00:00 2001 From: Vladimir Iliakov Date: Thu, 28 Aug 2025 16:44:51 +0200 Subject: [PATCH 1/4] STAC-22609: Adding stackpack test command --- cmd/stackpack.go | 1 + cmd/stackpack/stackpack_test_cmd.go | 492 +++++++++++++++++++++++ cmd/stackpack/stackpack_test_cmd_test.go | 446 ++++++++++++++++++++ cmd/stackpack/stackpack_upgrade.go | 2 +- cmd/stackpack_test.go | 62 +-- 5 files changed, 973 insertions(+), 30 deletions(-) create mode 100644 cmd/stackpack/stackpack_test_cmd.go create mode 100644 cmd/stackpack/stackpack_test_cmd_test.go diff --git a/cmd/stackpack.go b/cmd/stackpack.go index 4671a19c..2d7f2c6c 100644 --- a/cmd/stackpack.go +++ b/cmd/stackpack.go @@ -32,6 +32,7 @@ func StackPackCommand(cli *di.Deps) *cobra.Command { if os.Getenv(experimentalStackpackEnvVar) != "" { cmd.AddCommand(stackpack.StackpackScaffoldCommand(cli)) cmd.AddCommand(stackpack.StackpackPackageCommand(cli)) + cmd.AddCommand(stackpack.StackpackTestCommand(cli)) } return cmd diff --git a/cmd/stackpack/stackpack_test_cmd.go b/cmd/stackpack/stackpack_test_cmd.go new file mode 100644 index 00000000..a58f325a --- /dev/null +++ b/cmd/stackpack/stackpack_test_cmd.go @@ -0,0 +1,492 @@ +package stackpack + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/blang/semver/v4" + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" +) + +const ( + stackpackConfigFile = "stackpack.conf" +) + +// TestArgs contains arguments for stackpack test command +type TestArgs struct { + StackpackDir string + Params map[string]string + Yes bool + UnlockedStrategy string +} + +// StackpackTestCommand creates the test subcommand +func StackpackTestCommand(cli *di.Deps) *cobra.Command { + args := &TestArgs{ + Params: make(map[string]string), + } + cmd := &cobra.Command{ + Use: "test", + Short: "Test a stackpack by packaging, uploading, and installing/upgrading", + Long: `Test a stackpack by running package, upload, and install/upgrade commands in sequence. + +This command will: +1. Read the stackpack name and version from ` + stackpackConfigFile + ` +2. Create a temporary copy of the stackpack directory +3. Update the version in the temporary copy with -cli-test.N suffix +4. Package the stackpack from the temporary copy into a zip file +5. Upload the zip file to the server (with confirmation) +6. Install the stackpack (if not installed) or upgrade it (if already installed) + +The original stackpack directory is left untouched. The version is automatically incremented for each test run. +The stackpack name is read from ` + stackpackConfigFile + `, so no --name flag is required.`, + Example: `# Test stackpack with confirmation +sts stackpack test -p "param1=value1" + +# Skip confirmation prompt +sts stackpack test --yes + +# Test stackpack in specific directory with unlocked strategy +sts stackpack test -d ./my-stackpack --yes --unlocked-strategy force + +# Test with custom unlocked strategy +sts stackpack test --unlocked-strategy skip --yes`, + RunE: cli.CmdRunEWithApi(RunStackpackTestCommand(args)), + } + + cmd.Flags().StringVarP(&args.StackpackDir, "stackpack-directory", "d", "", "Path to stackpack directory (defaults to current directory)") + cmd.Flags().StringToStringVarP(&args.Params, ParameterFlag, "p", args.Params, "List of parameters of the form \"key=value\"") + cmd.Flags().BoolVarP(&args.Yes, "yes", "y", false, "Skip confirmation prompt before upload") + cmd.Flags().StringVar(&args.UnlockedStrategy, UnlockedStrategyFlag, "fail", "Strategy for dealing with unlocked StackPacks. Valid options are: fail, force, skip") + + return cmd +} + +// RunStackpackTestCommand executes the test command +// +//nolint:funlen +func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { + return func( + cmd *cobra.Command, + cli *di.Deps, + api *stackstate_api.APIClient, + serverInfo *stackstate_api.ServerInfo, + ) common.CLIError { + // Warn if JSON output is requested - not meaningful for test command + if cli.IsJson() { + cli.Printer.PrintLn("Warning: JSON output format is not meaningful for the test command, proceeding with text output") + } + + // Set default stackpack directory + if args.StackpackDir == "" { + currentDir, err := os.Getwd() + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to get current directory: %w", err)) + } + args.StackpackDir = currentDir + } + + // Parse stackpack configuration to get original version + parser := &HoconParser{} + stackpackConf := filepath.Join(args.StackpackDir, stackpackConfigFile) + originalInfo, err := parser.Parse(stackpackConf) + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to parse %s: %w", stackpackConfigFile, err)) + } + + cli.Printer.Success("Starting stackpack test sequence...") + cli.Printer.PrintLn(fmt.Sprintf(" Stackpack: %s (current version: %s)", originalInfo.Name, originalInfo.Version)) + cli.Printer.PrintLn("") + + // Step 1: Check installed version and determine base version for snapshot + cli.Printer.PrintLn("Step 1/5: Checking installed version...") + installedVersion, err := getInstalledStackpackVersion(cli, api, originalInfo.Name) + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to check installed stackpack version: %w", err)) + } + + baseVersionForSnapshot := originalInfo.Version + if installedVersion != "" { + cli.Printer.PrintLn(fmt.Sprintf(" Found installed version: %s", installedVersion)) + + // Compare base versions (strip cli-test suffix from installed version for comparison) + installedBaseVersion := installedVersion + if strings.Contains(installedVersion, "-cli-test.") { + // Extract base version from cli-test version (e.g., "1.0.0-cli-test.5" -> "1.0.0") + if parts := strings.Split(installedVersion, "-cli-test."); len(parts) > 0 { + installedBaseVersion = parts[0] + } + } + + // Base version selection logic: + // 1. If installed base version is higher: use installed version + // 2. If base versions are equal AND installed has cli-test: use installed version (continue test sequence) + // 3. If local version is higher: use local version (prevents downgrade) + baseComparison := compareVersions(installedBaseVersion, originalInfo.Version) + if baseComparison > 0 || (baseComparison == 0 && strings.Contains(installedVersion, "-cli-test.")) { + baseVersionForSnapshot = installedVersion + cli.Printer.PrintLn(fmt.Sprintf(" Using installed version as base: %s", baseVersionForSnapshot)) + } else if baseComparison < 0 { + cli.Printer.PrintLn(fmt.Sprintf(" Using local version as base (higher than installed): %s", baseVersionForSnapshot)) + } + } + + // Step 2: Create temporary directory and copy stackpack + cli.Printer.PrintLn("") + cli.Printer.PrintLn("Step 2/5: Creating temporary copy for testing...") + + tempDir, err := os.MkdirTemp("", "stackpack-test-*") + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to create temporary directory: %w", err)) + } + + // Ensure cleanup of temporary directory + defer func() { + if removeErr := os.RemoveAll(tempDir); removeErr != nil { + cli.Printer.PrintLn(fmt.Sprintf("Warning: Failed to cleanup temporary directory: %v", removeErr)) + } + }() + + tempStackpackDir := filepath.Join(tempDir, "stackpack") + if err := copyDirectory(args.StackpackDir, tempStackpackDir); err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to copy stackpack to temporary directory: %w", err)) + } + cli.Printer.Success("✓ Temporary copy created") + + // Step 3: Update version in temporary copy + cli.Printer.PrintLn("") + cli.Printer.PrintLn("Step 3/5: Bumping version for testing...") + + tempConfigPath := filepath.Join(tempStackpackDir, stackpackConfigFile) + newVersion, err := bumpSnapshotVersionWithBase(tempConfigPath, baseVersionForSnapshot) + if err != nil { + return common.NewRuntimeError(fmt.Errorf("failed to bump version: %w", err)) + } + cli.Printer.Success(fmt.Sprintf("✓ Version bumped to: %s", newVersion)) + + // Step 4: Package stackpack from temporary directory + cli.Printer.PrintLn("") + cli.Printer.PrintLn("Step 4/5: Packaging stackpack...") + packageArgs := &PackageArgs{ + StackpackDir: tempStackpackDir, // Use temporary directory + Force: true, // Always overwrite for testing + } + + // Set archive file in current directory + currentDir, _ := os.Getwd() + packageArgs.ArchiveFile = filepath.Join(currentDir, fmt.Sprintf("%s-%s.zip", originalInfo.Name, newVersion)) + + if err := runPackageStep(cli, packageArgs); err != nil { + return err + } + cli.Printer.Success("✓ Stackpack packaged successfully") + + // Step 5: Confirm upload (if needed) and execute upload/install workflow + if !args.Yes { + cli.Printer.PrintLn("") + if !confirmUpload(cli, packageArgs.ArchiveFile) { + return common.NewRuntimeError(fmt.Errorf("upload cancelled by user")) + } + } + + // Upload stackpack + cli.Printer.PrintLn("") + cli.Printer.PrintLn("Step 5/5: Uploading and installing/upgrading stackpack...") + uploadArgs := &UploadArgs{ + FilePath: packageArgs.ArchiveFile, + } + + if err := runUploadStep(cli, api, serverInfo, uploadArgs); err != nil { + return err + } + cli.Printer.Success("✓ Stackpack uploaded successfully") + + // Install or upgrade stackpack based on installation status + if installedVersion != "" { + upgradeArgs := &UpgradeArgs{ + TypeName: originalInfo.Name, + UnlockedStrategy: args.UnlockedStrategy, + } + + if err := runUpgradeStep(cli, api, serverInfo, upgradeArgs); err != nil { + return err + } + cli.Printer.Success("✓ Stackpack upgraded successfully") + } else { + installArgs := &InstallArgs{ + Name: originalInfo.Name, + UnlockedStrategy: args.UnlockedStrategy, + Params: args.Params, + } + + if err := runInstallStep(cli, api, serverInfo, installArgs); err != nil { + return err + } + cli.Printer.Success("✓ Stackpack installed successfully") + } + + cli.Printer.PrintLn("") + cli.Printer.Success("🎉 Test sequence completed successfully!") + + // Clean up zip file + if err := os.Remove(packageArgs.ArchiveFile); err != nil { + cli.Printer.PrintLn(fmt.Sprintf("Note: Could not clean up zip file %s: %v", packageArgs.ArchiveFile, err)) + } + + return nil + } +} + +// bumpSnapshotVersionWithBase increments cli-test version using a different base version for computation +// Logic: If baseVersion contains "-cli-test.N", increments N to N+1 +// If no cli-test suffix exists, adds "-cli-test.1" to the base version +// Examples: "1.0.0" -> "1.0.0-cli-test.1", "1.0.0-cli-test.5" -> "1.0.0-cli-test.6" +func bumpSnapshotVersionWithBase(configPath, baseVersion string) (string, error) { + // Parse the base version using semver + version, err := semver.Parse(baseVersion) + if err != nil { + return "", fmt.Errorf("failed to parse base version %s: %w", baseVersion, err) + } + + var newVersion string + // Search for existing "cli-test" pre-release identifier + cliTestIndex := -1 + for i, pre := range version.Pre { + if pre.VersionStr == "cli-test" && i+1 < len(version.Pre) { + cliTestIndex = i + break + } + } + + if cliTestIndex >= 0 && cliTestIndex+1 < len(version.Pre) { + // Existing cli-test found: increment its number + nextPart := version.Pre[cliTestIndex+1] + var currentNum uint64 + + // Extract current cli-test number (handles both numeric and string formats) + if nextPart.VersionStr == "" { + // Numeric format: use VersionNum directly + currentNum = nextPart.VersionNum + } else if num, err := strconv.ParseUint(nextPart.VersionStr, 10, 64); err == nil { + // String format: parse to number + currentNum = num + } else { + // Invalid format: reset to cli-test.1 + newVersion = fmt.Sprintf("%d.%d.%d-cli-test.1", version.Major, version.Minor, version.Patch) + return newVersion, updateVersionInHocon(configPath, newVersion) + } + + // Rebuild pre-release parts with incremented cli-test number + newPreParts := make([]string, len(version.Pre)) + for i, pre := range version.Pre { + switch { + case i == cliTestIndex+1: + // Increment the cli-test number + newPreParts[i] = fmt.Sprintf("%d", currentNum+1) + case pre.VersionStr == "": + // Convert numeric pre-release parts to string + newPreParts[i] = fmt.Sprintf("%d", pre.VersionNum) + default: + // Keep string pre-release parts as-is + newPreParts[i] = pre.VersionStr + } + } + newVersion = fmt.Sprintf("%d.%d.%d-%s", version.Major, version.Minor, version.Patch, strings.Join(newPreParts, ".")) + } else { + // No cli-test found: add initial cli-test.1 suffix + newVersion = fmt.Sprintf("%d.%d.%d-cli-test.1", version.Major, version.Minor, version.Patch) + } + + return newVersion, updateVersionInHocon(configPath, newVersion) +} + +// confirmUpload prompts user for confirmation before upload +func confirmUpload(cli *di.Deps, zipFile string) bool { + serverURL := "unknown" + if cli.CurrentContext != nil { + serverURL = cli.CurrentContext.URL + } + + cli.Printer.PrintLn(fmt.Sprintf("⚠️ This will upload '%s' to SUSE Observability server:", filepath.Base(zipFile))) + cli.Printer.PrintLn(fmt.Sprintf(" Server: %s", serverURL)) + fmt.Print(" Continue? (y/N): ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return false + } + + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes" +} + +// runPackageStep executes the package command logic +func runPackageStep(cli *di.Deps, args *PackageArgs) common.CLIError { + // Reuse the existing package command logic + packageCmd := &cobra.Command{} + packageFn := RunStackpackPackageCommand(args) + return packageFn(cli, packageCmd) +} + +// runUploadStep executes the upload command logic +func runUploadStep(cli *di.Deps, api *stackstate_api.APIClient, serverInfo *stackstate_api.ServerInfo, args *UploadArgs) common.CLIError { + // Reuse the existing upload command logic + uploadCmd := &cobra.Command{} + uploadFn := RunStackpackUploadCommand(args) + return uploadFn(uploadCmd, cli, api, serverInfo) +} + +// runInstallStep executes the install command logic +func runInstallStep(cli *di.Deps, api *stackstate_api.APIClient, serverInfo *stackstate_api.ServerInfo, args *InstallArgs) common.CLIError { + // Reuse the existing install command logic + installCmd := &cobra.Command{} + installFn := RunStackpackInstallCommand(args) + return installFn(installCmd, cli, api, serverInfo) +} + +// runUpgradeStep executes the upgrade command logic +func runUpgradeStep(cli *di.Deps, api *stackstate_api.APIClient, serverInfo *stackstate_api.ServerInfo, args *UpgradeArgs) common.CLIError { + // Reuse the existing upgrade command logic + upgradeCmd := &cobra.Command{} + upgradeFn := RunStackpackUpgradeCommand(args) + return upgradeFn(upgradeCmd, cli, api, serverInfo) +} + +// getInstalledStackpackVersion checks if a stackpack is installed and returns its version +func getInstalledStackpackVersion(cli *di.Deps, api *stackstate_api.APIClient, stackpackName string) (string, error) { + stackPackList, err := fetchAllStackPacks(cli, api) + if err != nil { + return "", err + } + + for _, stackpack := range stackPackList { + if stackpack.Name == stackpackName && len(stackpack.GetConfigurations()) > 0 { + // Stackpack is installed, return its version + return stackpack.Version, nil + } + } + + // Stackpack not installed + return "", nil +} + +// compareVersions compares two version strings using semver +// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 +func compareVersions(v1, v2 string) int { + ver1, err1 := semver.Parse(v1) + ver2, err2 := semver.Parse(v2) + + if err1 != nil || err2 != nil { + // If parsing fails, treat as equal (shouldn't happen with valid semver) + return 0 + } + + return ver1.Compare(ver2) +} + +// copyDirectory recursively copies a directory to a destination +func copyDirectory(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Calculate the destination path + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, info.Mode()) + } + + return copyFile(path, dstPath, info.Mode()) + }) +} + +// copyFile copies a single file +func copyFile(src, dst string, mode os.FileMode) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + if err := dstFile.Chmod(mode); err != nil { + return err + } + + _, err = io.Copy(dstFile, srcFile) + return err +} + +// updateVersionInHocon updates the version field in a HOCON configuration using regex +func updateVersionInHocon(configPath, newVersion string) error { + // Read the current config + content, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // Parse as HOCON to validate structure + parser := &HoconParser{} + _, err = parser.Parse(configPath) + if err != nil { + return fmt.Errorf("failed to parse HOCON config: %w", err) + } + + // Use regex to replace version while preserving HOCON structure + // This is more reliable than string replacement as it targets the version field specifically + oldContent := string(content) + versionRegex := regexp.MustCompile(`(?m)^(\s*version\s*=\s*)"[^"]*"(.*)$`) + newContent := versionRegex.ReplaceAllString(oldContent, `${1}"`+newVersion+`"${2}`) + + if oldContent == newContent { + // Try unquoted version format + versionRegex = regexp.MustCompile(`(?m)^(\s*version\s*=\s*)[^,\n\r}]*(.*)$`) + newContent = versionRegex.ReplaceAllString(oldContent, `${1}"`+newVersion+`"${2}`) + } + + if oldContent == newContent { + return fmt.Errorf("version field not found in config file") + } + + // Validate the result can still be parsed + tempFile := configPath + ".tmp" + if err := os.WriteFile(tempFile, []byte(newContent), os.FileMode(defaultDirMode)); err != nil { + return fmt.Errorf("failed to write temporary config: %w", err) + } + + // Validate the modified config + _, err = parser.Parse(tempFile) + if err != nil { + _ = os.Remove(tempFile) + return fmt.Errorf("modified config is not valid HOCON: %w", err) + } + + // Replace the original file + if err := os.Rename(tempFile, configPath); err != nil { + _ = os.Remove(tempFile) + return fmt.Errorf("failed to replace config file: %w", err) + } + + return nil +} diff --git a/cmd/stackpack/stackpack_test_cmd_test.go b/cmd/stackpack/stackpack_test_cmd_test.go new file mode 100644 index 00000000..0390766c --- /dev/null +++ b/cmd/stackpack/stackpack_test_cmd_test.go @@ -0,0 +1,446 @@ +package stackpack + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/blang/semver/v4" + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupStackpackTestCmd creates a test command with mock dependencies +func setupStackpackTestCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := StackpackTestCommand(&cli.Deps) + return &cli, cmd +} + +func TestStackpackTestCommand_FlagsAndStructure(t *testing.T) { + _, cmd := setupStackpackTestCmd(t) + + // Test command structure + assert.Equal(t, "test", cmd.Use) + assert.Contains(t, cmd.Short, "Test a stackpack") + assert.Contains(t, cmd.Long, "package, upload, and install") + assert.NotEmpty(t, cmd.Example) + + // Test flags exist + flags := cmd.Flags() + + stackpackDirFlag := flags.Lookup("stackpack-directory") + require.NotNil(t, stackpackDirFlag) + assert.Equal(t, "d", stackpackDirFlag.Shorthand) + + paramsFlag := flags.Lookup("parameter") + require.NotNil(t, paramsFlag) + assert.Equal(t, "p", paramsFlag.Shorthand) + + yesFlag := flags.Lookup("yes") + require.NotNil(t, yesFlag) + assert.Equal(t, "y", yesFlag.Shorthand) + + unlockedStrategyFlag := flags.Lookup("unlocked-strategy") + require.NotNil(t, unlockedStrategyFlag) + assert.Equal(t, "fail", unlockedStrategyFlag.DefValue) +} + +func TestBumpSnapshotVersion(t *testing.T) { + tests := []struct { + name string + currentVersion string + expectedVersion string + }{ + { + name: "first cli-test version", + currentVersion: "1.0.0", + expectedVersion: "1.0.0-cli-test.1", + }, + { + name: "increment existing cli-test", + currentVersion: "1.0.0-cli-test.1", + expectedVersion: "1.0.0-cli-test.2", + }, + { + name: "increment higher cli-test number", + currentVersion: "2.1.5-cli-test.10", + expectedVersion: "2.1.5-cli-test.11", + }, + { + name: "complex version with cli-test", + currentVersion: "1.0.0-beta.1.cli-test.3", + expectedVersion: "1.0.0-beta.1.cli-test.4", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary config file + tempDir, err := os.MkdirTemp("", "stackpack-version-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + configPath := filepath.Join(tempDir, "stackpack.conf") + configContent := fmt.Sprintf(`name = "test-stackpack" +version = "%s" +displayName = "Test StackPack"`, tt.currentVersion) + require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644)) + + // Test version bumping + newVersion, err := bumpSnapshotVersionWithBase(configPath, tt.currentVersion) + require.NoError(t, err) + assert.Equal(t, tt.expectedVersion, newVersion) + + // Verify config file was updated + parser := &HoconParser{} + updatedInfo, err := parser.Parse(configPath) + require.NoError(t, err) + assert.Equal(t, tt.expectedVersion, updatedInfo.Version) + }) + } +} + +func TestUpdateVersionInHocon(t *testing.T) { + // Create temporary config file + tempDir, err := os.MkdirTemp("", "stackpack-hocon-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + configPath := filepath.Join(tempDir, "stackpack.conf") + originalVersion := "2.0.0" + newVersion := "2.0.0-cli-test.1" + + // Create config with original version + configContent := fmt.Sprintf(`name = "test-stackpack" +version = "%s" +displayName = "Test StackPack"`, originalVersion) + require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644)) + + // Test version update using HOCON approach + err = updateVersionInHocon(configPath, newVersion) + require.NoError(t, err) + + // Verify config file was updated and is still valid HOCON + parser := &HoconParser{} + updatedInfo, err := parser.Parse(configPath) + require.NoError(t, err) + assert.Equal(t, newVersion, updatedInfo.Version) + assert.Equal(t, "test-stackpack", updatedInfo.Name) +} + +func TestCopyDirectory(t *testing.T) { + // Create source directory with test files + tempDir, err := os.MkdirTemp("", "copy-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + srcDir := filepath.Join(tempDir, "src") + dstDir := filepath.Join(tempDir, "dst") + + // Create source structure + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "subdir"), 0755)) + + testContent := "test content" + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte(testContent), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "subdir", "file2.txt"), []byte(testContent), 0644)) + + // Test directory copy + err = copyDirectory(srcDir, dstDir) + require.NoError(t, err) + + // Verify files were copied + copiedContent, err := os.ReadFile(filepath.Join(dstDir, "file1.txt")) + require.NoError(t, err) + assert.Equal(t, testContent, string(copiedContent)) + + copiedContent2, err := os.ReadFile(filepath.Join(dstDir, "subdir", "file2.txt")) + require.NoError(t, err) + assert.Equal(t, testContent, string(copiedContent2)) +} + +func TestStackpackTestCommand_RequiredFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + errorMessage string + }{ + { + name: "valid minimal args - no flags required", + args: []string{}, + wantErr: false, + }, + { + name: "with all flags", + args: []string{"-d", "./test", "-p", "param1=value1", "--yes", "--unlocked-strategy", "force"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, cmd := setupStackpackTestCmd(t) + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...) + + if tt.wantErr { + require.Error(t, err) + if tt.errorMessage != "" { + assert.Contains(t, err.Error(), tt.errorMessage) + } + } else if err != nil { + // Note: This will fail due to missing stackpack.conf, but that's expected + // We're only testing flag parsing here + // Should fail on stackpack.conf parsing, not flag validation + assert.Contains(t, err.Error(), "stackpack.conf") + } + }) + } +} + +func TestStackpackTestCommand_DirectoryHandling(t *testing.T) { + // Create temporary directory with valid stackpack structure + tempDir, err := os.MkdirTemp("", "stackpack-test-dir-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + stackpackDir := filepath.Join(tempDir, "test-stackpack") + require.NoError(t, os.MkdirAll(stackpackDir, 0755)) + + // Create required files + createTestStackpack(t, stackpackDir, "test-stackpack", "1.0.0") + + cli, cmd := setupStackpackTestCmd(t) + + tests := []struct { + name string + args []string + }{ + { + name: "explicit directory", + args: []string{"-d", stackpackDir, "--yes"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: This test will fail at the API call stage since we're using mock deps + // But we can verify that directory parsing works + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, tt.args...) + + // Should fail on API calls (upload/install), not on directory or config parsing + if err != nil { + // The error should not be about missing stackpack.conf or directory issues + assert.NotContains(t, err.Error(), "stackpack.conf") + assert.NotContains(t, err.Error(), "no such file or directory") + } + }) + } +} + +//nolint:funlen +func TestVersionRegexPatterns(t *testing.T) { + tests := []struct { + name string + version string + expectedBase string + expectedNumber int + isSnapshot bool + }{ + { + name: "regular version", + version: "1.0.0", + expectedBase: "", + expectedNumber: 0, + isSnapshot: false, + }, + { + name: "first cli-test", + version: "1.0.0-cli-test.1", + expectedBase: "1.0.0", + expectedNumber: 1, + isSnapshot: true, + }, + { + name: "higher cli-test number", + version: "2.1.0-cli-test.15", + expectedBase: "2.1.0", + expectedNumber: 15, + isSnapshot: true, + }, + { + name: "complex base version", + version: "1.0.0-beta.1.cli-test.3", + expectedBase: "1.0.0", + expectedNumber: 3, + isSnapshot: true, + }, + { + name: "semantic version with cli-test", + version: "10.20.30-cli-test.99", + expectedBase: "10.20.30", + expectedNumber: 99, + isSnapshot: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This tests the semver parsing used in bumpSnapshotVersion + version, err := semver.Parse(tt.version) + if tt.isSnapshot { + require.NoError(t, err, "Should be able to parse valid semver version") + // Check if it has cli-test pre-release + require.Greater(t, len(version.Pre), 1, "Should have at least 2 pre-release parts for cli-test") + + // Find cli-test index + cliTestIndex := -1 + for i, pre := range version.Pre { + if pre.VersionStr == "cli-test" && i+1 < len(version.Pre) { + cliTestIndex = i + break + } + } + require.GreaterOrEqual(t, cliTestIndex, 0, "Should have cli-test in pre-release parts") + + var actualNumber int + numberPart := version.Pre[cliTestIndex+1] + if numberPart.VersionStr == "" { + // Numeric identifier stored in VersionNum + actualNumber = int(numberPart.VersionNum) + } else { + // String identifier that should be numeric + var parseErr error + actualNumber, parseErr = strconv.Atoi(numberPart.VersionStr) + require.NoError(t, parseErr, "Number part after cli-test should be a number") + } + assert.Equal(t, tt.expectedNumber, actualNumber) + + // Reconstruct base version + expectedBase := fmt.Sprintf("%d.%d.%d", version.Major, version.Minor, version.Patch) + assert.Equal(t, tt.expectedBase, expectedBase) + } else { + require.NoError(t, err, "Should be able to parse regular version") + assert.Len(t, version.Pre, 0, "Regular version should have no pre-release parts") + } + }) + } +} + +func TestCompareVersionsSemver(t *testing.T) { + tests := []struct { + name string + v1 string + v2 string + expected int // -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 + }{ + { + name: "equal versions", + v1: "1.0.0", + v2: "1.0.0", + expected: 0, + }, + { + name: "v1 less than v2", + v1: "1.0.0", + v2: "1.1.0", + expected: -1, + }, + { + name: "v1 greater than v2", + v1: "2.0.0", + v2: "1.9.9", + expected: 1, + }, + { + name: "pre-release versions - cli-test comparison", + v1: "1.0.0-cli-test.1", + v2: "1.0.0-cli-test.2", + expected: -1, + }, + { + name: "pre-release vs release", + v1: "1.0.0-cli-test.1", + v2: "1.0.0", + expected: -1, + }, + { + name: "major version difference", + v1: "2.0.0-cli-test.1", + v2: "1.9.9", + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compareVersions(tt.v1, tt.v2) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestStackpackTestCommand_InstallUpgradeLogic(t *testing.T) { + tests := []struct { + name string + installedVersion string + expectedAction string + }{ + { + name: "not installed - should install", + installedVersion: "", + expectedAction: "install", + }, + { + name: "already installed - should upgrade", + installedVersion: "1.0.0", + expectedAction: "upgrade", + }, + { + name: "already installed with pre-release - should upgrade", + installedVersion: "1.0.0-beta.1", + expectedAction: "upgrade", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This test verifies the logic for choosing between install and upgrade + // The actual logic is: if installedVersion != "" then upgrade, else install + var shouldUpgrade bool + if tt.installedVersion != "" { + shouldUpgrade = true + } + + if tt.expectedAction == "upgrade" { + assert.True(t, shouldUpgrade, "Should choose upgrade when stackpack is already installed") + } else { + assert.False(t, shouldUpgrade, "Should choose install when stackpack is not installed") + } + }) + } +} + +func TestConfirmUpload(t *testing.T) { + // Note: This test is more complex because it involves stdin interaction + // For now, we test the function exists and can be called + // A full integration test would require mocking stdin + + cli := di.NewMockDeps(t) + + // Test that the function exists and doesn't panic + // Note: This will block waiting for input, so we skip actual execution + // In a real test environment, you'd mock the stdin reader + + // Just verify the function signature is correct + var confirmFunc func(*di.Deps, string) bool = confirmUpload + assert.NotNil(t, confirmFunc) + + // Test URL extraction logic implicitly by checking context access + // Note: In mock environment, CurrentContext may be nil, which is expected + _ = cli.CurrentContext // Just ensure the field exists +} diff --git a/cmd/stackpack/stackpack_upgrade.go b/cmd/stackpack/stackpack_upgrade.go index 14304deb..745bc9f5 100644 --- a/cmd/stackpack/stackpack_upgrade.go +++ b/cmd/stackpack/stackpack_upgrade.go @@ -61,7 +61,7 @@ func RunStackpackUpgradeCommand(args *UpgradeArgs) di.CmdWithApiFn { return common.NewNotFoundError(err) } if !stack.HasNextVersion() { - return common.NewNotFoundError(fmt.Errorf("stackpack %s cannot be upgraded at this moment", args.TypeName)) + return common.NewRuntimeError(fmt.Errorf("stackpack %s cannot be upgraded at this moment", args.TypeName)) } _, resp, err = api.StackpackApi.UpgradeStackPack(cli.Context, args.TypeName).Unlocked(args.UnlockedStrategy).Execute() if err != nil { diff --git a/cmd/stackpack_test.go b/cmd/stackpack_test.go index a47958b5..2d58cf70 100644 --- a/cmd/stackpack_test.go +++ b/cmd/stackpack_test.go @@ -11,37 +11,39 @@ import ( func TestStackPackCommand_FeatureGating(t *testing.T) { tests := []struct { - name string - envVarValue string - expectScaffoldCommand bool - description string + name string + envVarValue string + expectExperimentalCommands bool + description string }{ { - name: "scaffold command hidden by default", - envVarValue: "", - expectScaffoldCommand: false, - description: "When environment variable is not set, scaffold command should be hidden", + name: "experimental commands hidden by default", + envVarValue: "", + expectExperimentalCommands: false, + description: "When environment variable is not set, experimental commands should be hidden", }, { - name: "scaffold command visible when env var is set to 1", - envVarValue: "1", - expectScaffoldCommand: true, - description: "When environment variable is set to '1', scaffold command should be visible", + name: "experimental commands visible when env var is set to 1", + envVarValue: "1", + expectExperimentalCommands: true, + description: "When environment variable is set to '1', experimental commands should be visible", }, { - name: "scaffold command visible when env var is set to any value", - envVarValue: "true", - expectScaffoldCommand: true, - description: "When environment variable is set to any non-empty value, scaffold command should be visible", + name: "experimental commands visible when env var is set to any value", + envVarValue: "true", + expectExperimentalCommands: true, + description: "When environment variable is set to any non-empty value, experimental commands should be visible", }, { - name: "scaffold command visible when env var is set to enabled", - envVarValue: "enabled", - expectScaffoldCommand: true, - description: "When environment variable is set to 'enabled', scaffold command should be visible", + name: "experimental commands visible when env var is set to enabled", + envVarValue: "enabled", + expectExperimentalCommands: true, + description: "When environment variable is set to 'enabled', experimental commands should be visible", }, } + experimentalCommands := []string{"scaffold", "package", "test"} + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Store original environment value to restore later @@ -66,16 +68,18 @@ func TestStackPackCommand_FeatureGating(t *testing.T) { cli := di.NewMockDeps(t) cmd := StackPackCommand(&cli.Deps) - // Check if scaffold command exists - scaffoldCmd, _, err := cmd.Find([]string{"scaffold"}) + // Check each experimental command + for _, cmdName := range experimentalCommands { + foundCmd, _, err := cmd.Find([]string{cmdName}) - if tt.expectScaffoldCommand { - assert.NoError(t, err, tt.description) - assert.NotNil(t, scaffoldCmd, tt.description) - assert.Equal(t, "scaffold", scaffoldCmd.Use, tt.description) - } else { - assert.Error(t, err, tt.description) - assert.Contains(t, err.Error(), "unknown command", tt.description) + if tt.expectExperimentalCommands { + assert.NoError(t, err, tt.description+" (command: %s)", cmdName) + assert.NotNil(t, foundCmd, tt.description+" (command: %s)", cmdName) + assert.Equal(t, cmdName, foundCmd.Use, tt.description+" (command: %s)", cmdName) + } else { + assert.Error(t, err, tt.description+" (command: %s)", cmdName) + assert.Contains(t, err.Error(), "unknown command", tt.description+" (command: %s)", cmdName) + } } }) } From 247ba174bb7723ff6b8a9420a2ff0750808723a4 Mon Sep 17 00:00:00 2001 From: Vladimir Iliakov Date: Mon, 1 Sep 2025 10:01:58 +0200 Subject: [PATCH 2/4] STAC-22609: Fix comment and test --- cmd/stackpack/stackpack_test_cmd.go | 16 ++++++++-------- cmd/stackpack/stackpack_test_cmd_test.go | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/stackpack/stackpack_test_cmd.go b/cmd/stackpack/stackpack_test_cmd.go index a58f325a..8ce16d65 100644 --- a/cmd/stackpack/stackpack_test_cmd.go +++ b/cmd/stackpack/stackpack_test_cmd.go @@ -248,8 +248,8 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { // bumpSnapshotVersionWithBase increments cli-test version using a different base version for computation // Logic: If baseVersion contains "-cli-test.N", increments N to N+1 -// If no cli-test suffix exists, adds "-cli-test.1" to the base version -// Examples: "1.0.0" -> "1.0.0-cli-test.1", "1.0.0-cli-test.5" -> "1.0.0-cli-test.6" +// If no cli-test suffix exists, adds "-cli-test.10000" to the base version (high number for alphanumeric ordering) +// Examples: "1.0.0" -> "1.0.0-cli-test.10000", "1.0.0-cli-test.10005" -> "1.0.0-cli-test.10006" func bumpSnapshotVersionWithBase(configPath, baseVersion string) (string, error) { // Parse the base version using semver version, err := semver.Parse(baseVersion) @@ -273,15 +273,15 @@ func bumpSnapshotVersionWithBase(configPath, baseVersion string) (string, error) var currentNum uint64 // Extract current cli-test number (handles both numeric and string formats) - if nextPart.VersionStr == "" { + if nextPart.IsNumeric() { // Numeric format: use VersionNum directly currentNum = nextPart.VersionNum } else if num, err := strconv.ParseUint(nextPart.VersionStr, 10, 64); err == nil { // String format: parse to number currentNum = num } else { - // Invalid format: reset to cli-test.1 - newVersion = fmt.Sprintf("%d.%d.%d-cli-test.1", version.Major, version.Minor, version.Patch) + // Invalid format: reset to cli-test.10000 + newVersion = fmt.Sprintf("%d.%d.%d-cli-test.10000", version.Major, version.Minor, version.Patch) return newVersion, updateVersionInHocon(configPath, newVersion) } @@ -292,7 +292,7 @@ func bumpSnapshotVersionWithBase(configPath, baseVersion string) (string, error) case i == cliTestIndex+1: // Increment the cli-test number newPreParts[i] = fmt.Sprintf("%d", currentNum+1) - case pre.VersionStr == "": + case pre.IsNumeric(): // Convert numeric pre-release parts to string newPreParts[i] = fmt.Sprintf("%d", pre.VersionNum) default: @@ -302,8 +302,8 @@ func bumpSnapshotVersionWithBase(configPath, baseVersion string) (string, error) } newVersion = fmt.Sprintf("%d.%d.%d-%s", version.Major, version.Minor, version.Patch, strings.Join(newPreParts, ".")) } else { - // No cli-test found: add initial cli-test.1 suffix - newVersion = fmt.Sprintf("%d.%d.%d-cli-test.1", version.Major, version.Minor, version.Patch) + // No cli-test found: add initial cli-test.10000 suffix + newVersion = fmt.Sprintf("%d.%d.%d-cli-test.10000", version.Major, version.Minor, version.Patch) } return newVersion, updateVersionInHocon(configPath, newVersion) diff --git a/cmd/stackpack/stackpack_test_cmd_test.go b/cmd/stackpack/stackpack_test_cmd_test.go index 0390766c..da1a54f1 100644 --- a/cmd/stackpack/stackpack_test_cmd_test.go +++ b/cmd/stackpack/stackpack_test_cmd_test.go @@ -59,7 +59,7 @@ func TestBumpSnapshotVersion(t *testing.T) { { name: "first cli-test version", currentVersion: "1.0.0", - expectedVersion: "1.0.0-cli-test.1", + expectedVersion: "1.0.0-cli-test.10000", }, { name: "increment existing cli-test", From f162e5651e6e37ed05e3495f92ef59e4428e4abf Mon Sep 17 00:00:00 2001 From: Vladimir Iliakov Date: Thu, 4 Sep 2025 11:24:18 +0200 Subject: [PATCH 3/4] STAC-22609: stackpack test waits for installation/upgrade results --- cmd/stackpack/stackpack_test_cmd.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/stackpack/stackpack_test_cmd.go b/cmd/stackpack/stackpack_test_cmd.go index 8ce16d65..6dcb1fa5 100644 --- a/cmd/stackpack/stackpack_test_cmd.go +++ b/cmd/stackpack/stackpack_test_cmd.go @@ -215,6 +215,8 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { upgradeArgs := &UpgradeArgs{ TypeName: originalInfo.Name, UnlockedStrategy: args.UnlockedStrategy, + Wait: true, + Timeout: DefaultTimeout, } if err := runUpgradeStep(cli, api, serverInfo, upgradeArgs); err != nil { @@ -226,6 +228,8 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { Name: originalInfo.Name, UnlockedStrategy: args.UnlockedStrategy, Params: args.Params, + Wait: true, + Timeout: DefaultTimeout, } if err := runInstallStep(cli, api, serverInfo, installArgs); err != nil { From 4c1af838824600ed44ed64bc14aef4ff15c7c9d7 Mon Sep 17 00:00:00 2001 From: Vladimir Iliakov Date: Thu, 4 Sep 2025 12:39:13 +0200 Subject: [PATCH 4/4] STAC-22609: Improving logging --- cmd/stackpack/common.go | 2 +- cmd/stackpack/stackpack_package.go | 2 +- cmd/stackpack/stackpack_package_test.go | 6 +++--- cmd/stackpack/stackpack_scaffold.go | 2 +- cmd/stackpack/stackpack_test_cmd.go | 12 ++++++------ 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/stackpack/common.go b/cmd/stackpack/common.go index 391c6e1c..6e501b46 100644 --- a/cmd/stackpack/common.go +++ b/cmd/stackpack/common.go @@ -105,7 +105,7 @@ func (w *OperationWaiter) WaitForCompletion(options WaitOptions) error { // Return immediately if any configuration has failed if len(errorMessages) > 0 { - return fmt.Errorf("stackpack '%s' installation failed:\n%s", options.StackPackName, strings.Join(errorMessages, "\n")) + return fmt.Errorf("stackpack '%s' failed:\n%s", options.StackPackName, strings.Join(errorMessages, "\n")) } // Success: all configurations are installed and none are provisioning diff --git a/cmd/stackpack/stackpack_package.go b/cmd/stackpack/stackpack_package.go index 966e160a..ee260db8 100644 --- a/cmd/stackpack/stackpack_package.go +++ b/cmd/stackpack/stackpack_package.go @@ -195,7 +195,7 @@ func RunStackpackPackageCommand(args *PackageArgs) func(cli *di.Deps, cmd *cobra "source_dir": args.StackpackDir, }) } else { - cli.Printer.Successf("✓ Stackpack packaged successfully!") + cli.Printer.Successf("Stackpack packaged successfully!") cli.Printer.PrintLn("") cli.Printer.PrintLn(fmt.Sprintf("Stackpack: %s (v%s)", stackpackInfo.Name, stackpackInfo.Version)) cli.Printer.PrintLn(fmt.Sprintf("Zip file: %s", args.ArchiveFile)) diff --git a/cmd/stackpack/stackpack_package_test.go b/cmd/stackpack/stackpack_package_test.go index 10997699..a4a90b52 100644 --- a/cmd/stackpack/stackpack_package_test.go +++ b/cmd/stackpack/stackpack_package_test.go @@ -79,7 +79,7 @@ func TestStackpackPackageCommand_DefaultBehavior(t *testing.T) { // Verify text output require.NotEmpty(t, *cli.MockPrinter.SuccessCalls) successCall := (*cli.MockPrinter.SuccessCalls)[0] - assert.Contains(t, successCall, "✓ Stackpack packaged successfully!") + assert.Contains(t, successCall, "Stackpack packaged successfully!") require.NotEmpty(t, *cli.MockPrinter.PrintLnCalls) printLnCalls := *cli.MockPrinter.PrintLnCalls @@ -143,7 +143,7 @@ func TestStackpackPackageCommand_ForceFlag(t *testing.T) { // Verify success message require.NotEmpty(t, *cli2.MockPrinter.SuccessCalls) successCall := (*cli2.MockPrinter.SuccessCalls)[0] - assert.Contains(t, successCall, "✓ Stackpack packaged successfully!") + assert.Contains(t, successCall, "Stackpack packaged successfully!") } func TestStackpackPackageCommand_JSONOutput(t *testing.T) { @@ -568,7 +568,7 @@ func TestStackpackPackageCommand_TextOutput(t *testing.T) { // Verify success message require.NotEmpty(t, *cli.MockPrinter.SuccessCalls) successCall := (*cli.MockPrinter.SuccessCalls)[0] - assert.Contains(t, successCall, "✓ Stackpack packaged successfully!") + assert.Contains(t, successCall, "Stackpack packaged successfully!") // Verify stackpack info is printed printLnCalls := *cli.MockPrinter.PrintLnCalls diff --git a/cmd/stackpack/stackpack_scaffold.go b/cmd/stackpack/stackpack_scaffold.go index ec0bcffe..90da66ad 100644 --- a/cmd/stackpack/stackpack_scaffold.go +++ b/cmd/stackpack/stackpack_scaffold.go @@ -155,7 +155,7 @@ func RunStackpackScaffoldCommand(args *ScaffoldArgs) func(cli *di.Deps, cmd *cob }) } else { // Display success message and next steps - cli.Printer.Successf("✓ Scaffold complete!") + cli.Printer.Successf("Scaffold complete!") cli.Printer.PrintLn("") displayNextSteps(cli, args) } diff --git a/cmd/stackpack/stackpack_test_cmd.go b/cmd/stackpack/stackpack_test_cmd.go index 6dcb1fa5..3570ac28 100644 --- a/cmd/stackpack/stackpack_test_cmd.go +++ b/cmd/stackpack/stackpack_test_cmd.go @@ -160,7 +160,7 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { if err := copyDirectory(args.StackpackDir, tempStackpackDir); err != nil { return common.NewRuntimeError(fmt.Errorf("failed to copy stackpack to temporary directory: %w", err)) } - cli.Printer.Success("✓ Temporary copy created") + cli.Printer.Success("Temporary copy created") // Step 3: Update version in temporary copy cli.Printer.PrintLn("") @@ -171,7 +171,7 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { if err != nil { return common.NewRuntimeError(fmt.Errorf("failed to bump version: %w", err)) } - cli.Printer.Success(fmt.Sprintf("✓ Version bumped to: %s", newVersion)) + cli.Printer.Success(fmt.Sprintf("Version bumped to: %s", newVersion)) // Step 4: Package stackpack from temporary directory cli.Printer.PrintLn("") @@ -188,7 +188,7 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { if err := runPackageStep(cli, packageArgs); err != nil { return err } - cli.Printer.Success("✓ Stackpack packaged successfully") + cli.Printer.Success("Stackpack packaged successfully") // Step 5: Confirm upload (if needed) and execute upload/install workflow if !args.Yes { @@ -208,7 +208,7 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { if err := runUploadStep(cli, api, serverInfo, uploadArgs); err != nil { return err } - cli.Printer.Success("✓ Stackpack uploaded successfully") + cli.Printer.Success("Stackpack uploaded successfully") // Install or upgrade stackpack based on installation status if installedVersion != "" { @@ -222,7 +222,7 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { if err := runUpgradeStep(cli, api, serverInfo, upgradeArgs); err != nil { return err } - cli.Printer.Success("✓ Stackpack upgraded successfully") + cli.Printer.Success("Stackpack upgraded successfully") } else { installArgs := &InstallArgs{ Name: originalInfo.Name, @@ -235,7 +235,7 @@ func RunStackpackTestCommand(args *TestArgs) di.CmdWithApiFn { if err := runInstallStep(cli, api, serverInfo, installArgs); err != nil { return err } - cli.Printer.Success("✓ Stackpack installed successfully") + cli.Printer.Success("Stackpack installed successfully") } cli.Printer.PrintLn("")