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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 02:14:40 +00:00
parent fe5cf71d5e
commit 0664eb2591
2 changed files with 346 additions and 0 deletions

167
pkg/devops/test.go Normal file
View file

@ -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
}

179
pkg/devops/test_test.go Normal file
View file

@ -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")
}
}