testmark-go

testexec_basic.go

package testmark

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"testing"
	"time"
)

// ExecFunc executes a command with given arguments.
type ExecFunc func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (exitcode int, err error)

// ExecConfig configures test execution behavior.
type ExecConfig struct {
	// ExecFn customizes how commands are executed
	ExecFn ExecFunc

	// Timeout for command execution
	Timeout time.Duration

	// Env sets environment variables
	Env []string
}

// DefaultExecConfig returns a configuration with sensible defaults.
func DefaultExecConfig() *ExecConfig {
	return &ExecConfig{
		ExecFn:  DefaultExecFunc,
		Timeout: 30 * time.Second,
	}
}

// DefaultExecFunc executes commands using os/exec.
func DefaultExecFunc(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
	if len(args) == 0 {
		return 1, fmt.Errorf("no command specified")
	}

	cmd := exec.CommandContext(ctx, args[0], args[1:]...)
	cmd.Stdin = stdin
	cmd.Stdout = stdout
	cmd.Stderr = stderr

	err := cmd.Run()
	if err != nil {
		if exitError, ok := err.(*exec.ExitError); ok {
			return exitError.ExitCode(), nil
		}
		return 1, err
	}
	return 0, nil
}

// TestExec tests execution of commands or scripts from a testmark entry.
func TestExec(t *testing.T, entry *DirEntry, config *ExecConfig) {
	t.Helper()

	if config == nil {
		config = DefaultExecConfig()
	}

	// Check what type of execution this is
	sequenceEntry := entry.Child("sequence")
	scriptEntry := entry.Child("script")

	if sequenceEntry == nil && scriptEntry == nil {
		t.Fatalf("entry %q must contain either 'sequence' or 'script' hunk", entry.Path())
	}
	if sequenceEntry != nil && scriptEntry != nil {
		t.Fatalf("entry %q cannot contain both 'sequence' and 'script' hunks", entry.Path())
	}

	// Set up execution context
	ctx := context.Background()
	if config.Timeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, config.Timeout)
		defer cancel()
	}

	// Set up working directory
	origDir, err := os.Getwd()
	if err != nil {
		t.Fatalf("failed to get working directory: %v", err)
	}
	defer os.Chdir(origDir)

	// Create temp directory
	workDir, err := os.MkdirTemp("", "testmark-exec")
	if err != nil {
		t.Fatalf("failed to create temp directory: %v", err)
	}
	defer os.RemoveAll(workDir)

	if err := os.Chdir(workDir); err != nil {
		t.Fatalf("failed to change to working directory: %v", err)
	}

	// Set up files from fs/ entry
	if fsEntry := entry.Child("fs"); fsEntry != nil {
		if err := createFiles(fsEntry, "."); err != nil {
			t.Fatalf("failed to create files: %v", err)
		}
	}

	// Prepare I/O
	var stdin io.Reader = strings.NewReader("")
	if inputEntry := entry.Child("input"); inputEntry != nil {
		stdin = bytes.NewReader(inputEntry.Hunk().Body())
	}

	var stdout, stderr bytes.Buffer
	var combinedOutput *bytes.Buffer

	if entry.Child("output") != nil {
		// Combined output mode
		combinedOutput = &bytes.Buffer{}
		stdout = *combinedOutput
		stderr = *combinedOutput
		if entry.Child("stdout") != nil || entry.Child("stderr") != nil {
			t.Errorf("cannot use 'output' with 'stdout' or 'stderr'")
		}
	} else {
		// Separate stdout/stderr
		stdout = bytes.Buffer{}
		stderr = bytes.Buffer{}
	}

	// Execute the command(s)
	var exitCode int
	if sequenceEntry != nil {
		exitCode = runSequence(ctx, t, sequenceEntry, config, stdin, &stdout, &stderr)
	} else {
		exitCode = runScript(ctx, t, scriptEntry, config, stdin, &stdout, &stderr)
	}

	// Check results
	checkOutput := func(name string, actual []byte, expected *DirEntry) {
		if expected == nil {
			return
		}

		actualStr := string(actual)
		expectedStr := expected.Hunk().String()

		// Handle regeneration
		if ShouldRegen() {
			t.Logf("would regenerate %s hunk with: %q", name, actualStr)
		} else {
			t.Run("check-"+name, func(t *testing.T) {
				if actualStr != expectedStr {
					t.Errorf("%s mismatch:\nexpected: %q\nactual:   %q", name, expectedStr, actualStr)
				}
			})
		}
	}

	if combinedOutput != nil {
		checkOutput("output", combinedOutput.Bytes(), entry.Child("output"))
	} else {
		checkOutput("stdout", stdout.Bytes(), entry.Child("stdout"))
		checkOutput("stderr", stderr.Bytes(), entry.Child("stderr"))
	}

	// Check exit code
	t.Run("check-exitcode", func(t *testing.T) {
		expectedCode := 0
		if exitEntry := entry.Child("exitcode"); exitEntry != nil {
			if code, err := strconv.Atoi(strings.TrimSpace(exitEntry.Hunk().String())); err == nil {
				expectedCode = code
			}
		}

		if ShouldRegen() {
			t.Logf("would regenerate exitcode hunk with: %d", exitCode)
		} else {
			if exitCode != expectedCode {
				t.Errorf("exit code mismatch: expected %d, got %d", expectedCode, exitCode)
			}
		}
	})

	// Run subtests
	for iter := entry.Children(); iter.Next(); {
		child := iter.Entry()
		if strings.HasPrefix(child.Name(), "then-") {
			subtestName := strings.TrimPrefix(child.Name(), "then-")
			t.Run(subtestName, func(t *testing.T) {
				if t.Failed() {
					t.Skip("parent test failed")
				}
				TestExec(t, child, config)
			})
		}
	}
}

// runSequence executes a sequence of commands
func runSequence(ctx context.Context, t *testing.T, entry *DirEntry, config *ExecConfig, stdin io.Reader, stdout, stderr io.Writer) int {
	lines := strings.Split(entry.Hunk().String(), "\n")
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}

		args := strings.Fields(line)
		if len(args) == 0 {
			continue
		}

		exitCode, err := config.ExecFn(ctx, args, stdin, stdout, stderr)
		if err != nil {
			t.Fatalf("command execution failed: %v", err)
		}
		if exitCode != 0 {
			return exitCode
		}
	}
	return 0
}

// runScript executes a script
func runScript(ctx context.Context, t *testing.T, entry *DirEntry, config *ExecConfig, stdin io.Reader, stdout, stderr io.Writer) int {
	// For scripts, we'll use the shell
	var shell, shellArg string
	if runtime.GOOS == "windows" {
		shell = "cmd"
		shellArg = "/c"
	} else {
		shell = "/bin/sh"
		shellArg = "-c"
	}

	exitCode, err := config.ExecFn(ctx, []string{shell, shellArg, entry.Hunk().String()}, stdin, stdout, stderr)
	if err != nil {
		t.Fatalf("script execution failed: %v", err)
	}
	return exitCode
}

// createFiles recursively creates files from a directory entry
func createFiles(entry *DirEntry, basePath string) error {
	if entry.IsFile() {
		return os.WriteFile(basePath, entry.Hunk().Body(), 0644)
	}

	if entry.IsDir() {
		if err := os.MkdirAll(basePath, 0755); err != nil {
			return err
		}
		for iter := entry.Children(); iter.Next(); {
			child := iter.Entry()
			childPath := filepath.Join(basePath, child.Name())
			if err := createFiles(child, childPath); err != nil {
				return err
			}
		}
	}

	return nil
}

v2/testexec_basic.go · 6.7 KiB · 07c6e4c9e7