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:
parent
fe5cf71d5e
commit
0664eb2591
2 changed files with 346 additions and 0 deletions
167
pkg/devops/test.go
Normal file
167
pkg/devops/test.go
Normal 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
179
pkg/devops/test_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue