Migrate pkg/devops to Medium abstraction (#293)

* chore(io): migrate pkg/devops to Medium abstraction

This commit migrates the pkg/devops package to use the io.Medium abstraction instead of direct calls to io.Local or the os package.

Changes:
- Updated DevOps, ImageManager, and Manifest structs to hold an io.Medium.
- Updated New, NewImageManager, and LoadConfig to accept an io.Medium.
- Updated ImageSource interface and its implementations (GitHubSource, CDNSource) to accept io.Medium in Download method.
- Refactored internal helper functions (hasFile, hasPackageScript, etc.) to use io.Medium.
- Updated all unit tests and CLI entry points to pass the appropriate io.Medium.

This migration improves the testability and flexibility of the devops package by allowing for different storage backends.

* chore(io): migrate pkg/devops to Medium abstraction

This commit completes the migration of the pkg/devops package to the io.Medium abstraction.

Changes:
- Refactored DevOps, ImageManager, and Manifest structs to use io.Medium for storage operations.
- Updated New, NewImageManager, and LoadConfig to accept an io.Medium.
- Updated ImageSource interface and its implementations (GitHubSource, CDNSource) to accept io.Medium in Download method.
- Refactored internal helper functions (hasFile, hasPackageScript, etc.) to use io.Medium.
- Updated all unit tests and CLI entry points to pass the appropriate io.Medium.
- Fixed formatting issues in test files.

This migration enables easier testing and supports alternative storage backends.
This commit is contained in:
Snider 2026-02-04 14:58:03 +00:00 committed by GitHub
parent c54b28249c
commit 7ccfa92c7e
15 changed files with 223 additions and 181 deletions

View file

@ -9,6 +9,7 @@ import (
"github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/devops" "github.com/host-uk/core/pkg/devops"
"github.com/host-uk/core/pkg/i18n" "github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
) )
// addVMCommands adds the dev environment VM commands to the dev parent command. // addVMCommands adds the dev environment VM commands to the dev parent command.
@ -40,7 +41,7 @@ func addVMInstallCommand(parent *cli.Command) {
} }
func runVMInstall() error { func runVMInstall() error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }
@ -112,7 +113,7 @@ func addVMBootCommand(parent *cli.Command) {
} }
func runVMBoot(memory, cpus int, fresh bool) error { func runVMBoot(memory, cpus int, fresh bool) error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }
@ -163,7 +164,7 @@ func addVMStopCommand(parent *cli.Command) {
} }
func runVMStop() error { func runVMStop() error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }
@ -204,7 +205,7 @@ func addVMStatusCommand(parent *cli.Command) {
} }
func runVMStatus() error { func runVMStatus() error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }
@ -283,7 +284,7 @@ func addVMShellCommand(parent *cli.Command) {
} }
func runVMShell(console bool, command []string) error { func runVMShell(console bool, command []string) error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }
@ -321,7 +322,7 @@ func addVMServeCommand(parent *cli.Command) {
} }
func runVMServe(port int, path string) error { func runVMServe(port int, path string) error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }
@ -360,7 +361,7 @@ func addVMTestCommand(parent *cli.Command) {
} }
func runVMTest(name string, command []string) error { func runVMTest(name string, command []string) error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }
@ -405,7 +406,7 @@ func addVMClaudeCommand(parent *cli.Command) {
} }
func runVMClaude(noAuth bool, model string, authFlags []string) error { func runVMClaude(noAuth bool, model string, authFlags []string) error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }
@ -445,7 +446,7 @@ func addVMUpdateCommand(parent *cli.Command) {
} }
func runVMUpdate(apply bool) error { func runVMUpdate(apply bool) error {
d, err := devops.New() d, err := devops.New(io.Local)
if err != nil { if err != nil {
return err return err
} }

View file

@ -62,15 +62,15 @@ func ConfigPath() (string, error) {
return filepath.Join(home, ".core", "config.yaml"), nil return filepath.Join(home, ".core", "config.yaml"), nil
} }
// LoadConfig loads configuration from ~/.core/config.yaml. // LoadConfig loads configuration from ~/.core/config.yaml using the provided medium.
// Returns default config if file doesn't exist. // Returns default config if file doesn't exist.
func LoadConfig() (*Config, error) { func LoadConfig(m io.Medium) (*Config, error) {
configPath, err := ConfigPath() configPath, err := ConfigPath()
if err != nil { if err != nil {
return DefaultConfig(), nil return DefaultConfig(), nil
} }
content, err := io.Local.Read(configPath) content, err := m.Read(configPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return DefaultConfig(), nil return DefaultConfig(), nil

View file

@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -30,7 +31,7 @@ func TestLoadConfig_Good(t *testing.T) {
t.Setenv("HOME", tempHome) t.Setenv("HOME", tempHome)
defer func() { _ = os.Setenv("HOME", origHome) }() defer func() { _ = os.Setenv("HOME", origHome) }()
cfg, err := LoadConfig() cfg, err := LoadConfig(io.Local)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, DefaultConfig(), cfg) assert.Equal(t, DefaultConfig(), cfg)
}) })
@ -53,7 +54,7 @@ images:
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig() cfg, err := LoadConfig(io.Local)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 2, cfg.Version) assert.Equal(t, 2, cfg.Version)
assert.Equal(t, "cdn", cfg.Images.Source) assert.Equal(t, "cdn", cfg.Images.Source)
@ -73,7 +74,7 @@ func TestLoadConfig_Bad(t *testing.T) {
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644) err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte("invalid: yaml: :"), 0644)
require.NoError(t, err) require.NoError(t, err)
_, err = LoadConfig() _, err = LoadConfig(io.Local)
assert.Error(t, err) assert.Error(t, err)
}) })
} }
@ -127,7 +128,7 @@ images:
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644) err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(configData), 0644)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig() cfg, err := LoadConfig(io.Local)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 1, cfg.Version) assert.Equal(t, 1, cfg.Version)
assert.Equal(t, "github", cfg.Images.Source) assert.Equal(t, "github", cfg.Images.Source)
@ -197,7 +198,7 @@ images:
err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644) err = os.WriteFile(filepath.Join(coreDir, "config.yaml"), []byte(tt.config), 0644)
require.NoError(t, err) require.NoError(t, err)
cfg, err := LoadConfig() cfg, err := LoadConfig(io.Local)
assert.NoError(t, err) assert.NoError(t, err)
tt.check(t, cfg) tt.check(t, cfg)
}) })
@ -246,7 +247,7 @@ func TestLoadConfig_Bad_UnreadableFile(t *testing.T) {
err = os.WriteFile(configPath, []byte("version: 1"), 0000) err = os.WriteFile(configPath, []byte("version: 1"), 0000)
require.NoError(t, err) require.NoError(t, err)
_, err = LoadConfig() _, err = LoadConfig(io.Local)
assert.Error(t, err) assert.Error(t, err)
// Restore permissions so cleanup works // Restore permissions so cleanup works

View file

@ -15,19 +15,20 @@ import (
// DevOps manages the portable development environment. // DevOps manages the portable development environment.
type DevOps struct { type DevOps struct {
medium io.Medium
config *Config config *Config
images *ImageManager images *ImageManager
container *container.LinuxKitManager container *container.LinuxKitManager
} }
// New creates a new DevOps instance. // New creates a new DevOps instance using the provided medium.
func New() (*DevOps, error) { func New(m io.Medium) (*DevOps, error) {
cfg, err := LoadConfig() cfg, err := LoadConfig(m)
if err != nil { if err != nil {
return nil, fmt.Errorf("devops.New: failed to load config: %w", err) return nil, fmt.Errorf("devops.New: failed to load config: %w", err)
} }
images, err := NewImageManager(cfg) images, err := NewImageManager(m, cfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("devops.New: failed to create image manager: %w", err) return nil, fmt.Errorf("devops.New: failed to create image manager: %w", err)
} }
@ -38,6 +39,7 @@ func New() (*DevOps, error) {
} }
return &DevOps{ return &DevOps{
medium: m,
config: cfg, config: cfg,
images: images, images: images,
container: mgr, container: mgr,
@ -76,7 +78,7 @@ func (d *DevOps) IsInstalled() bool {
if err != nil { if err != nil {
return false return false
} }
return io.Local.IsFile(path) return d.medium.IsFile(path)
} }
// Install downloads and installs the dev image. // Install downloads and installs the dev image.

View file

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/host-uk/core/pkg/container" "github.com/host-uk/core/pkg/container"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -69,7 +70,7 @@ func TestIsInstalled_Bad(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
// Create devops instance manually to avoid loading real config/images // Create devops instance manually to avoid loading real config/images
d := &DevOps{} d := &DevOps{medium: io.Local}
assert.False(t, d.IsInstalled()) assert.False(t, d.IsInstalled())
}) })
} }
@ -84,7 +85,7 @@ func TestIsInstalled_Good(t *testing.T) {
err := os.WriteFile(imagePath, []byte("fake image data"), 0644) err := os.WriteFile(imagePath, []byte("fake image data"), 0644)
require.NoError(t, err) require.NoError(t, err)
d := &DevOps{} d := &DevOps{medium: io.Local}
assert.True(t, d.IsInstalled()) assert.True(t, d.IsInstalled())
}) })
} }
@ -102,7 +103,7 @@ func TestDevOps_Status_Good(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
// Setup mock container manager // Setup mock container manager
@ -111,7 +112,7 @@ func TestDevOps_Status_Good(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -143,7 +144,7 @@ func TestDevOps_Status_Good_NotInstalled(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -151,7 +152,7 @@ func TestDevOps_Status_Good_NotInstalled(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -174,7 +175,7 @@ func TestDevOps_Status_Good_NoContainer(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -182,7 +183,7 @@ func TestDevOps_Status_Good_NoContainer(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -200,7 +201,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -208,7 +209,7 @@ func TestDevOps_IsRunning_Good(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -233,7 +234,7 @@ func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -241,7 +242,7 @@ func TestDevOps_IsRunning_Bad_NotRunning(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -256,7 +257,7 @@ func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -264,7 +265,7 @@ func TestDevOps_IsRunning_Bad_ContainerStopped(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -289,7 +290,7 @@ func TestDevOps_findContainer_Good(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -297,7 +298,7 @@ func TestDevOps_findContainer_Good(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -324,7 +325,7 @@ func TestDevOps_findContainer_Bad_NotFound(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -332,7 +333,7 @@ func TestDevOps_findContainer_Bad_NotFound(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -347,7 +348,7 @@ func TestDevOps_Stop_Bad_NotFound(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -355,7 +356,7 @@ func TestDevOps_Stop_Bad_NotFound(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -404,7 +405,7 @@ func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -412,7 +413,7 @@ func TestDevOps_Boot_Bad_NotInstalled(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -432,7 +433,7 @@ func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -440,7 +441,7 @@ func TestDevOps_Boot_Bad_AlreadyRunning(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -471,7 +472,7 @@ func TestDevOps_Status_Good_WithImageVersion(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
// Manually set manifest with version info // Manually set manifest with version info
@ -485,7 +486,7 @@ func TestDevOps_Status_Good_WithImageVersion(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
config: cfg, config: cfg,
images: mgr, images: mgr,
container: cm, container: cm,
@ -502,7 +503,7 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -510,7 +511,7 @@ func TestDevOps_findContainer_Good_MultipleContainers(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -547,7 +548,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -555,7 +556,7 @@ func TestDevOps_Status_Good_ContainerWithUptime(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -584,7 +585,7 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -592,7 +593,7 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -626,7 +627,7 @@ func TestDevOps_Boot_Good_FreshFlag(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -634,7 +635,7 @@ func TestDevOps_Boot_Good_FreshFlag(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -668,7 +669,7 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -676,7 +677,7 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -710,7 +711,7 @@ func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -718,7 +719,7 @@ func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -750,10 +751,10 @@ func TestDevOps_Install_Delegates(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
} }
@ -768,10 +769,10 @@ func TestDevOps_CheckUpdate_Delegates(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
} }
@ -792,7 +793,7 @@ func TestDevOps_Boot_Good_Success(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
statePath := filepath.Join(tempDir, "containers.json") statePath := filepath.Join(tempDir, "containers.json")
@ -800,7 +801,7 @@ func TestDevOps_Boot_Good_Success(t *testing.T) {
h := &mockHypervisor{} h := &mockHypervisor{}
cm := container.NewLinuxKitManagerWithHypervisor(state, h) cm := container.NewLinuxKitManagerWithHypervisor(state, h)
d := &DevOps{ d := &DevOps{medium: io.Local,
images: mgr, images: mgr,
container: cm, container: cm,
} }
@ -816,10 +817,10 @@ func TestDevOps_Config(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tempDir) t.Setenv("CORE_IMAGES_DIR", tempDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
d := &DevOps{ d := &DevOps{medium: io.Local,
config: cfg, config: cfg,
images: mgr, images: mgr,
} }

View file

@ -14,6 +14,7 @@ import (
// ImageManager handles image downloads and updates. // ImageManager handles image downloads and updates.
type ImageManager struct { type ImageManager struct {
medium io.Medium
config *Config config *Config
manifest *Manifest manifest *Manifest
sources []sources.ImageSource sources []sources.ImageSource
@ -21,6 +22,7 @@ type ImageManager struct {
// Manifest tracks installed images. // Manifest tracks installed images.
type Manifest struct { type Manifest struct {
medium io.Medium
Images map[string]ImageInfo `json:"images"` Images map[string]ImageInfo `json:"images"`
path string path string
} }
@ -34,20 +36,20 @@ type ImageInfo struct {
} }
// NewImageManager creates a new image manager. // NewImageManager creates a new image manager.
func NewImageManager(cfg *Config) (*ImageManager, error) { func NewImageManager(m io.Medium, cfg *Config) (*ImageManager, error) {
imagesDir, err := ImagesDir() imagesDir, err := ImagesDir()
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Ensure images directory exists // Ensure images directory exists
if err := io.Local.EnsureDir(imagesDir); err != nil { if err := m.EnsureDir(imagesDir); err != nil {
return nil, err return nil, err
} }
// Load or create manifest // Load or create manifest
manifestPath := filepath.Join(imagesDir, "manifest.json") manifestPath := filepath.Join(imagesDir, "manifest.json")
manifest, err := loadManifest(manifestPath) manifest, err := loadManifest(m, manifestPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -75,6 +77,7 @@ func NewImageManager(cfg *Config) (*ImageManager, error) {
} }
return &ImageManager{ return &ImageManager{
medium: m,
config: cfg, config: cfg,
manifest: manifest, manifest: manifest,
sources: srcs, sources: srcs,
@ -87,7 +90,7 @@ func (m *ImageManager) IsInstalled() bool {
if err != nil { if err != nil {
return false return false
} }
return io.Local.IsFile(path) return m.medium.IsFile(path)
} }
// Install downloads and installs the dev image. // Install downloads and installs the dev image.
@ -118,7 +121,7 @@ func (m *ImageManager) Install(ctx context.Context, progress func(downloaded, to
fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name()) fmt.Printf("Downloading %s from %s...\n", ImageName(), src.Name())
// Download // Download
if err := src.Download(ctx, imagesDir, progress); err != nil { if err := src.Download(ctx, m.medium, imagesDir, progress); err != nil {
return err return err
} }
@ -161,26 +164,28 @@ func (m *ImageManager) CheckUpdate(ctx context.Context) (current, latest string,
return current, latest, hasUpdate, nil return current, latest, hasUpdate, nil
} }
func loadManifest(path string) (*Manifest, error) { func loadManifest(m io.Medium, path string) (*Manifest, error) {
m := &Manifest{ manifest := &Manifest{
medium: m,
Images: make(map[string]ImageInfo), Images: make(map[string]ImageInfo),
path: path, path: path,
} }
content, err := io.Local.Read(path) content, err := m.Read(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return m, nil return manifest, nil
} }
return nil, err return nil, err
} }
if err := json.Unmarshal([]byte(content), m); err != nil { if err := json.Unmarshal([]byte(content), manifest); err != nil {
return nil, err return nil, err
} }
m.path = path manifest.medium = m
manifest.path = path
return m, nil return manifest, nil
} }
// Save writes the manifest to disk. // Save writes the manifest to disk.
@ -189,5 +194,5 @@ func (m *Manifest) Save() error {
if err != nil { if err != nil {
return err return err
} }
return io.Local.Write(m.path, string(data)) return m.medium.Write(m.path, string(data))
} }

View file

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/host-uk/core/pkg/devops/sources" "github.com/host-uk/core/pkg/devops/sources"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -17,7 +18,7 @@ func TestImageManager_Good_IsInstalled(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
// Not installed yet // Not installed yet
@ -40,7 +41,7 @@ func TestNewImageManager_Good(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.Images.Source = "cdn" cfg.Images.Source = "cdn"
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, mgr) assert.NotNil(t, mgr)
assert.Len(t, mgr.sources, 1) assert.Len(t, mgr.sources, 1)
@ -54,7 +55,7 @@ func TestNewImageManager_Good(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.Images.Source = "github" cfg.Images.Source = "github"
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, mgr) assert.NotNil(t, mgr)
assert.Len(t, mgr.sources, 1) assert.Len(t, mgr.sources, 1)
@ -67,6 +68,7 @@ func TestManifest_Save(t *testing.T) {
path := filepath.Join(tmpDir, "manifest.json") path := filepath.Join(tmpDir, "manifest.json")
m := &Manifest{ m := &Manifest{
medium: io.Local,
Images: make(map[string]ImageInfo), Images: make(map[string]ImageInfo),
path: path, path: path,
} }
@ -84,7 +86,7 @@ func TestManifest_Save(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// Reload // Reload
m2, err := loadManifest(path) m2, err := loadManifest(io.Local, path)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "1.0.0", m2.Images["test.img"].Version) assert.Equal(t, "1.0.0", m2.Images["test.img"].Version)
} }
@ -96,7 +98,7 @@ func TestLoadManifest_Bad(t *testing.T) {
err := os.WriteFile(path, []byte("invalid json"), 0644) err := os.WriteFile(path, []byte("invalid json"), 0644)
require.NoError(t, err) require.NoError(t, err)
_, err = loadManifest(path) _, err = loadManifest(io.Local, path)
assert.Error(t, err) assert.Error(t, err)
}) })
} }
@ -107,7 +109,7 @@ func TestCheckUpdate_Bad(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
require.NoError(t, err) require.NoError(t, err)
_, _, _, err = mgr.CheckUpdate(context.Background()) _, _, _, err = mgr.CheckUpdate(context.Background())
@ -123,7 +125,7 @@ func TestNewImageManager_Good_AutoSource(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.Images.Source = "auto" cfg.Images.Source = "auto"
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, mgr) assert.NotNil(t, mgr)
assert.Len(t, mgr.sources, 2) // github and cdn assert.Len(t, mgr.sources, 2) // github and cdn
@ -136,7 +138,7 @@ func TestNewImageManager_Good_UnknownSourceFallsToAuto(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.Images.Source = "unknown" cfg.Images.Source = "unknown"
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, mgr) assert.NotNil(t, mgr)
assert.Len(t, mgr.sources, 2) // falls to default (auto) which is github + cdn assert.Len(t, mgr.sources, 2) // falls to default (auto) which is github + cdn
@ -146,7 +148,7 @@ func TestLoadManifest_Good_Empty(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "nonexistent.json") path := filepath.Join(tmpDir, "nonexistent.json")
m, err := loadManifest(path) m, err := loadManifest(io.Local, path)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, m) assert.NotNil(t, m)
assert.NotNil(t, m.Images) assert.NotNil(t, m.Images)
@ -162,7 +164,7 @@ func TestLoadManifest_Good_ExistingData(t *testing.T) {
err := os.WriteFile(path, []byte(data), 0644) err := os.WriteFile(path, []byte(data), 0644)
require.NoError(t, err) require.NoError(t, err)
m, err := loadManifest(path) m, err := loadManifest(io.Local, path)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, m) assert.NotNil(t, m)
assert.Equal(t, "2.0.0", m.Images["test.img"].Version) assert.Equal(t, "2.0.0", m.Images["test.img"].Version)
@ -187,6 +189,7 @@ func TestManifest_Save_Good_CreatesDirs(t *testing.T) {
nestedPath := filepath.Join(tmpDir, "nested", "dir", "manifest.json") nestedPath := filepath.Join(tmpDir, "nested", "dir", "manifest.json")
m := &Manifest{ m := &Manifest{
medium: io.Local,
Images: make(map[string]ImageInfo), Images: make(map[string]ImageInfo),
path: nestedPath, path: nestedPath,
} }
@ -207,6 +210,7 @@ func TestManifest_Save_Good_Overwrite(t *testing.T) {
// First save // First save
m1 := &Manifest{ m1 := &Manifest{
medium: io.Local,
Images: make(map[string]ImageInfo), Images: make(map[string]ImageInfo),
path: path, path: path,
} }
@ -216,6 +220,7 @@ func TestManifest_Save_Good_Overwrite(t *testing.T) {
// Second save with different data // Second save with different data
m2 := &Manifest{ m2 := &Manifest{
medium: io.Local,
Images: make(map[string]ImageInfo), Images: make(map[string]ImageInfo),
path: path, path: path,
} }
@ -224,7 +229,7 @@ func TestManifest_Save_Good_Overwrite(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Verify second data // Verify second data
loaded, err := loadManifest(path) loaded, err := loadManifest(io.Local, path)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "2.0.0", loaded.Images["other.img"].Version) assert.Equal(t, "2.0.0", loaded.Images["other.img"].Version)
_, exists := loaded.Images["test.img"] _, exists := loaded.Images["test.img"]
@ -237,8 +242,9 @@ func TestImageManager_Install_Bad_NoSourceAvailable(t *testing.T) {
// Create manager with empty sources // Create manager with empty sources
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
sources: nil, // no sources sources: nil, // no sources
} }
@ -253,7 +259,7 @@ func TestNewImageManager_Good_CreatesDir(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", imagesDir) t.Setenv("CORE_IMAGES_DIR", imagesDir)
cfg := DefaultConfig() cfg := DefaultConfig()
mgr, err := NewImageManager(cfg) mgr, err := NewImageManager(io.Local, cfg)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, mgr) assert.NotNil(t, mgr)
@ -277,7 +283,7 @@ func (m *mockImageSource) Available() bool { return m.available }
func (m *mockImageSource) LatestVersion(ctx context.Context) (string, error) { func (m *mockImageSource) LatestVersion(ctx context.Context) (string, error) {
return m.latestVersion, m.latestErr return m.latestVersion, m.latestErr
} }
func (m *mockImageSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error { func (m *mockImageSource) Download(ctx context.Context, medium io.Medium, dest string, progress func(downloaded, total int64)) error {
if m.downloadErr != nil { if m.downloadErr != nil {
return m.downloadErr return m.downloadErr
} }
@ -297,8 +303,9 @@ func TestImageManager_Install_Good_WithMockSource(t *testing.T) {
} }
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -325,8 +332,9 @@ func TestImageManager_Install_Bad_DownloadError(t *testing.T) {
} }
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -345,8 +353,9 @@ func TestImageManager_Install_Bad_VersionError(t *testing.T) {
} }
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
sources: []sources.ImageSource{mock}, sources: []sources.ImageSource{mock},
} }
@ -370,8 +379,9 @@ func TestImageManager_Install_Good_SkipsUnavailableSource(t *testing.T) {
} }
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
sources: []sources.ImageSource{unavailableMock, availableMock}, sources: []sources.ImageSource{unavailableMock, availableMock},
} }
@ -394,8 +404,10 @@ func TestImageManager_CheckUpdate_Good_WithMockSource(t *testing.T) {
} }
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{ manifest: &Manifest{
medium: io.Local,
Images: map[string]ImageInfo{ Images: map[string]ImageInfo{
ImageName(): {Version: "v1.0.0", Source: "mock"}, ImageName(): {Version: "v1.0.0", Source: "mock"},
}, },
@ -422,8 +434,10 @@ func TestImageManager_CheckUpdate_Good_NoUpdate(t *testing.T) {
} }
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{ manifest: &Manifest{
medium: io.Local,
Images: map[string]ImageInfo{ Images: map[string]ImageInfo{
ImageName(): {Version: "v1.0.0", Source: "mock"}, ImageName(): {Version: "v1.0.0", Source: "mock"},
}, },
@ -449,8 +463,10 @@ func TestImageManager_CheckUpdate_Bad_NoSource(t *testing.T) {
} }
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{ manifest: &Manifest{
medium: io.Local,
Images: map[string]ImageInfo{ Images: map[string]ImageInfo{
ImageName(): {Version: "v1.0.0", Source: "mock"}, ImageName(): {Version: "v1.0.0", Source: "mock"},
}, },
@ -475,8 +491,10 @@ func TestImageManager_CheckUpdate_Bad_VersionError(t *testing.T) {
} }
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{ manifest: &Manifest{
medium: io.Local,
Images: map[string]ImageInfo{ Images: map[string]ImageInfo{
ImageName(): {Version: "v1.0.0", Source: "mock"}, ImageName(): {Version: "v1.0.0", Source: "mock"},
}, },
@ -495,8 +513,9 @@ func TestImageManager_Install_Bad_EmptySources(t *testing.T) {
t.Setenv("CORE_IMAGES_DIR", tmpDir) t.Setenv("CORE_IMAGES_DIR", tmpDir)
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
sources: []sources.ImageSource{}, // Empty slice, not nil sources: []sources.ImageSource{}, // Empty slice, not nil
} }
@ -513,8 +532,9 @@ func TestImageManager_Install_Bad_AllUnavailable(t *testing.T) {
mock2 := &mockImageSource{name: "mock2", available: false} mock2 := &mockImageSource{name: "mock2", available: false}
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")}, manifest: &Manifest{medium: io.Local, Images: make(map[string]ImageInfo), path: filepath.Join(tmpDir, "manifest.json")},
sources: []sources.ImageSource{mock1, mock2}, sources: []sources.ImageSource{mock1, mock2},
} }
@ -531,8 +551,10 @@ func TestImageManager_CheckUpdate_Good_FirstSourceUnavailable(t *testing.T) {
available := &mockImageSource{name: "available", available: true, latestVersion: "v2.0.0"} available := &mockImageSource{name: "available", available: true, latestVersion: "v2.0.0"}
mgr := &ImageManager{ mgr := &ImageManager{
medium: io.Local,
config: DefaultConfig(), config: DefaultConfig(),
manifest: &Manifest{ manifest: &Manifest{
medium: io.Local,
Images: map[string]ImageInfo{ Images: map[string]ImageInfo{
ImageName(): {Version: "v1.0.0", Source: "available"}, ImageName(): {Version: "v1.0.0", Source: "available"},
}, },

View file

@ -6,6 +6,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"github.com/host-uk/core/pkg/io"
) )
// ServeOptions configures the dev server. // ServeOptions configures the dev server.
@ -39,7 +41,7 @@ func (d *DevOps) Serve(ctx context.Context, projectDir string, opts ServeOptions
} }
// Detect and run serve command // Detect and run serve command
serveCmd := DetectServeCommand(servePath) serveCmd := DetectServeCommand(d.medium, servePath)
fmt.Printf("Starting server: %s\n", serveCmd) fmt.Printf("Starting server: %s\n", serveCmd)
fmt.Printf("Listening on http://localhost:%d\n", opts.Port) fmt.Printf("Listening on http://localhost:%d\n", opts.Port)
@ -69,36 +71,36 @@ func (d *DevOps) mountProject(ctx context.Context, path string) error {
} }
// DetectServeCommand auto-detects the serve command for a project. // DetectServeCommand auto-detects the serve command for a project.
func DetectServeCommand(projectDir string) string { func DetectServeCommand(m io.Medium, projectDir string) string {
// Laravel/Octane // Laravel/Octane
if hasFile(projectDir, "artisan") { if hasFile(m, projectDir, "artisan") {
return "php artisan octane:start --host=0.0.0.0 --port=8000" return "php artisan octane:start --host=0.0.0.0 --port=8000"
} }
// Node.js with dev script // Node.js with dev script
if hasFile(projectDir, "package.json") { if hasFile(m, projectDir, "package.json") {
if hasPackageScript(projectDir, "dev") { if hasPackageScript(m, projectDir, "dev") {
return "npm run dev -- --host 0.0.0.0" return "npm run dev -- --host 0.0.0.0"
} }
if hasPackageScript(projectDir, "start") { if hasPackageScript(m, projectDir, "start") {
return "npm start" return "npm start"
} }
} }
// PHP with composer // PHP with composer
if hasFile(projectDir, "composer.json") { if hasFile(m, projectDir, "composer.json") {
return "frankenphp php-server -l :8000" return "frankenphp php-server -l :8000"
} }
// Go // Go
if hasFile(projectDir, "go.mod") { if hasFile(m, projectDir, "go.mod") {
if hasFile(projectDir, "main.go") { if hasFile(m, projectDir, "main.go") {
return "go run ." return "go run ."
} }
} }
// Python Django // Python Django
if hasFile(projectDir, "manage.py") { if hasFile(m, projectDir, "manage.py") {
return "python manage.py runserver 0.0.0.0:8000" return "python manage.py runserver 0.0.0.0:8000"
} }

View file

@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -13,7 +14,7 @@ func TestDetectServeCommand_Good_Laravel(t *testing.T) {
err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644) err := os.WriteFile(filepath.Join(tmpDir, "artisan"), []byte("#!/usr/bin/env php"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd)
} }
@ -23,7 +24,7 @@ func TestDetectServeCommand_Good_NodeDev(t *testing.T) {
err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd) assert.Equal(t, "npm run dev -- --host 0.0.0.0", cmd)
} }
@ -33,7 +34,7 @@ func TestDetectServeCommand_Good_NodeStart(t *testing.T) {
err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644) err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(packageJSON), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "npm start", cmd) assert.Equal(t, "npm start", cmd)
} }
@ -42,7 +43,7 @@ func TestDetectServeCommand_Good_PHP(t *testing.T) {
err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "frankenphp php-server -l :8000", cmd) assert.Equal(t, "frankenphp php-server -l :8000", cmd)
} }
@ -53,7 +54,7 @@ func TestDetectServeCommand_Good_GoMain(t *testing.T) {
err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644) err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "go run .", cmd) assert.Equal(t, "go run .", cmd)
} }
@ -63,7 +64,7 @@ func TestDetectServeCommand_Good_GoWithoutMain(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// No main.go, so falls through to fallback // No main.go, so falls through to fallback
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "python3 -m http.server 8000", cmd) assert.Equal(t, "python3 -m http.server 8000", cmd)
} }
@ -72,14 +73,14 @@ func TestDetectServeCommand_Good_Django(t *testing.T) {
err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644) err := os.WriteFile(filepath.Join(tmpDir, "manage.py"), []byte("#!/usr/bin/env python"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd) assert.Equal(t, "python manage.py runserver 0.0.0.0:8000", cmd)
} }
func TestDetectServeCommand_Good_Fallback(t *testing.T) { func TestDetectServeCommand_Good_Fallback(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "python3 -m http.server 8000", cmd) assert.Equal(t, "python3 -m http.server 8000", cmd)
} }
@ -91,7 +92,7 @@ func TestDetectServeCommand_Good_Priority(t *testing.T) {
err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644) err = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"require":{}}`), 0644)
assert.NoError(t, err) assert.NoError(t, err)
cmd := DetectServeCommand(tmpDir) cmd := DetectServeCommand(io.Local, tmpDir)
assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd) assert.Equal(t, "php artisan octane:start --host=0.0.0.0 --port=8000", cmd)
} }
@ -116,13 +117,13 @@ func TestHasFile_Good(t *testing.T) {
err := os.WriteFile(testFile, []byte("content"), 0644) err := os.WriteFile(testFile, []byte("content"), 0644)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, hasFile(tmpDir, "test.txt")) assert.True(t, hasFile(io.Local, tmpDir, "test.txt"))
} }
func TestHasFile_Bad(t *testing.T) { func TestHasFile_Bad(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
assert.False(t, hasFile(tmpDir, "nonexistent.txt")) assert.False(t, hasFile(io.Local, tmpDir, "nonexistent.txt"))
} }
func TestHasFile_Bad_Directory(t *testing.T) { func TestHasFile_Bad_Directory(t *testing.T) {
@ -132,5 +133,5 @@ func TestHasFile_Bad_Directory(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
// hasFile correctly returns false for directories (only true for regular files) // hasFile correctly returns false for directories (only true for regular files)
assert.False(t, hasFile(tmpDir, "subdir")) assert.False(t, hasFile(io.Local, tmpDir, "subdir"))
} }

View file

@ -54,7 +54,7 @@ func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
} }
// Download downloads the image from CDN. // Download downloads the image from CDN.
func (s *CDNSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error { func (s *CDNSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error {
url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName) url := fmt.Sprintf("%s/%s", s.config.CDNURL, s.config.ImageName)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
@ -73,7 +73,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow
} }
// Ensure dest directory exists // Ensure dest directory exists
if err := io.Local.EnsureDir(dest); err != nil { if err := m.EnsureDir(dest); err != nil {
return fmt.Errorf("cdn.Download: %w", err) return fmt.Errorf("cdn.Download: %w", err)
} }

View file

@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -71,7 +72,7 @@ func TestCDNSource_Download_Good(t *testing.T) {
}) })
var progressCalled bool var progressCalled bool
err := src.Download(context.Background(), dest, func(downloaded, total int64) { err := src.Download(context.Background(), io.Local, dest, func(downloaded, total int64) {
progressCalled = true progressCalled = true
}) })
@ -97,7 +98,7 @@ func TestCDNSource_Download_Bad(t *testing.T) {
ImageName: "test.img", ImageName: "test.img",
}) })
err := src.Download(context.Background(), dest, nil) err := src.Download(context.Background(), io.Local, dest, nil)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "HTTP 500") assert.Contains(t, err.Error(), "HTTP 500")
}) })
@ -109,7 +110,7 @@ func TestCDNSource_Download_Bad(t *testing.T) {
ImageName: "test.img", ImageName: "test.img",
}) })
err := src.Download(context.Background(), dest, nil) err := src.Download(context.Background(), io.Local, dest, nil)
assert.Error(t, err) assert.Error(t, err)
}) })
} }
@ -162,7 +163,7 @@ func TestCDNSource_Download_Good_NoProgress(t *testing.T) {
}) })
// nil progress callback should be handled gracefully // nil progress callback should be handled gracefully
err := src.Download(context.Background(), dest, nil) err := src.Download(context.Background(), io.Local, dest, nil)
assert.NoError(t, err) assert.NoError(t, err)
data, err := os.ReadFile(filepath.Join(dest, "test.img")) data, err := os.ReadFile(filepath.Join(dest, "test.img"))
@ -192,7 +193,7 @@ func TestCDNSource_Download_Good_LargeFile(t *testing.T) {
var progressCalls int var progressCalls int
var lastDownloaded int64 var lastDownloaded int64
err := src.Download(context.Background(), dest, func(downloaded, total int64) { err := src.Download(context.Background(), io.Local, dest, func(downloaded, total int64) {
progressCalls++ progressCalls++
lastDownloaded = downloaded lastDownloaded = downloaded
}) })
@ -227,7 +228,7 @@ func TestCDNSource_Download_Bad_HTTPErrorCodes(t *testing.T) {
ImageName: "test.img", ImageName: "test.img",
}) })
err := src.Download(context.Background(), dest, nil) err := src.Download(context.Background(), io.Local, dest, nil)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", tc.statusCode)) assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", tc.statusCode))
}) })
@ -281,7 +282,7 @@ func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) {
ImageName: "test.img", ImageName: "test.img",
}) })
err := src.Download(context.Background(), dest, nil) err := src.Download(context.Background(), io.Local, dest, nil)
assert.NoError(t, err) assert.NoError(t, err)
// Verify nested dir was created // Verify nested dir was created

View file

@ -6,6 +6,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"github.com/host-uk/core/pkg/io"
) )
// GitHubSource downloads images from GitHub Releases. // GitHubSource downloads images from GitHub Releases.
@ -52,7 +54,7 @@ func (s *GitHubSource) LatestVersion(ctx context.Context) (string, error) {
} }
// Download downloads the image from the latest release. // Download downloads the image from the latest release.
func (s *GitHubSource) Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error { func (s *GitHubSource) Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error {
// Get release assets to find our image // Get release assets to find our image
cmd := exec.CommandContext(ctx, "gh", "release", "download", cmd := exec.CommandContext(ctx, "gh", "release", "download",
"-R", s.config.GitHubRepo, "-R", s.config.GitHubRepo,

View file

@ -3,6 +3,8 @@ package sources
import ( import (
"context" "context"
"github.com/host-uk/core/pkg/io"
) )
// ImageSource defines the interface for downloading dev images. // ImageSource defines the interface for downloading dev images.
@ -15,7 +17,7 @@ type ImageSource interface {
LatestVersion(ctx context.Context) (string, error) LatestVersion(ctx context.Context) (string, error)
// Download downloads the image to the destination path. // Download downloads the image to the destination path.
// Reports progress via the callback if provided. // Reports progress via the callback if provided.
Download(ctx context.Context, dest string, progress func(downloaded, total int64)) error Download(ctx context.Context, m io.Medium, dest string, progress func(downloaded, total int64)) error
} }
// SourceConfig holds configuration for a source. // SourceConfig holds configuration for a source.

View file

@ -47,7 +47,7 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions)
if len(opts.Command) > 0 { if len(opts.Command) > 0 {
cmd = strings.Join(opts.Command, " ") cmd = strings.Join(opts.Command, " ")
} else if opts.Name != "" { } else if opts.Name != "" {
cfg, err := LoadTestConfig(projectDir) cfg, err := LoadTestConfig(d.medium, projectDir)
if err != nil { if err != nil {
return err return err
} }
@ -61,7 +61,7 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions)
return fmt.Errorf("test command %q not found in .core/test.yaml", opts.Name) return fmt.Errorf("test command %q not found in .core/test.yaml", opts.Name)
} }
} else { } else {
cmd = DetectTestCommand(projectDir) cmd = DetectTestCommand(d.medium, projectDir)
if cmd == "" { if cmd == "" {
return fmt.Errorf("could not detect test command (create .core/test.yaml)") return fmt.Errorf("could not detect test command (create .core/test.yaml)")
} }
@ -72,39 +72,39 @@ func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions)
} }
// DetectTestCommand auto-detects the test command for a project. // DetectTestCommand auto-detects the test command for a project.
func DetectTestCommand(projectDir string) string { func DetectTestCommand(m io.Medium, projectDir string) string {
// 1. Check .core/test.yaml // 1. Check .core/test.yaml
cfg, err := LoadTestConfig(projectDir) cfg, err := LoadTestConfig(m, projectDir)
if err == nil && cfg.Command != "" { if err == nil && cfg.Command != "" {
return cfg.Command return cfg.Command
} }
// 2. Check composer.json for test script // 2. Check composer.json for test script
if hasFile(projectDir, "composer.json") { if hasFile(m, projectDir, "composer.json") {
if hasComposerScript(projectDir, "test") { if hasComposerScript(m, projectDir, "test") {
return "composer test" return "composer test"
} }
} }
// 3. Check package.json for test script // 3. Check package.json for test script
if hasFile(projectDir, "package.json") { if hasFile(m, projectDir, "package.json") {
if hasPackageScript(projectDir, "test") { if hasPackageScript(m, projectDir, "test") {
return "npm test" return "npm test"
} }
} }
// 4. Check go.mod // 4. Check go.mod
if hasFile(projectDir, "go.mod") { if hasFile(m, projectDir, "go.mod") {
return "go test ./..." return "go test ./..."
} }
// 5. Check pytest // 5. Check pytest
if hasFile(projectDir, "pytest.ini") || hasFile(projectDir, "pyproject.toml") { if hasFile(m, projectDir, "pytest.ini") || hasFile(m, projectDir, "pyproject.toml") {
return "pytest" return "pytest"
} }
// 6. Check Taskfile // 6. Check Taskfile
if hasFile(projectDir, "Taskfile.yaml") || hasFile(projectDir, "Taskfile.yml") { if hasFile(m, projectDir, "Taskfile.yaml") || hasFile(m, projectDir, "Taskfile.yml") {
return "task test" return "task test"
} }
@ -112,14 +112,14 @@ func DetectTestCommand(projectDir string) string {
} }
// LoadTestConfig loads .core/test.yaml. // LoadTestConfig loads .core/test.yaml.
func LoadTestConfig(projectDir string) (*TestConfig, error) { func LoadTestConfig(m io.Medium, projectDir string) (*TestConfig, error) {
path := filepath.Join(projectDir, ".core", "test.yaml") path := filepath.Join(projectDir, ".core", "test.yaml")
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
content, err := io.Local.Read(absPath) content, err := m.Read(absPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -132,23 +132,23 @@ func LoadTestConfig(projectDir string) (*TestConfig, error) {
return &cfg, nil return &cfg, nil
} }
func hasFile(dir, name string) bool { func hasFile(m io.Medium, dir, name string) bool {
path := filepath.Join(dir, name) path := filepath.Join(dir, name)
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return false return false
} }
return io.Local.IsFile(absPath) return m.IsFile(absPath)
} }
func hasPackageScript(projectDir, script string) bool { func hasPackageScript(m io.Medium, projectDir, script string) bool {
path := filepath.Join(projectDir, "package.json") path := filepath.Join(projectDir, "package.json")
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return false return false
} }
content, err := io.Local.Read(absPath) content, err := m.Read(absPath)
if err != nil { if err != nil {
return false return false
} }
@ -164,14 +164,14 @@ func hasPackageScript(projectDir, script string) bool {
return ok return ok
} }
func hasComposerScript(projectDir, script string) bool { func hasComposerScript(m io.Medium, projectDir, script string) bool {
path := filepath.Join(projectDir, "composer.json") path := filepath.Join(projectDir, "composer.json")
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return false return false
} }
content, err := io.Local.Read(absPath) content, err := m.Read(absPath)
if err != nil { if err != nil {
return false return false
} }

View file

@ -4,13 +4,15 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/host-uk/core/pkg/io"
) )
func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "composer test" { if cmd != "composer test" {
t.Errorf("expected 'composer test', got %q", cmd) t.Errorf("expected 'composer test', got %q", cmd)
} }
@ -20,7 +22,7 @@ func TestDetectTestCommand_Good_PackageJSON(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "npm test" { if cmd != "npm test" {
t.Errorf("expected 'npm test', got %q", cmd) t.Errorf("expected 'npm test', got %q", cmd)
} }
@ -30,7 +32,7 @@ func TestDetectTestCommand_Good_GoMod(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "go test ./..." { if cmd != "go test ./..." {
t.Errorf("expected 'go test ./...', got %q", cmd) t.Errorf("expected 'go test ./...', got %q", cmd)
} }
@ -42,7 +44,7 @@ func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) {
_ = os.MkdirAll(coreDir, 0755) _ = os.MkdirAll(coreDir, 0755)
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644) _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "custom-test" { if cmd != "custom-test" {
t.Errorf("expected 'custom-test', got %q", cmd) t.Errorf("expected 'custom-test', got %q", cmd)
} }
@ -52,7 +54,7 @@ func TestDetectTestCommand_Good_Pytest(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "pytest" { if cmd != "pytest" {
t.Errorf("expected 'pytest', got %q", cmd) t.Errorf("expected 'pytest', got %q", cmd)
} }
@ -62,7 +64,7 @@ func TestDetectTestCommand_Good_Taskfile(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "task test" { if cmd != "task test" {
t.Errorf("expected 'task test', got %q", cmd) t.Errorf("expected 'task test', got %q", cmd)
} }
@ -71,7 +73,7 @@ func TestDetectTestCommand_Good_Taskfile(t *testing.T) {
func TestDetectTestCommand_Bad_NoFiles(t *testing.T) { func TestDetectTestCommand_Bad_NoFiles(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "" { if cmd != "" {
t.Errorf("expected empty string, got %q", cmd) t.Errorf("expected empty string, got %q", cmd)
} }
@ -85,7 +87,7 @@ func TestDetectTestCommand_Good_Priority(t *testing.T) {
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644) _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644)
_ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "my-custom-test" { if cmd != "my-custom-test" {
t.Errorf("expected 'my-custom-test' (from .core/test.yaml), got %q", cmd) t.Errorf("expected 'my-custom-test' (from .core/test.yaml), got %q", cmd)
} }
@ -108,7 +110,7 @@ env:
` `
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644) _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644)
cfg, err := LoadTestConfig(tmpDir) cfg, err := LoadTestConfig(io.Local, tmpDir)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -133,7 +135,7 @@ env:
func TestLoadTestConfig_Bad_NotFound(t *testing.T) { func TestLoadTestConfig_Bad_NotFound(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_, err := LoadTestConfig(tmpDir) _, err := LoadTestConfig(io.Local, tmpDir)
if err == nil { if err == nil {
t.Error("expected error for missing config, got nil") t.Error("expected error for missing config, got nil")
} }
@ -143,10 +145,10 @@ func TestHasPackageScript_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644)
if !hasPackageScript(tmpDir, "test") { if !hasPackageScript(io.Local, tmpDir, "test") {
t.Error("expected to find 'test' script") t.Error("expected to find 'test' script")
} }
if !hasPackageScript(tmpDir, "build") { if !hasPackageScript(io.Local, tmpDir, "build") {
t.Error("expected to find 'build' script") t.Error("expected to find 'build' script")
} }
} }
@ -155,7 +157,7 @@ func TestHasPackageScript_Bad_MissingScript(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644)
if hasPackageScript(tmpDir, "test") { if hasPackageScript(io.Local, tmpDir, "test") {
t.Error("expected not to find 'test' script") t.Error("expected not to find 'test' script")
} }
} }
@ -164,7 +166,7 @@ func TestHasComposerScript_Good(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644)
if !hasComposerScript(tmpDir, "test") { if !hasComposerScript(io.Local, tmpDir, "test") {
t.Error("expected to find 'test' script") t.Error("expected to find 'test' script")
} }
} }
@ -173,7 +175,7 @@ func TestHasComposerScript_Bad_MissingScript(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644)
if hasComposerScript(tmpDir, "test") { if hasComposerScript(io.Local, tmpDir, "test") {
t.Error("expected not to find 'test' script") t.Error("expected not to find 'test' script")
} }
} }
@ -229,7 +231,7 @@ func TestDetectTestCommand_Good_TaskfileYml(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yml"), []byte("version: '3'"), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "Taskfile.yml"), []byte("version: '3'"), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "task test" { if cmd != "task test" {
t.Errorf("expected 'task test', got %q", cmd) t.Errorf("expected 'task test', got %q", cmd)
} }
@ -239,7 +241,7 @@ func TestDetectTestCommand_Good_Pyproject(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "pyproject.toml"), []byte("[tool.pytest]"), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "pyproject.toml"), []byte("[tool.pytest]"), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
if cmd != "pytest" { if cmd != "pytest" {
t.Errorf("expected 'pytest', got %q", cmd) t.Errorf("expected 'pytest', got %q", cmd)
} }
@ -248,7 +250,7 @@ func TestDetectTestCommand_Good_Pyproject(t *testing.T) {
func TestHasPackageScript_Bad_NoFile(t *testing.T) { func TestHasPackageScript_Bad_NoFile(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
if hasPackageScript(tmpDir, "test") { if hasPackageScript(io.Local, tmpDir, "test") {
t.Error("expected false for missing package.json") t.Error("expected false for missing package.json")
} }
} }
@ -257,7 +259,7 @@ func TestHasPackageScript_Bad_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`invalid json`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`invalid json`), 0644)
if hasPackageScript(tmpDir, "test") { if hasPackageScript(io.Local, tmpDir, "test") {
t.Error("expected false for invalid JSON") t.Error("expected false for invalid JSON")
} }
} }
@ -266,7 +268,7 @@ func TestHasPackageScript_Bad_NoScripts(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644)
if hasPackageScript(tmpDir, "test") { if hasPackageScript(io.Local, tmpDir, "test") {
t.Error("expected false for missing scripts section") t.Error("expected false for missing scripts section")
} }
} }
@ -274,7 +276,7 @@ func TestHasPackageScript_Bad_NoScripts(t *testing.T) {
func TestHasComposerScript_Bad_NoFile(t *testing.T) { func TestHasComposerScript_Bad_NoFile(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
if hasComposerScript(tmpDir, "test") { if hasComposerScript(io.Local, tmpDir, "test") {
t.Error("expected false for missing composer.json") t.Error("expected false for missing composer.json")
} }
} }
@ -283,7 +285,7 @@ func TestHasComposerScript_Bad_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`invalid json`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`invalid json`), 0644)
if hasComposerScript(tmpDir, "test") { if hasComposerScript(io.Local, tmpDir, "test") {
t.Error("expected false for invalid JSON") t.Error("expected false for invalid JSON")
} }
} }
@ -292,7 +294,7 @@ func TestHasComposerScript_Bad_NoScripts(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644)
if hasComposerScript(tmpDir, "test") { if hasComposerScript(io.Local, tmpDir, "test") {
t.Error("expected false for missing scripts section") t.Error("expected false for missing scripts section")
} }
} }
@ -303,7 +305,7 @@ func TestLoadTestConfig_Bad_InvalidYAML(t *testing.T) {
_ = os.MkdirAll(coreDir, 0755) _ = os.MkdirAll(coreDir, 0755)
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("invalid: yaml: :"), 0644) _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("invalid: yaml: :"), 0644)
_, err := LoadTestConfig(tmpDir) _, err := LoadTestConfig(io.Local, tmpDir)
if err == nil { if err == nil {
t.Error("expected error for invalid YAML") t.Error("expected error for invalid YAML")
} }
@ -315,7 +317,7 @@ func TestLoadTestConfig_Good_MinimalConfig(t *testing.T) {
_ = os.MkdirAll(coreDir, 0755) _ = os.MkdirAll(coreDir, 0755)
_ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("version: 1"), 0644) _ = os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("version: 1"), 0644)
cfg, err := LoadTestConfig(tmpDir) cfg, err := LoadTestConfig(io.Local, tmpDir)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -332,7 +334,7 @@ func TestDetectTestCommand_Good_ComposerWithoutScript(t *testing.T) {
// composer.json without test script should not return composer test // composer.json without test script should not return composer test
_ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"name":"test/pkg"}`), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
// Falls through to empty (no match) // Falls through to empty (no match)
if cmd != "" { if cmd != "" {
t.Errorf("expected empty string, got %q", cmd) t.Errorf("expected empty string, got %q", cmd)
@ -344,7 +346,7 @@ func TestDetectTestCommand_Good_PackageJSONWithoutScript(t *testing.T) {
// package.json without test or dev script // package.json without test or dev script
_ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644) _ = os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644)
cmd := DetectTestCommand(tmpDir) cmd := DetectTestCommand(io.Local, tmpDir)
// Falls through to empty // Falls through to empty
if cmd != "" { if cmd != "" {
t.Errorf("expected empty string, got %q", cmd) t.Errorf("expected empty string, got %q", cmd)