From 50f6839c517f20619b96c33cfae78af2c479de30 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 13:19:08 +0000 Subject: [PATCH] test: increase coverage across packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pkg/container: 65.6% → 85.7% (hypervisor, linuxkit, templates tests) - pkg/release/publishers: 13.3% → 41.7% (homebrew, aur, npm, scoop, chocolatey tests) - Fix flaky test cleanup in TestLinuxKitManager_Stop_Good_ContextCancelled Overall coverage: 29.2% → 40.6% Co-Authored-By: Claude Opus 4.5 --- pkg/container/hypervisor_test.go | 358 +++++++++++++++++++++ pkg/container/linuxkit_test.go | 370 ++++++++++++++++++++-- pkg/container/templates_test.go | 198 ++++++++++++ pkg/release/publishers/aur_test.go | 223 +++++++++++++ pkg/release/publishers/chocolatey_test.go | 320 +++++++++++++++++++ pkg/release/publishers/homebrew_test.go | 344 ++++++++++++++++++++ pkg/release/publishers/npm_test.go | 298 +++++++++++++++++ pkg/release/publishers/scoop_test.go | 307 ++++++++++++++++++ 8 files changed, 2386 insertions(+), 32 deletions(-) create mode 100644 pkg/container/hypervisor_test.go create mode 100644 pkg/release/publishers/aur_test.go create mode 100644 pkg/release/publishers/chocolatey_test.go create mode 100644 pkg/release/publishers/homebrew_test.go create mode 100644 pkg/release/publishers/npm_test.go create mode 100644 pkg/release/publishers/scoop_test.go diff --git a/pkg/container/hypervisor_test.go b/pkg/container/hypervisor_test.go new file mode 100644 index 00000000..e5c99644 --- /dev/null +++ b/pkg/container/hypervisor_test.go @@ -0,0 +1,358 @@ +package container + +import ( + "context" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQemuHypervisor_Available_Good(t *testing.T) { + q := NewQemuHypervisor() + + // Check if qemu is available on this system + available := q.Available() + + // We just verify it returns a boolean without error + // The actual availability depends on the system + assert.IsType(t, true, available) +} + +func TestQemuHypervisor_Available_Bad_InvalidBinary(t *testing.T) { + q := &QemuHypervisor{ + Binary: "nonexistent-qemu-binary-that-does-not-exist", + } + + available := q.Available() + + assert.False(t, available) +} + +func TestHyperkitHypervisor_Available_Good(t *testing.T) { + h := NewHyperkitHypervisor() + + available := h.Available() + + // On non-darwin systems, should always be false + if runtime.GOOS != "darwin" { + assert.False(t, available) + } else { + // On darwin, just verify it returns a boolean + assert.IsType(t, true, available) + } +} + +func TestHyperkitHypervisor_Available_Bad_NotDarwin(t *testing.T) { + if runtime.GOOS == "darwin" { + t.Skip("This test only runs on non-darwin systems") + } + + h := NewHyperkitHypervisor() + + available := h.Available() + + assert.False(t, available, "Hyperkit should not be available on non-darwin systems") +} + +func TestHyperkitHypervisor_Available_Bad_InvalidBinary(t *testing.T) { + h := &HyperkitHypervisor{ + Binary: "nonexistent-hyperkit-binary-that-does-not-exist", + } + + available := h.Available() + + assert.False(t, available) +} + +func TestIsKVMAvailable_Good(t *testing.T) { + // This test verifies the function runs without error + // The actual result depends on the system + result := isKVMAvailable() + + // On non-linux systems, should be false + if runtime.GOOS != "linux" { + assert.False(t, result, "KVM should not be available on non-linux systems") + } else { + // On linux, just verify it returns a boolean + assert.IsType(t, true, result) + } +} + +func TestDetectHypervisor_Good(t *testing.T) { + // DetectHypervisor tries to find an available hypervisor + hv, err := DetectHypervisor() + + // This test may pass or fail depending on system configuration + // If no hypervisor is available, it should return an error + if err != nil { + assert.Nil(t, hv) + assert.Contains(t, err.Error(), "no hypervisor available") + } else { + assert.NotNil(t, hv) + assert.NotEmpty(t, hv.Name()) + } +} + +func TestGetHypervisor_Good_Qemu(t *testing.T) { + hv, err := GetHypervisor("qemu") + + // Depends on whether qemu is installed + if err != nil { + assert.Contains(t, err.Error(), "not available") + } else { + assert.NotNil(t, hv) + assert.Equal(t, "qemu", hv.Name()) + } +} + +func TestGetHypervisor_Good_QemuUppercase(t *testing.T) { + hv, err := GetHypervisor("QEMU") + + // Depends on whether qemu is installed + if err != nil { + assert.Contains(t, err.Error(), "not available") + } else { + assert.NotNil(t, hv) + assert.Equal(t, "qemu", hv.Name()) + } +} + +func TestGetHypervisor_Good_Hyperkit(t *testing.T) { + hv, err := GetHypervisor("hyperkit") + + // On non-darwin systems, should always fail + if runtime.GOOS != "darwin" { + assert.Error(t, err) + assert.Contains(t, err.Error(), "not available") + } else { + // On darwin, depends on whether hyperkit is installed + if err != nil { + assert.Contains(t, err.Error(), "not available") + } else { + assert.NotNil(t, hv) + assert.Equal(t, "hyperkit", hv.Name()) + } + } +} + +func TestGetHypervisor_Bad_Unknown(t *testing.T) { + _, err := GetHypervisor("unknown-hypervisor") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown hypervisor") +} + +func TestQemuHypervisor_BuildCommand_Good_WithPortsAndVolumes(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 2048, + CPUs: 4, + SSHPort: 2222, + Ports: map[int]int{8080: 80, 443: 443}, + Volumes: map[string]string{ + "/host/data": "/container/data", + "/host/logs": "/container/logs", + }, + Detach: true, + } + + cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) + + // Verify command includes all expected args + args := cmd.Args + assert.Contains(t, args, "-m") + assert.Contains(t, args, "2048") + assert.Contains(t, args, "-smp") + assert.Contains(t, args, "4") +} + +func TestQemuHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := q.BuildCommand(ctx, "/path/to/image.qcow2", opts) + require.NoError(t, err) + + // Check that the drive format is qcow2 + found := false + for _, arg := range cmd.Args { + if arg == "file=/path/to/image.qcow2,format=qcow2" { + found = true + break + } + } + assert.True(t, found, "Should have qcow2 drive argument") +} + +func TestQemuHypervisor_BuildCommand_Good_VMDKFormat(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := q.BuildCommand(ctx, "/path/to/image.vmdk", opts) + require.NoError(t, err) + + // Check that the drive format is vmdk + found := false + for _, arg := range cmd.Args { + if arg == "file=/path/to/image.vmdk,format=vmdk" { + found = true + break + } + } + assert.True(t, found, "Should have vmdk drive argument") +} + +func TestQemuHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := q.BuildCommand(ctx, "/path/to/image.raw", opts) + require.NoError(t, err) + + // Check that the drive format is raw + found := false + for _, arg := range cmd.Args { + if arg == "file=/path/to/image.raw,format=raw" { + found = true + break + } + } + assert.True(t, found, "Should have raw drive argument") +} + +func TestHyperkitHypervisor_BuildCommand_Good_WithPorts(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 1024, + CPUs: 2, + SSHPort: 2222, + Ports: map[int]int{8080: 80}, + } + + cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) + + // Verify it creates a command with memory and CPU args + args := cmd.Args + assert.Contains(t, args, "-m") + assert.Contains(t, args, "1024M") + assert.Contains(t, args, "-c") + assert.Contains(t, args, "2") +} + +func TestHyperkitHypervisor_BuildCommand_Good_QCow2Format(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := h.BuildCommand(ctx, "/path/to/image.qcow2", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) +} + +func TestHyperkitHypervisor_BuildCommand_Good_RawFormat(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + cmd, err := h.BuildCommand(ctx, "/path/to/image.raw", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) +} + +func TestHyperkitHypervisor_BuildCommand_Good_NoPorts(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 512, + CPUs: 1, + SSHPort: 0, // No SSH port + Ports: nil, + } + + cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) +} + +func TestQemuHypervisor_BuildCommand_Good_NoSSHPort(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 512, + CPUs: 1, + SSHPort: 0, // No SSH port + Ports: nil, + } + + cmd, err := q.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) +} + +func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { + q := NewQemuHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + _, err := q.BuildCommand(ctx, "/path/to/image.txt", opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown image format") +} + +func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + + _, err := h.BuildCommand(ctx, "/path/to/image.unknown", opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown image format") +} + +func TestHyperkitHypervisor_Name_Good(t *testing.T) { + h := NewHyperkitHypervisor() + assert.Equal(t, "hyperkit", h.Name()) +} + +func TestHyperkitHypervisor_BuildCommand_Good_ISOFormat(t *testing.T) { + h := NewHyperkitHypervisor() + + ctx := context.Background() + opts := &HypervisorOptions{ + Memory: 1024, + CPUs: 2, + SSHPort: 2222, + } + + cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) + require.NoError(t, err) + assert.NotNil(t, cmd) + + args := cmd.Args + assert.Contains(t, args, "-m") + assert.Contains(t, args, "1024M") + assert.Contains(t, args, "-c") + assert.Contains(t, args, "2") +} diff --git a/pkg/container/linuxkit_test.go b/pkg/container/linuxkit_test.go index 5dc2cd6a..cee98951 100644 --- a/pkg/container/linuxkit_test.go +++ b/pkg/container/linuxkit_test.go @@ -410,56 +410,362 @@ func TestQemuHypervisor_BuildCommand_Good(t *testing.T) { assert.Contains(t, args, "-nographic") } -func TestQemuHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { - q := NewQemuHypervisor() + +func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) { + manager, _, _ := newTestManager(t) + + // Create a unique container ID + uniqueID, _ := GenerateID() + container := &Container{ID: uniqueID} + manager.State().Add(container) + + // Create a log file at the expected location + logPath, err := LogPath(uniqueID) + require.NoError(t, err) + os.MkdirAll(filepath.Dir(logPath), 0755) + + // Write initial content + err = os.WriteFile(logPath, []byte("initial log content\n"), 0644) + require.NoError(t, err) + + // Create a cancellable context + ctx, cancel := context.WithCancel(context.Background()) + + // Get the follow reader + reader, err := manager.Logs(ctx, uniqueID, true) + require.NoError(t, err) + + // Cancel the context to stop the follow + cancel() + + // Read should return EOF after context cancellation + buf := make([]byte, 1024) + _, readErr := reader.Read(buf) + // After context cancel, Read should return EOF + assert.Equal(t, "EOF", readErr.Error()) + + // Close the reader + err = reader.Close() + assert.NoError(t, err) +} + +func TestFollowReader_Read_Good_WithData(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + // Create log file with content + content := "test log line 1\ntest log line 2\n" + err := os.WriteFile(logPath, []byte(content), 0644) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + reader, err := newFollowReader(ctx, logPath) + require.NoError(t, err) + defer reader.Close() + + // The followReader seeks to end, so we need to append more content + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0644) + require.NoError(t, err) + _, err = f.WriteString("new line\n") + require.NoError(t, err) + f.Close() + + // Give the reader time to poll + time.Sleep(150 * time.Millisecond) + + buf := make([]byte, 1024) + n, err := reader.Read(buf) + if err == nil { + assert.Greater(t, n, 0) + } +} + +func TestFollowReader_Read_Good_ContextCancel(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + // Create log file + err := os.WriteFile(logPath, []byte("initial content\n"), 0644) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + + reader, err := newFollowReader(ctx, logPath) + require.NoError(t, err) + + // Cancel the context + cancel() + + // Read should return EOF + buf := make([]byte, 1024) + _, readErr := reader.Read(buf) + assert.Equal(t, "EOF", readErr.Error()) + + reader.Close() +} + +func TestFollowReader_Close_Good(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + err := os.WriteFile(logPath, []byte("content\n"), 0644) + require.NoError(t, err) ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + reader, err := newFollowReader(ctx, logPath) + require.NoError(t, err) + + err = reader.Close() + assert.NoError(t, err) + + // Reading after close should fail or return EOF + buf := make([]byte, 1024) + _, readErr := reader.Read(buf) + assert.Error(t, readErr) +} + +func TestNewFollowReader_Bad_FileNotFound(t *testing.T) { + ctx := context.Background() + _, err := newFollowReader(ctx, "/nonexistent/path/to/file.log") - _, err := q.BuildCommand(ctx, "/path/to/image.txt", opts) assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown image format") } -func TestHyperkitHypervisor_Name_Good(t *testing.T) { - h := NewHyperkitHypervisor() - assert.Equal(t, "hyperkit", h.Name()) -} +func TestLinuxKitManager_Run_Bad_BuildCommandError(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) -func TestHyperkitHypervisor_BuildCommand_Good(t *testing.T) { - h := NewHyperkitHypervisor() + // Create a test image file + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Configure mock to return an error + mock.buildErr = assert.AnError ctx := context.Background() - opts := &HypervisorOptions{ - Memory: 1024, - CPUs: 2, - SSHPort: 2222, + opts := RunOptions{Detach: true} + + _, err = manager.Run(ctx, imagePath, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to build hypervisor command") +} + +func TestLinuxKitManager_Run_Good_Foreground(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + // Create a test image file + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use echo which exits quickly + mock.commandToRun = "echo" + + ctx := context.Background() + opts := RunOptions{ + Name: "test-foreground", + Detach: false, // Run in foreground + Memory: 512, + CPUs: 1, } - cmd, err := h.BuildCommand(ctx, "/path/to/image.iso", opts) + container, err := manager.Run(ctx, imagePath, opts) require.NoError(t, err) - assert.NotNil(t, cmd) - args := cmd.Args - assert.Contains(t, args, "-m") - assert.Contains(t, args, "1024M") - assert.Contains(t, args, "-c") - assert.Contains(t, args, "2") + assert.NotEmpty(t, container.ID) + assert.Equal(t, "test-foreground", container.Name) + // Foreground process should have completed + assert.Equal(t, StatusStopped, container.Status) } -func TestHyperkitHypervisor_BuildCommand_Bad_UnknownFormat(t *testing.T) { - h := NewHyperkitHypervisor() +func TestLinuxKitManager_Stop_Good_ContextCancelled(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + // Create a test image file + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that takes a long time + mock.commandToRun = "sleep" + + // Start a container + ctx := context.Background() + opts := RunOptions{ + Name: "test-cancel", + Detach: true, + } + + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) + + // Ensure cleanup happens regardless of test outcome + t.Cleanup(func() { + _ = manager.Stop(context.Background(), container.ID) + }) + + // Create a context that's already cancelled + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + + // Stop with cancelled context + err = manager.Stop(cancelCtx, container.ID) + // Should return context error + assert.Error(t, err) + assert.Equal(t, context.Canceled, err) +} + +func TestIsProcessRunning_Good_ExistingProcess(t *testing.T) { + // Use our own PID which definitely exists + running := isProcessRunning(os.Getpid()) + assert.True(t, running) +} + +func TestIsProcessRunning_Bad_NonexistentProcess(t *testing.T) { + // Use a PID that almost certainly doesn't exist + running := isProcessRunning(999999) + assert.False(t, running) +} + +func TestLinuxKitManager_Run_Good_WithPortsAndVolumes(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) ctx := context.Background() - opts := &HypervisorOptions{Memory: 1024, CPUs: 1} + opts := RunOptions{ + Name: "test-ports", + Detach: true, + Memory: 512, + CPUs: 1, + SSHPort: 2223, + Ports: map[int]int{8080: 80, 443: 443}, + Volumes: map[string]string{"/host/data": "/container/data"}, + } - _, err := h.BuildCommand(ctx, "/path/to/image.unknown", opts) - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown image format") + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) + + assert.NotEmpty(t, container.ID) + assert.Equal(t, map[int]int{8080: 80, 443: 443}, container.Ports) + assert.Equal(t, 2223, mock.lastOpts.SSHPort) + assert.Equal(t, map[string]string{"/host/data": "/container/data"}, mock.lastOpts.Volumes) + + time.Sleep(50 * time.Millisecond) } -func TestGetHypervisor_Bad_Unknown(t *testing.T) { - _, err := GetHypervisor("unknown-hypervisor") - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown hypervisor") +func TestFollowReader_Read_Good_ReaderError(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "test.log") + + // Create log file + err := os.WriteFile(logPath, []byte("content\n"), 0644) + require.NoError(t, err) + + ctx := context.Background() + reader, err := newFollowReader(ctx, logPath) + require.NoError(t, err) + + // Close the underlying file to cause read errors + reader.file.Close() + + // Read should return an error + buf := make([]byte, 1024) + _, readErr := reader.Read(buf) + assert.Error(t, readErr) +} + +func TestLinuxKitManager_Run_Bad_StartError(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that doesn't exist to cause Start() to fail + mock.commandToRun = "/nonexistent/command/that/does/not/exist" + + ctx := context.Background() + opts := RunOptions{ + Name: "test-start-error", + Detach: true, + } + + _, err = manager.Run(ctx, imagePath, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to start VM") +} + +func TestLinuxKitManager_Run_Bad_ForegroundStartError(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that doesn't exist to cause Start() to fail + mock.commandToRun = "/nonexistent/command/that/does/not/exist" + + ctx := context.Background() + opts := RunOptions{ + Name: "test-foreground-error", + Detach: false, + } + + _, err = manager.Run(ctx, imagePath, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to start VM") +} + +func TestLinuxKitManager_Run_Good_ForegroundWithError(t *testing.T) { + manager, mock, tmpDir := newTestManager(t) + + imagePath := filepath.Join(tmpDir, "test.iso") + err := os.WriteFile(imagePath, []byte("fake image"), 0644) + require.NoError(t, err) + + // Use a command that exits with error + mock.commandToRun = "false" // false command exits with code 1 + + ctx := context.Background() + opts := RunOptions{ + Name: "test-foreground-exit-error", + Detach: false, + } + + container, err := manager.Run(ctx, imagePath, opts) + require.NoError(t, err) // Run itself should succeed + + // Container should be in error state since process exited with error + assert.Equal(t, StatusError, container.Status) +} + +func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) { + manager, _, _ := newTestManager(t) + + // Add a "running" container with a process that has already exited + // This simulates the race condition where process exits between status check + // and signal send + container := &Container{ + ID: "test1234", + Status: StatusRunning, + PID: 999999, // Non-existent PID + StartedAt: time.Now(), + } + manager.State().Add(container) + + ctx := context.Background() + err := manager.Stop(ctx, "test1234") + + // Stop should succeed gracefully + assert.NoError(t, err) + + // Container should be stopped + c, ok := manager.State().Get("test1234") + assert.True(t, ok) + assert.Equal(t, StatusStopped, c.Status) } diff --git a/pkg/container/templates_test.go b/pkg/container/templates_test.go index 614cbc16..5825863d 100644 --- a/pkg/container/templates_test.go +++ b/pkg/container/templates_test.go @@ -383,3 +383,201 @@ func TestVariablePatternEdgeCases_Good(t *testing.T) { }) } } + +func TestListTemplates_Good_WithUserTemplates(t *testing.T) { + // Create a workspace directory with user templates + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core", "linuxkit") + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + // Create a user template + templateContent := `# Custom user template +kernel: + image: linuxkit/kernel:6.6 +` + err = os.WriteFile(filepath.Join(coreDir, "user-custom.yml"), []byte(templateContent), 0644) + require.NoError(t, err) + + // Change to the temp directory + oldWd, err := os.Getwd() + require.NoError(t, err) + err = os.Chdir(tmpDir) + require.NoError(t, err) + defer os.Chdir(oldWd) + + templates := ListTemplates() + + // Should have at least the builtin templates plus the user template + assert.GreaterOrEqual(t, len(templates), 3) + + // Check that user template is included + found := false + for _, tmpl := range templates { + if tmpl.Name == "user-custom" { + found = true + assert.Equal(t, "Custom user template", tmpl.Description) + break + } + } + assert.True(t, found, "user-custom template should exist") +} + +func TestGetTemplate_Good_UserTemplate(t *testing.T) { + // Create a workspace directory with user templates + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core", "linuxkit") + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + // Create a user template + templateContent := `# My user template +kernel: + image: linuxkit/kernel:6.6 +services: + - name: test +` + err = os.WriteFile(filepath.Join(coreDir, "my-user-template.yml"), []byte(templateContent), 0644) + require.NoError(t, err) + + // Change to the temp directory + oldWd, err := os.Getwd() + require.NoError(t, err) + err = os.Chdir(tmpDir) + require.NoError(t, err) + defer os.Chdir(oldWd) + + content, err := GetTemplate("my-user-template") + + require.NoError(t, err) + assert.Contains(t, content, "kernel:") + assert.Contains(t, content, "My user template") +} + +func TestScanUserTemplates_Good_SkipsBuiltinNames(t *testing.T) { + tmpDir := t.TempDir() + + // Create a template with a builtin name (should be skipped) + err := os.WriteFile(filepath.Join(tmpDir, "core-dev.yml"), []byte("# Duplicate\nkernel:"), 0644) + require.NoError(t, err) + + // Create a unique template + err = os.WriteFile(filepath.Join(tmpDir, "unique.yml"), []byte("# Unique\nkernel:"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + // Should only have the unique template, not the builtin name + assert.Len(t, templates, 1) + assert.Equal(t, "unique", templates[0].Name) +} + +func TestScanUserTemplates_Good_SkipsDirectories(t *testing.T) { + tmpDir := t.TempDir() + + // Create a subdirectory (should be skipped) + err := os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0755) + require.NoError(t, err) + + // Create a valid template + err = os.WriteFile(filepath.Join(tmpDir, "valid.yml"), []byte("# Valid\nkernel:"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 1) + assert.Equal(t, "valid", templates[0].Name) +} + +func TestScanUserTemplates_Good_YamlExtension(t *testing.T) { + tmpDir := t.TempDir() + + // Create templates with both extensions + err := os.WriteFile(filepath.Join(tmpDir, "template1.yml"), []byte("# Template 1\nkernel:"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "template2.yaml"), []byte("# Template 2\nkernel:"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 2) + + names := make(map[string]bool) + for _, tmpl := range templates { + names[tmpl.Name] = true + } + assert.True(t, names["template1"]) + assert.True(t, names["template2"]) +} + +func TestExtractTemplateDescription_Good_EmptyComment(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.yml") + + // First comment is empty, second has content + content := `# +# Actual description here +kernel: + image: test +` + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + desc := extractTemplateDescription(path) + + assert.Equal(t, "Actual description here", desc) +} + +func TestExtractTemplateDescription_Good_MultipleEmptyComments(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.yml") + + // Multiple empty comments before actual content + content := `# +# +# +# Real description +kernel: + image: test +` + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + desc := extractTemplateDescription(path) + + assert.Equal(t, "Real description", desc) +} + +func TestGetUserTemplatesDir_Good_NoDirectory(t *testing.T) { + // Save current working directory + oldWd, err := os.Getwd() + require.NoError(t, err) + + // Create a temp directory without .core/linuxkit + tmpDir := t.TempDir() + err = os.Chdir(tmpDir) + require.NoError(t, err) + defer os.Chdir(oldWd) + + dir := getUserTemplatesDir() + + // Should return empty string since no templates dir exists + // (unless home dir has one) + assert.True(t, dir == "" || strings.Contains(dir, "linuxkit")) +} + +func TestScanUserTemplates_Good_DefaultDescription(t *testing.T) { + tmpDir := t.TempDir() + + // Create a template without comments + content := `kernel: + image: test +` + err := os.WriteFile(filepath.Join(tmpDir, "nocomment.yml"), []byte(content), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 1) + assert.Equal(t, "User-defined template", templates[0].Description) +} diff --git a/pkg/release/publishers/aur_test.go b/pkg/release/publishers/aur_test.go new file mode 100644 index 00000000..cf0b3290 --- /dev/null +++ b/pkg/release/publishers/aur_test.go @@ -0,0 +1,223 @@ +package publishers + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAURPublisher_Name_Good(t *testing.T) { + t.Run("returns aur", func(t *testing.T) { + p := NewAURPublisher() + assert.Equal(t, "aur", p.Name()) + }) +} + +func TestAURPublisher_ParseConfig_Good(t *testing.T) { + p := NewAURPublisher() + + t.Run("uses defaults when no extended config", func(t *testing.T) { + pubCfg := PublisherConfig{Type: "aur"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.Empty(t, cfg.Maintainer) + assert.Nil(t, cfg.Official) + }) + + t.Run("parses package and maintainer from extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "aur", + Extended: map[string]any{ + "package": "mypackage", + "maintainer": "John Doe ", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Equal(t, "mypackage", cfg.Package) + assert.Equal(t, "John Doe ", cfg.Maintainer) + }) + + t.Run("parses official config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "aur", + Extended: map[string]any{ + "official": map[string]any{ + "enabled": true, + "output": "dist/aur-files", + }, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + require.NotNil(t, cfg.Official) + assert.True(t, cfg.Official.Enabled) + assert.Equal(t, "dist/aur-files", cfg.Official.Output) + }) + + t.Run("handles missing official fields", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "aur", + Extended: map[string]any{ + "official": map[string]any{}, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + require.NotNil(t, cfg.Official) + assert.False(t, cfg.Official.Enabled) + assert.Empty(t, cfg.Official.Output) + }) +} + +func TestAURPublisher_RenderTemplate_Good(t *testing.T) { + p := NewAURPublisher() + + t.Run("renders PKGBUILD template with data", func(t *testing.T) { + data := aurTemplateData{ + PackageName: "myapp", + Description: "My awesome CLI", + Repository: "owner/myapp", + Version: "1.2.3", + License: "MIT", + BinaryName: "myapp", + Maintainer: "John Doe ", + Checksums: ChecksumMap{ + LinuxAmd64: "abc123", + LinuxArm64: "def456", + }, + } + + result, err := p.renderTemplate("templates/aur/PKGBUILD.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, "# Maintainer: John Doe ") + assert.Contains(t, result, "pkgname=myapp-bin") + assert.Contains(t, result, "pkgver=1.2.3") + assert.Contains(t, result, `pkgdesc="My awesome CLI"`) + assert.Contains(t, result, "url=\"https://github.com/owner/myapp\"") + assert.Contains(t, result, "license=('MIT')") + assert.Contains(t, result, "sha256sums_x86_64=('abc123')") + assert.Contains(t, result, "sha256sums_aarch64=('def456')") + }) + + t.Run("renders .SRCINFO template with data", func(t *testing.T) { + data := aurTemplateData{ + PackageName: "myapp", + Description: "My CLI", + Repository: "owner/myapp", + Version: "1.0.0", + License: "MIT", + BinaryName: "myapp", + Maintainer: "Test ", + Checksums: ChecksumMap{ + LinuxAmd64: "checksum1", + LinuxArm64: "checksum2", + }, + } + + result, err := p.renderTemplate("templates/aur/.SRCINFO.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, "pkgbase = myapp-bin") + assert.Contains(t, result, "pkgdesc = My CLI") + assert.Contains(t, result, "pkgver = 1.0.0") + assert.Contains(t, result, "arch = x86_64") + assert.Contains(t, result, "arch = aarch64") + assert.Contains(t, result, "sha256sums_x86_64 = checksum1") + assert.Contains(t, result, "sha256sums_aarch64 = checksum2") + assert.Contains(t, result, "pkgname = myapp-bin") + }) +} + +func TestAURPublisher_RenderTemplate_Bad(t *testing.T) { + p := NewAURPublisher() + + t.Run("returns error for non-existent template", func(t *testing.T) { + data := aurTemplateData{} + _, err := p.renderTemplate("templates/aur/nonexistent.tmpl", data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read template") + }) +} + +func TestAURPublisher_DryRunPublish_Good(t *testing.T) { + p := NewAURPublisher() + + t.Run("outputs expected dry run information", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := aurTemplateData{ + PackageName: "myapp", + Version: "1.0.0", + Maintainer: "John Doe ", + Repository: "owner/repo", + BinaryName: "myapp", + Checksums: ChecksumMap{}, + } + cfg := AURConfig{ + Maintainer: "John Doe ", + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + assert.Contains(t, output, "DRY RUN: AUR Publish") + assert.Contains(t, output, "Package: myapp-bin") + assert.Contains(t, output, "Version: 1.0.0") + assert.Contains(t, output, "Maintainer: John Doe ") + assert.Contains(t, output, "Repository: owner/repo") + assert.Contains(t, output, "Generated PKGBUILD:") + assert.Contains(t, output, "Generated .SRCINFO:") + assert.Contains(t, output, "Would push to AUR: ssh://aur@aur.archlinux.org/myapp-bin.git") + assert.Contains(t, output, "END DRY RUN") + }) +} + +func TestAURPublisher_Publish_Bad(t *testing.T) { + p := NewAURPublisher() + + t.Run("fails when maintainer not configured", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + ProjectDir: "/project", + } + pubCfg := PublisherConfig{Type: "aur"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + err := p.Publish(nil, release, pubCfg, relCfg, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "maintainer is required") + }) +} + +func TestAURConfig_Defaults_Good(t *testing.T) { + t.Run("has sensible defaults", func(t *testing.T) { + p := NewAURPublisher() + pubCfg := PublisherConfig{Type: "aur"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.Empty(t, cfg.Maintainer) + assert.Nil(t, cfg.Official) + }) +} diff --git a/pkg/release/publishers/chocolatey_test.go b/pkg/release/publishers/chocolatey_test.go new file mode 100644 index 00000000..fe5ea63d --- /dev/null +++ b/pkg/release/publishers/chocolatey_test.go @@ -0,0 +1,320 @@ +package publishers + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChocolateyPublisher_Name_Good(t *testing.T) { + t.Run("returns chocolatey", func(t *testing.T) { + p := NewChocolateyPublisher() + assert.Equal(t, "chocolatey", p.Name()) + }) +} + +func TestChocolateyPublisher_ParseConfig_Good(t *testing.T) { + p := NewChocolateyPublisher() + + t.Run("uses defaults when no extended config", func(t *testing.T) { + pubCfg := PublisherConfig{Type: "chocolatey"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.False(t, cfg.Push) + assert.Nil(t, cfg.Official) + }) + + t.Run("parses package and push from extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "chocolatey", + Extended: map[string]any{ + "package": "mypackage", + "push": true, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Equal(t, "mypackage", cfg.Package) + assert.True(t, cfg.Push) + }) + + t.Run("parses official config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "chocolatey", + Extended: map[string]any{ + "official": map[string]any{ + "enabled": true, + "output": "dist/choco", + }, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + require.NotNil(t, cfg.Official) + assert.True(t, cfg.Official.Enabled) + assert.Equal(t, "dist/choco", cfg.Official.Output) + }) + + t.Run("handles missing official fields", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "chocolatey", + Extended: map[string]any{ + "official": map[string]any{}, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + require.NotNil(t, cfg.Official) + assert.False(t, cfg.Official.Enabled) + assert.Empty(t, cfg.Official.Output) + }) + + t.Run("handles nil extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "chocolatey", + Extended: nil, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.False(t, cfg.Push) + assert.Nil(t, cfg.Official) + }) + + t.Run("defaults push to false when not specified", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "chocolatey", + Extended: map[string]any{ + "package": "mypackage", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.False(t, cfg.Push) + }) +} + +func TestChocolateyPublisher_RenderTemplate_Good(t *testing.T) { + p := NewChocolateyPublisher() + + t.Run("renders nuspec template with data", func(t *testing.T) { + data := chocolateyTemplateData{ + PackageName: "myapp", + Title: "MyApp CLI", + Description: "My awesome CLI", + Repository: "owner/myapp", + Version: "1.2.3", + License: "MIT", + BinaryName: "myapp", + Authors: "owner", + Tags: "cli myapp", + Checksums: ChecksumMap{}, + } + + result, err := p.renderTemplate("templates/chocolatey/package.nuspec.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, `myapp`) + assert.Contains(t, result, `1.2.3`) + assert.Contains(t, result, `MyApp CLI`) + assert.Contains(t, result, `owner`) + assert.Contains(t, result, `My awesome CLI`) + assert.Contains(t, result, `cli myapp`) + assert.Contains(t, result, "projectUrl>https://github.com/owner/myapp") + assert.Contains(t, result, "releaseNotes>https://github.com/owner/myapp/releases/tag/v1.2.3") + }) + + t.Run("renders install script template with data", func(t *testing.T) { + data := chocolateyTemplateData{ + PackageName: "myapp", + Repository: "owner/myapp", + Version: "1.2.3", + BinaryName: "myapp", + Checksums: ChecksumMap{ + WindowsAmd64: "abc123def456", + }, + } + + result, err := p.renderTemplate("templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, "$ErrorActionPreference = 'Stop'") + assert.Contains(t, result, "https://github.com/owner/myapp/releases/download/v1.2.3/myapp-windows-amd64.zip") + assert.Contains(t, result, "packageName = 'myapp'") + assert.Contains(t, result, "checksum64 = 'abc123def456'") + assert.Contains(t, result, "checksumType64 = 'sha256'") + assert.Contains(t, result, "Install-ChocolateyZipPackage") + }) +} + +func TestChocolateyPublisher_RenderTemplate_Bad(t *testing.T) { + p := NewChocolateyPublisher() + + t.Run("returns error for non-existent template", func(t *testing.T) { + data := chocolateyTemplateData{} + _, err := p.renderTemplate("templates/chocolatey/nonexistent.tmpl", data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read template") + }) +} + +func TestChocolateyPublisher_DryRunPublish_Good(t *testing.T) { + p := NewChocolateyPublisher() + + t.Run("outputs expected dry run information", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := chocolateyTemplateData{ + PackageName: "myapp", + Version: "1.0.0", + Repository: "owner/repo", + BinaryName: "myapp", + Authors: "owner", + Tags: "cli myapp", + Checksums: ChecksumMap{}, + } + cfg := ChocolateyConfig{ + Push: false, + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + assert.Contains(t, output, "DRY RUN: Chocolatey Publish") + assert.Contains(t, output, "Package: myapp") + assert.Contains(t, output, "Version: 1.0.0") + assert.Contains(t, output, "Push: false") + assert.Contains(t, output, "Repository: owner/repo") + assert.Contains(t, output, "Generated package.nuspec:") + assert.Contains(t, output, "Generated chocolateyinstall.ps1:") + assert.Contains(t, output, "Would generate package files only (push=false)") + assert.Contains(t, output, "END DRY RUN") + }) + + t.Run("shows push message when push is enabled", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := chocolateyTemplateData{ + PackageName: "myapp", + Version: "1.0.0", + BinaryName: "myapp", + Authors: "owner", + Tags: "cli", + Checksums: ChecksumMap{}, + } + cfg := ChocolateyConfig{ + Push: true, + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "Push: true") + assert.Contains(t, output, "Would push to Chocolatey community repo") + }) +} + +func TestChocolateyPublisher_ExecutePublish_Bad(t *testing.T) { + p := NewChocolateyPublisher() + + t.Run("fails when CHOCOLATEY_API_KEY not set for push", func(t *testing.T) { + // Ensure CHOCOLATEY_API_KEY is not set + oldKey := os.Getenv("CHOCOLATEY_API_KEY") + os.Unsetenv("CHOCOLATEY_API_KEY") + defer func() { + if oldKey != "" { + os.Setenv("CHOCOLATEY_API_KEY", oldKey) + } + }() + + // Create a temp directory for the test + tmpDir, err := os.MkdirTemp("", "choco-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + data := chocolateyTemplateData{ + PackageName: "testpkg", + Version: "1.0.0", + BinaryName: "testpkg", + Repository: "owner/repo", + Authors: "owner", + Tags: "cli", + Checksums: ChecksumMap{}, + } + + err = p.pushToChocolatey(nil, tmpDir, data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "CHOCOLATEY_API_KEY environment variable is required") + }) +} + +func TestChocolateyConfig_Defaults_Good(t *testing.T) { + t.Run("has sensible defaults", func(t *testing.T) { + p := NewChocolateyPublisher() + pubCfg := PublisherConfig{Type: "chocolatey"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.False(t, cfg.Push) + assert.Nil(t, cfg.Official) + }) +} + +func TestChocolateyTemplateData_Good(t *testing.T) { + t.Run("struct has all expected fields", func(t *testing.T) { + data := chocolateyTemplateData{ + PackageName: "myapp", + Title: "MyApp CLI", + Description: "description", + Repository: "org/repo", + Version: "1.0.0", + License: "MIT", + BinaryName: "myapp", + Authors: "org", + Tags: "cli tool", + Checksums: ChecksumMap{ + WindowsAmd64: "hash1", + }, + } + + assert.Equal(t, "myapp", data.PackageName) + assert.Equal(t, "MyApp CLI", data.Title) + assert.Equal(t, "description", data.Description) + assert.Equal(t, "org/repo", data.Repository) + assert.Equal(t, "1.0.0", data.Version) + assert.Equal(t, "MIT", data.License) + assert.Equal(t, "myapp", data.BinaryName) + assert.Equal(t, "org", data.Authors) + assert.Equal(t, "cli tool", data.Tags) + assert.Equal(t, "hash1", data.Checksums.WindowsAmd64) + }) +} diff --git a/pkg/release/publishers/homebrew_test.go b/pkg/release/publishers/homebrew_test.go new file mode 100644 index 00000000..e77011e3 --- /dev/null +++ b/pkg/release/publishers/homebrew_test.go @@ -0,0 +1,344 @@ +package publishers + +import ( + "bytes" + "os" + "testing" + + "github.com/host-uk/core/pkg/build" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHomebrewPublisher_Name_Good(t *testing.T) { + t.Run("returns homebrew", func(t *testing.T) { + p := NewHomebrewPublisher() + assert.Equal(t, "homebrew", p.Name()) + }) +} + +func TestHomebrewPublisher_ParseConfig_Good(t *testing.T) { + p := NewHomebrewPublisher() + + t.Run("uses defaults when no extended config", func(t *testing.T) { + pubCfg := PublisherConfig{Type: "homebrew"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Tap) + assert.Empty(t, cfg.Formula) + assert.Nil(t, cfg.Official) + }) + + t.Run("parses tap and formula from extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "homebrew", + Extended: map[string]any{ + "tap": "host-uk/homebrew-tap", + "formula": "myformula", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Equal(t, "host-uk/homebrew-tap", cfg.Tap) + assert.Equal(t, "myformula", cfg.Formula) + }) + + t.Run("parses official config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "homebrew", + Extended: map[string]any{ + "official": map[string]any{ + "enabled": true, + "output": "dist/brew", + }, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + require.NotNil(t, cfg.Official) + assert.True(t, cfg.Official.Enabled) + assert.Equal(t, "dist/brew", cfg.Official.Output) + }) + + t.Run("handles missing official fields", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "homebrew", + Extended: map[string]any{ + "official": map[string]any{}, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + require.NotNil(t, cfg.Official) + assert.False(t, cfg.Official.Enabled) + assert.Empty(t, cfg.Official.Output) + }) +} + +func TestHomebrewPublisher_ToFormulaClass_Good(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple name", + input: "core", + expected: "Core", + }, + { + name: "kebab case", + input: "my-cli-tool", + expected: "MyCliTool", + }, + { + name: "already capitalised", + input: "CLI", + expected: "CLI", + }, + { + name: "single letter", + input: "x", + expected: "X", + }, + { + name: "multiple dashes", + input: "my-super-cool-app", + expected: "MySuperCoolApp", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := toFormulaClass(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestHomebrewPublisher_BuildChecksumMap_Good(t *testing.T) { + t.Run("maps artifacts to checksums by platform", func(t *testing.T) { + artifacts := []build.Artifact{ + {Path: "/dist/myapp-darwin-amd64.tar.gz", OS: "darwin", Arch: "amd64", Checksum: "abc123"}, + {Path: "/dist/myapp-darwin-arm64.tar.gz", OS: "darwin", Arch: "arm64", Checksum: "def456"}, + {Path: "/dist/myapp-linux-amd64.tar.gz", OS: "linux", Arch: "amd64", Checksum: "ghi789"}, + {Path: "/dist/myapp-linux-arm64.tar.gz", OS: "linux", Arch: "arm64", Checksum: "jkl012"}, + {Path: "/dist/myapp-windows-amd64.zip", OS: "windows", Arch: "amd64", Checksum: "mno345"}, + {Path: "/dist/myapp-windows-arm64.zip", OS: "windows", Arch: "arm64", Checksum: "pqr678"}, + } + + checksums := buildChecksumMap(artifacts) + + assert.Equal(t, "abc123", checksums.DarwinAmd64) + assert.Equal(t, "def456", checksums.DarwinArm64) + assert.Equal(t, "ghi789", checksums.LinuxAmd64) + assert.Equal(t, "jkl012", checksums.LinuxArm64) + assert.Equal(t, "mno345", checksums.WindowsAmd64) + assert.Equal(t, "pqr678", checksums.WindowsArm64) + }) + + t.Run("handles empty artifacts", func(t *testing.T) { + checksums := buildChecksumMap([]build.Artifact{}) + + assert.Empty(t, checksums.DarwinAmd64) + assert.Empty(t, checksums.DarwinArm64) + assert.Empty(t, checksums.LinuxAmd64) + assert.Empty(t, checksums.LinuxArm64) + }) + + t.Run("handles partial platform coverage", func(t *testing.T) { + artifacts := []build.Artifact{ + {Path: "/dist/myapp-darwin-arm64.tar.gz", Checksum: "def456"}, + {Path: "/dist/myapp-linux-amd64.tar.gz", Checksum: "ghi789"}, + } + + checksums := buildChecksumMap(artifacts) + + assert.Empty(t, checksums.DarwinAmd64) + assert.Equal(t, "def456", checksums.DarwinArm64) + assert.Equal(t, "ghi789", checksums.LinuxAmd64) + assert.Empty(t, checksums.LinuxArm64) + }) +} + +func TestHomebrewPublisher_RenderTemplate_Good(t *testing.T) { + p := NewHomebrewPublisher() + + t.Run("renders formula template with data", func(t *testing.T) { + data := homebrewTemplateData{ + FormulaClass: "MyApp", + Description: "My awesome CLI", + Repository: "owner/myapp", + Version: "1.2.3", + License: "MIT", + BinaryName: "myapp", + Checksums: ChecksumMap{ + DarwinAmd64: "abc123", + DarwinArm64: "def456", + LinuxAmd64: "ghi789", + LinuxArm64: "jkl012", + }, + } + + result, err := p.renderTemplate("templates/homebrew/formula.rb.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, "class MyApp < Formula") + assert.Contains(t, result, `desc "My awesome CLI"`) + assert.Contains(t, result, `version "1.2.3"`) + assert.Contains(t, result, `license "MIT"`) + assert.Contains(t, result, "owner/myapp") + assert.Contains(t, result, "abc123") + assert.Contains(t, result, "def456") + assert.Contains(t, result, "ghi789") + assert.Contains(t, result, "jkl012") + assert.Contains(t, result, `bin.install "myapp"`) + }) +} + +func TestHomebrewPublisher_RenderTemplate_Bad(t *testing.T) { + p := NewHomebrewPublisher() + + t.Run("returns error for non-existent template", func(t *testing.T) { + data := homebrewTemplateData{} + _, err := p.renderTemplate("templates/homebrew/nonexistent.tmpl", data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read template") + }) +} + +func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { + p := NewHomebrewPublisher() + + t.Run("outputs expected dry run information", func(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := homebrewTemplateData{ + FormulaClass: "MyApp", + Description: "My CLI", + Repository: "owner/repo", + Version: "1.0.0", + License: "MIT", + BinaryName: "myapp", + Checksums: ChecksumMap{}, + } + cfg := HomebrewConfig{ + Tap: "owner/homebrew-tap", + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + assert.Contains(t, output, "DRY RUN: Homebrew Publish") + assert.Contains(t, output, "Formula: MyApp") + assert.Contains(t, output, "Version: 1.0.0") + assert.Contains(t, output, "Tap: owner/homebrew-tap") + assert.Contains(t, output, "Repository: owner/repo") + assert.Contains(t, output, "Would commit to tap: owner/homebrew-tap") + assert.Contains(t, output, "END DRY RUN") + }) + + t.Run("shows official output path when enabled", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := homebrewTemplateData{ + FormulaClass: "MyApp", + Version: "1.0.0", + BinaryName: "myapp", + Checksums: ChecksumMap{}, + } + cfg := HomebrewConfig{ + Official: &OfficialConfig{ + Enabled: true, + Output: "custom/path", + }, + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "Would write files for official PR to: custom/path") + }) + + t.Run("uses default official output path when not specified", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := homebrewTemplateData{ + FormulaClass: "MyApp", + Version: "1.0.0", + BinaryName: "myapp", + Checksums: ChecksumMap{}, + } + cfg := HomebrewConfig{ + Official: &OfficialConfig{ + Enabled: true, + }, + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "Would write files for official PR to: dist/homebrew") + }) +} + +func TestHomebrewPublisher_Publish_Bad(t *testing.T) { + p := NewHomebrewPublisher() + + t.Run("fails when tap not configured and not official mode", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + ProjectDir: "/project", + } + pubCfg := PublisherConfig{Type: "homebrew"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + err := p.Publish(nil, release, pubCfg, relCfg, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "tap is required") + }) +} + +func TestHomebrewConfig_Defaults_Good(t *testing.T) { + t.Run("has sensible defaults", func(t *testing.T) { + p := NewHomebrewPublisher() + pubCfg := PublisherConfig{Type: "homebrew"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Tap) + assert.Empty(t, cfg.Formula) + assert.Nil(t, cfg.Official) + }) +} diff --git a/pkg/release/publishers/npm_test.go b/pkg/release/publishers/npm_test.go new file mode 100644 index 00000000..b726ee48 --- /dev/null +++ b/pkg/release/publishers/npm_test.go @@ -0,0 +1,298 @@ +package publishers + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNpmPublisher_Name_Good(t *testing.T) { + t.Run("returns npm", func(t *testing.T) { + p := NewNpmPublisher() + assert.Equal(t, "npm", p.Name()) + }) +} + +func TestNpmPublisher_ParseConfig_Good(t *testing.T) { + p := NewNpmPublisher() + + t.Run("uses defaults when no extended config", func(t *testing.T) { + pubCfg := PublisherConfig{Type: "npm"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.Equal(t, "public", cfg.Access) + }) + + t.Run("parses package and access from extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "npm", + Extended: map[string]any{ + "package": "@myorg/mypackage", + "access": "restricted", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Equal(t, "@myorg/mypackage", cfg.Package) + assert.Equal(t, "restricted", cfg.Access) + }) + + t.Run("keeps default access when not specified", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "npm", + Extended: map[string]any{ + "package": "@myorg/mypackage", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Equal(t, "@myorg/mypackage", cfg.Package) + assert.Equal(t, "public", cfg.Access) + }) + + t.Run("handles nil extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "npm", + Extended: nil, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.Equal(t, "public", cfg.Access) + }) + + t.Run("handles empty strings in config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "npm", + Extended: map[string]any{ + "package": "", + "access": "", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.Equal(t, "public", cfg.Access) + }) +} + +func TestNpmPublisher_RenderTemplate_Good(t *testing.T) { + p := NewNpmPublisher() + + t.Run("renders package.json template with data", func(t *testing.T) { + data := npmTemplateData{ + Package: "@myorg/mycli", + Version: "1.2.3", + Description: "My awesome CLI", + License: "MIT", + Repository: "owner/myapp", + BinaryName: "myapp", + ProjectName: "myapp", + Access: "public", + } + + result, err := p.renderTemplate("templates/npm/package.json.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, `"name": "@myorg/mycli"`) + assert.Contains(t, result, `"version": "1.2.3"`) + assert.Contains(t, result, `"description": "My awesome CLI"`) + assert.Contains(t, result, `"license": "MIT"`) + assert.Contains(t, result, "owner/myapp") + assert.Contains(t, result, `"myapp": "./bin/run.js"`) + assert.Contains(t, result, `"access": "public"`) + }) + + t.Run("renders restricted access correctly", func(t *testing.T) { + data := npmTemplateData{ + Package: "@private/cli", + Version: "1.0.0", + Description: "Private CLI", + License: "MIT", + Repository: "org/repo", + BinaryName: "cli", + ProjectName: "cli", + Access: "restricted", + } + + result, err := p.renderTemplate("templates/npm/package.json.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, `"access": "restricted"`) + }) +} + +func TestNpmPublisher_RenderTemplate_Bad(t *testing.T) { + p := NewNpmPublisher() + + t.Run("returns error for non-existent template", func(t *testing.T) { + data := npmTemplateData{} + _, err := p.renderTemplate("templates/npm/nonexistent.tmpl", data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read template") + }) +} + +func TestNpmPublisher_DryRunPublish_Good(t *testing.T) { + p := NewNpmPublisher() + + t.Run("outputs expected dry run information", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := npmTemplateData{ + Package: "@myorg/mycli", + Version: "1.0.0", + Access: "public", + Repository: "owner/repo", + BinaryName: "mycli", + Description: "My CLI", + } + cfg := &NpmConfig{ + Package: "@myorg/mycli", + Access: "public", + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + assert.Contains(t, output, "DRY RUN: npm Publish") + assert.Contains(t, output, "Package: @myorg/mycli") + assert.Contains(t, output, "Version: 1.0.0") + assert.Contains(t, output, "Access: public") + assert.Contains(t, output, "Repository: owner/repo") + assert.Contains(t, output, "Binary: mycli") + assert.Contains(t, output, "Generated package.json:") + assert.Contains(t, output, "Would run: npm publish --access public") + assert.Contains(t, output, "END DRY RUN") + }) + + t.Run("shows restricted access correctly", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := npmTemplateData{ + Package: "@private/cli", + Version: "2.0.0", + Access: "restricted", + Repository: "org/repo", + BinaryName: "cli", + } + cfg := &NpmConfig{ + Package: "@private/cli", + Access: "restricted", + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + assert.Contains(t, output, "Access: restricted") + assert.Contains(t, output, "Would run: npm publish --access restricted") + }) +} + +func TestNpmPublisher_Publish_Bad(t *testing.T) { + p := NewNpmPublisher() + + t.Run("fails when package name not configured", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + ProjectDir: "/project", + } + pubCfg := PublisherConfig{Type: "npm"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + err := p.Publish(nil, release, pubCfg, relCfg, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "package name is required") + }) + + t.Run("fails when NPM_TOKEN not set in non-dry-run", func(t *testing.T) { + // Ensure NPM_TOKEN is not set + oldToken := os.Getenv("NPM_TOKEN") + os.Unsetenv("NPM_TOKEN") + defer func() { + if oldToken != "" { + os.Setenv("NPM_TOKEN", oldToken) + } + }() + + release := &Release{ + Version: "v1.0.0", + ProjectDir: "/project", + } + pubCfg := PublisherConfig{ + Type: "npm", + Extended: map[string]any{ + "package": "@test/package", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + err := p.Publish(nil, release, pubCfg, relCfg, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "NPM_TOKEN environment variable is required") + }) +} + +func TestNpmConfig_Defaults_Good(t *testing.T) { + t.Run("has sensible defaults", func(t *testing.T) { + p := NewNpmPublisher() + pubCfg := PublisherConfig{Type: "npm"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Package) + assert.Equal(t, "public", cfg.Access) + }) +} + +func TestNpmTemplateData_Good(t *testing.T) { + t.Run("struct has all expected fields", func(t *testing.T) { + data := npmTemplateData{ + Package: "@myorg/package", + Version: "1.0.0", + Description: "description", + License: "MIT", + Repository: "org/repo", + BinaryName: "cli", + ProjectName: "cli", + Access: "public", + } + + assert.Equal(t, "@myorg/package", data.Package) + assert.Equal(t, "1.0.0", data.Version) + assert.Equal(t, "description", data.Description) + assert.Equal(t, "MIT", data.License) + assert.Equal(t, "org/repo", data.Repository) + assert.Equal(t, "cli", data.BinaryName) + assert.Equal(t, "cli", data.ProjectName) + assert.Equal(t, "public", data.Access) + }) +} diff --git a/pkg/release/publishers/scoop_test.go b/pkg/release/publishers/scoop_test.go new file mode 100644 index 00000000..5c8d6b41 --- /dev/null +++ b/pkg/release/publishers/scoop_test.go @@ -0,0 +1,307 @@ +package publishers + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScoopPublisher_Name_Good(t *testing.T) { + t.Run("returns scoop", func(t *testing.T) { + p := NewScoopPublisher() + assert.Equal(t, "scoop", p.Name()) + }) +} + +func TestScoopPublisher_ParseConfig_Good(t *testing.T) { + p := NewScoopPublisher() + + t.Run("uses defaults when no extended config", func(t *testing.T) { + pubCfg := PublisherConfig{Type: "scoop"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Bucket) + assert.Nil(t, cfg.Official) + }) + + t.Run("parses bucket from extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "scoop", + Extended: map[string]any{ + "bucket": "host-uk/scoop-bucket", + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Equal(t, "host-uk/scoop-bucket", cfg.Bucket) + }) + + t.Run("parses official config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "scoop", + Extended: map[string]any{ + "official": map[string]any{ + "enabled": true, + "output": "dist/scoop-manifest", + }, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + require.NotNil(t, cfg.Official) + assert.True(t, cfg.Official.Enabled) + assert.Equal(t, "dist/scoop-manifest", cfg.Official.Output) + }) + + t.Run("handles missing official fields", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "scoop", + Extended: map[string]any{ + "official": map[string]any{}, + }, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + require.NotNil(t, cfg.Official) + assert.False(t, cfg.Official.Enabled) + assert.Empty(t, cfg.Official.Output) + }) + + t.Run("handles nil extended config", func(t *testing.T) { + pubCfg := PublisherConfig{ + Type: "scoop", + Extended: nil, + } + relCfg := &mockReleaseConfig{repository: "owner/repo"} + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Bucket) + assert.Nil(t, cfg.Official) + }) +} + +func TestScoopPublisher_RenderTemplate_Good(t *testing.T) { + p := NewScoopPublisher() + + t.Run("renders manifest template with data", func(t *testing.T) { + data := scoopTemplateData{ + PackageName: "myapp", + Description: "My awesome CLI", + Repository: "owner/myapp", + Version: "1.2.3", + License: "MIT", + BinaryName: "myapp", + Checksums: ChecksumMap{ + WindowsAmd64: "abc123", + WindowsArm64: "def456", + }, + } + + result, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, `"version": "1.2.3"`) + assert.Contains(t, result, `"description": "My awesome CLI"`) + assert.Contains(t, result, `"homepage": "https://github.com/owner/myapp"`) + assert.Contains(t, result, `"license": "MIT"`) + assert.Contains(t, result, `"64bit"`) + assert.Contains(t, result, `"arm64"`) + assert.Contains(t, result, "myapp-windows-amd64.zip") + assert.Contains(t, result, "myapp-windows-arm64.zip") + assert.Contains(t, result, `"hash": "abc123"`) + assert.Contains(t, result, `"hash": "def456"`) + assert.Contains(t, result, `"bin": "myapp.exe"`) + }) + + t.Run("includes autoupdate configuration", func(t *testing.T) { + data := scoopTemplateData{ + PackageName: "tool", + Description: "A tool", + Repository: "org/tool", + Version: "2.0.0", + License: "Apache-2.0", + BinaryName: "tool", + Checksums: ChecksumMap{}, + } + + result, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data) + require.NoError(t, err) + + assert.Contains(t, result, `"checkver"`) + assert.Contains(t, result, `"github": "https://github.com/org/tool"`) + assert.Contains(t, result, `"autoupdate"`) + }) +} + +func TestScoopPublisher_RenderTemplate_Bad(t *testing.T) { + p := NewScoopPublisher() + + t.Run("returns error for non-existent template", func(t *testing.T) { + data := scoopTemplateData{} + _, err := p.renderTemplate("templates/scoop/nonexistent.tmpl", data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read template") + }) +} + +func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { + p := NewScoopPublisher() + + t.Run("outputs expected dry run information", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := scoopTemplateData{ + PackageName: "myapp", + Version: "1.0.0", + Repository: "owner/repo", + BinaryName: "myapp", + Checksums: ChecksumMap{}, + } + cfg := ScoopConfig{ + Bucket: "owner/scoop-bucket", + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + + assert.Contains(t, output, "DRY RUN: Scoop Publish") + assert.Contains(t, output, "Package: myapp") + assert.Contains(t, output, "Version: 1.0.0") + assert.Contains(t, output, "Bucket: owner/scoop-bucket") + assert.Contains(t, output, "Repository: owner/repo") + assert.Contains(t, output, "Generated manifest.json:") + assert.Contains(t, output, "Would commit to bucket: owner/scoop-bucket") + assert.Contains(t, output, "END DRY RUN") + }) + + t.Run("shows official output path when enabled", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := scoopTemplateData{ + PackageName: "myapp", + Version: "1.0.0", + BinaryName: "myapp", + Checksums: ChecksumMap{}, + } + cfg := ScoopConfig{ + Official: &OfficialConfig{ + Enabled: true, + Output: "custom/scoop/path", + }, + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "Would write files for official PR to: custom/scoop/path") + }) + + t.Run("uses default official output path when not specified", func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + data := scoopTemplateData{ + PackageName: "myapp", + Version: "1.0.0", + BinaryName: "myapp", + Checksums: ChecksumMap{}, + } + cfg := ScoopConfig{ + Official: &OfficialConfig{ + Enabled: true, + }, + } + + err := p.dryRunPublish(data, cfg) + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + os.Stdout = oldStdout + + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "Would write files for official PR to: dist/scoop") + }) +} + +func TestScoopPublisher_Publish_Bad(t *testing.T) { + p := NewScoopPublisher() + + t.Run("fails when bucket not configured and not official mode", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + ProjectDir: "/project", + } + pubCfg := PublisherConfig{Type: "scoop"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + err := p.Publish(nil, release, pubCfg, relCfg, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "bucket is required") + }) +} + +func TestScoopConfig_Defaults_Good(t *testing.T) { + t.Run("has sensible defaults", func(t *testing.T) { + p := NewScoopPublisher() + pubCfg := PublisherConfig{Type: "scoop"} + relCfg := &mockReleaseConfig{repository: "owner/repo"} + + cfg := p.parseConfig(pubCfg, relCfg) + + assert.Empty(t, cfg.Bucket) + assert.Nil(t, cfg.Official) + }) +} + +func TestScoopTemplateData_Good(t *testing.T) { + t.Run("struct has all expected fields", func(t *testing.T) { + data := scoopTemplateData{ + PackageName: "myapp", + Description: "description", + Repository: "org/repo", + Version: "1.0.0", + License: "MIT", + BinaryName: "myapp", + Checksums: ChecksumMap{ + WindowsAmd64: "hash1", + WindowsArm64: "hash2", + }, + } + + assert.Equal(t, "myapp", data.PackageName) + assert.Equal(t, "description", data.Description) + assert.Equal(t, "org/repo", data.Repository) + assert.Equal(t, "1.0.0", data.Version) + assert.Equal(t, "MIT", data.License) + assert.Equal(t, "myapp", data.BinaryName) + assert.Equal(t, "hash1", data.Checksums.WindowsAmd64) + assert.Equal(t, "hash2", data.Checksums.WindowsArm64) + }) +}