From 0664eb2591db5e35f51928cc583ef48314423160 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 02:14:40 +0000 Subject: [PATCH] feat(devops): add test detection and execution Auto-detects test framework from project files. Supports .core/test.yaml for custom configuration. Co-Authored-By: Claude Opus 4.5 --- pkg/devops/test.go | 167 +++++++++++++++++++++++++++++++++++++ pkg/devops/test_test.go | 179 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 pkg/devops/test.go create mode 100644 pkg/devops/test_test.go diff --git a/pkg/devops/test.go b/pkg/devops/test.go new file mode 100644 index 0000000..d5116cd --- /dev/null +++ b/pkg/devops/test.go @@ -0,0 +1,167 @@ +package devops + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// TestConfig holds test configuration from .core/test.yaml. +type TestConfig struct { + Version int `yaml:"version"` + Command string `yaml:"command,omitempty"` + Commands []TestCommand `yaml:"commands,omitempty"` + Env map[string]string `yaml:"env,omitempty"` +} + +// TestCommand is a named test command. +type TestCommand struct { + Name string `yaml:"name"` + Run string `yaml:"run"` +} + +// TestOptions configures test execution. +type TestOptions struct { + Name string // Run specific named command from .core/test.yaml + Command []string // Override command (from -- args) +} + +// Test runs tests in the dev environment. +func (d *DevOps) Test(ctx context.Context, projectDir string, opts TestOptions) error { + running, err := d.IsRunning(ctx) + if err != nil { + return err + } + if !running { + return fmt.Errorf("dev environment not running (run 'core dev boot' first)") + } + + var cmd string + + // Priority: explicit command > named command > auto-detect + if len(opts.Command) > 0 { + cmd = strings.Join(opts.Command, " ") + } else if opts.Name != "" { + cfg, err := LoadTestConfig(projectDir) + if err != nil { + return err + } + for _, c := range cfg.Commands { + if c.Name == opts.Name { + cmd = c.Run + break + } + } + if cmd == "" { + return fmt.Errorf("test command %q not found in .core/test.yaml", opts.Name) + } + } else { + cmd = DetectTestCommand(projectDir) + if cmd == "" { + return fmt.Errorf("could not detect test command (create .core/test.yaml)") + } + } + + // Run via SSH - construct command as single string for shell execution + return d.sshShell(ctx, []string{"cd", "/app", "&&", cmd}) +} + +// DetectTestCommand auto-detects the test command for a project. +func DetectTestCommand(projectDir string) string { + // 1. Check .core/test.yaml + cfg, err := LoadTestConfig(projectDir) + if err == nil && cfg.Command != "" { + return cfg.Command + } + + // 2. Check composer.json for test script + if hasFile(projectDir, "composer.json") { + if hasComposerScript(projectDir, "test") { + return "composer test" + } + } + + // 3. Check package.json for test script + if hasFile(projectDir, "package.json") { + if hasPackageScript(projectDir, "test") { + return "npm test" + } + } + + // 4. Check go.mod + if hasFile(projectDir, "go.mod") { + return "go test ./..." + } + + // 5. Check pytest + if hasFile(projectDir, "pytest.ini") || hasFile(projectDir, "pyproject.toml") { + return "pytest" + } + + // 6. Check Taskfile + if hasFile(projectDir, "Taskfile.yaml") || hasFile(projectDir, "Taskfile.yml") { + return "task test" + } + + return "" +} + +// LoadTestConfig loads .core/test.yaml. +func LoadTestConfig(projectDir string) (*TestConfig, error) { + path := filepath.Join(projectDir, ".core", "test.yaml") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg TestConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +func hasFile(dir, name string) bool { + _, err := os.Stat(filepath.Join(dir, name)) + return err == nil +} + +func hasPackageScript(projectDir, script string) bool { + data, err := os.ReadFile(filepath.Join(projectDir, "package.json")) + if err != nil { + return false + } + + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return false + } + + _, ok := pkg.Scripts[script] + return ok +} + +func hasComposerScript(projectDir, script string) bool { + data, err := os.ReadFile(filepath.Join(projectDir, "composer.json")) + if err != nil { + return false + } + + var pkg struct { + Scripts map[string]interface{} `json:"scripts"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return false + } + + _, ok := pkg.Scripts[script] + return ok +} diff --git a/pkg/devops/test_test.go b/pkg/devops/test_test.go new file mode 100644 index 0000000..9cc565a --- /dev/null +++ b/pkg/devops/test_test.go @@ -0,0 +1,179 @@ +package devops + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectTestCommand_Good_ComposerJSON(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest"}}`), 0644) + + cmd := DetectTestCommand(tmpDir) + if cmd != "composer test" { + t.Errorf("expected 'composer test', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_PackageJSON(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"vitest"}}`), 0644) + + cmd := DetectTestCommand(tmpDir) + if cmd != "npm test" { + t.Errorf("expected 'npm test', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_GoMod(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + + cmd := DetectTestCommand(tmpDir) + if cmd != "go test ./..." { + t.Errorf("expected 'go test ./...', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_CoreTestYaml(t *testing.T) { + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + os.MkdirAll(coreDir, 0755) + os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: custom-test"), 0644) + + cmd := DetectTestCommand(tmpDir) + if cmd != "custom-test" { + t.Errorf("expected 'custom-test', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_Pytest(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "pytest.ini"), []byte("[pytest]"), 0644) + + cmd := DetectTestCommand(tmpDir) + if cmd != "pytest" { + t.Errorf("expected 'pytest', got %q", cmd) + } +} + +func TestDetectTestCommand_Good_Taskfile(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "Taskfile.yaml"), []byte("version: '3'"), 0644) + + cmd := DetectTestCommand(tmpDir) + if cmd != "task test" { + t.Errorf("expected 'task test', got %q", cmd) + } +} + +func TestDetectTestCommand_Bad_NoFiles(t *testing.T) { + tmpDir := t.TempDir() + + cmd := DetectTestCommand(tmpDir) + if cmd != "" { + t.Errorf("expected empty string, got %q", cmd) + } +} + +func TestDetectTestCommand_Good_Priority(t *testing.T) { + // .core/test.yaml should take priority over other detection methods + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + os.MkdirAll(coreDir, 0755) + os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte("command: my-custom-test"), 0644) + os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module example"), 0644) + + cmd := DetectTestCommand(tmpDir) + if cmd != "my-custom-test" { + t.Errorf("expected 'my-custom-test' (from .core/test.yaml), got %q", cmd) + } +} + +func TestLoadTestConfig_Good(t *testing.T) { + tmpDir := t.TempDir() + coreDir := filepath.Join(tmpDir, ".core") + os.MkdirAll(coreDir, 0755) + + configYAML := `version: 1 +command: default-test +commands: + - name: unit + run: go test ./... + - name: integration + run: go test -tags=integration ./... +env: + CI: "true" +` + os.WriteFile(filepath.Join(coreDir, "test.yaml"), []byte(configYAML), 0644) + + cfg, err := LoadTestConfig(tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Version != 1 { + t.Errorf("expected version 1, got %d", cfg.Version) + } + if cfg.Command != "default-test" { + t.Errorf("expected command 'default-test', got %q", cfg.Command) + } + if len(cfg.Commands) != 2 { + t.Errorf("expected 2 commands, got %d", len(cfg.Commands)) + } + if cfg.Commands[0].Name != "unit" { + t.Errorf("expected first command name 'unit', got %q", cfg.Commands[0].Name) + } + if cfg.Env["CI"] != "true" { + t.Errorf("expected env CI='true', got %q", cfg.Env["CI"]) + } +} + +func TestLoadTestConfig_Bad_NotFound(t *testing.T) { + tmpDir := t.TempDir() + + _, err := LoadTestConfig(tmpDir) + if err == nil { + t.Error("expected error for missing config, got nil") + } +} + +func TestHasPackageScript_Good(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"test":"jest","build":"webpack"}}`), 0644) + + if !hasPackageScript(tmpDir, "test") { + t.Error("expected to find 'test' script") + } + if !hasPackageScript(tmpDir, "build") { + t.Error("expected to find 'build' script") + } +} + +func TestHasPackageScript_Bad_MissingScript(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"scripts":{"build":"webpack"}}`), 0644) + + if hasPackageScript(tmpDir, "test") { + t.Error("expected not to find 'test' script") + } +} + +func TestHasComposerScript_Good(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"test":"pest","post-install-cmd":"@php artisan migrate"}}`), 0644) + + if !hasComposerScript(tmpDir, "test") { + t.Error("expected to find 'test' script") + } +} + +func TestHasComposerScript_Bad_MissingScript(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{"scripts":{"build":"@php build.php"}}`), 0644) + + if hasComposerScript(tmpDir, "test") { + t.Error("expected not to find 'test' script") + } +}