testexec.go
// Package testexec provides support for testing command execution from testmark documents.
//
// This package supports three types of execution testing:
//
// 1. System commands: Run any command with system execution (potentially non-portable)
// 2. Go programs: Compile and run Go programs from your module (portable if your code is)
// 3. Go snippets: Synthesize runnable Go programs from code snippets (see subpackage doctest)
//
// Basic usage:
//
// testexec.Test(t, entry) // Auto-detect execution type
// testexec.TestWithConfig(t, entry, config) // Custom configuration
package testexec
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/warpfork/go-testmark/v2"
)
// 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)
// Config configures test execution behavior.
type Config struct {
// ExecFn customizes how commands are executed
ExecFn ExecFunc
// Timeout for command execution
Timeout time.Duration
// Env sets environment variables
Env []string
}
// DefaultConfig returns a configuration with sensible defaults.
func DefaultConfig() *Config {
return &Config{
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
}
// Test tests execution of commands or scripts from a testmark entry.
func Test(t *testing.T, entry *testmark.DirEntry, config *Config) {
t.Helper()
if config == nil {
config = DefaultConfig()
}
// 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 *testmark.DirEntry) {
if expected == nil {
return
}
actualStr := string(actual)
expectedStr := expected.Hunk().String()
// Handle regeneration
if testmark.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 testmark.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
iter := entry.Children()
for child, ok := iter.Next(); ok; child, ok = iter.Next() {
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")
}
Test(t, child, config)
})
}
}
}
// runSequence executes a sequence of commands
func runSequence(ctx context.Context, t *testing.T, entry *testmark.DirEntry, config *Config, 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 *testmark.DirEntry, config *Config, 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 *testmark.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
}
iter := entry.Children()
for child, ok := iter.Next(); ok; child, ok = iter.Next() {
childPath := filepath.Join(basePath, child.Name())
if err := createFiles(child, childPath); err != nil {
return err
}
}
}
return nil
}
// TestWithConfig is an alias for Test with explicit config (for clarity).
func TestWithConfig(t *testing.T, entry *testmark.DirEntry, config *Config) {
Test(t, entry, config)
}
v2/testexec/testexec.go · 7.5 KiB · 93c0df2a6e