testmark-go

testexec_improved.go

package testmark

import (
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// TestGo runs Go code from a testmark entry with intelligent package resolution.
// This is a convenience function that handles common Go testing scenarios.
func TestGo(t *testing.T, entry *DirEntry) {
	TestGoWithTimeout(t, entry, 30*time.Second)
}

// TestGoWithTimeout runs Go code with a custom timeout.
func TestGoWithTimeout(t *testing.T, entry *DirEntry, timeout time.Duration) {
	config := DefaultExecConfig()
	config.Timeout = timeout
	config.ExecFn = SmartGoExecFunc()
	TestExec(t, entry, config)
}

// SmartGoExecFunc creates an ExecFunc that intelligently handles Go packages.
// It supports:
// - "." for current package
// - "./path" for relative packages
// - "package" for packages in current module
// - "module/package" for full module paths
func SmartGoExecFunc() ExecFunc {
	return func(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")
		}

		// Handle Go-specific commands
		if args[0] == "go" || strings.HasSuffix(args[0], ".go") || isGoPackageReference(args[0]) {
			return runGoCommand(ctx, args, stdin, stdout, stderr)
		}

		// Fall back to regular execution
		return DefaultExecFunc(ctx, args, stdin, stdout, stderr)
	}
}

// isGoPackageReference checks if the argument looks like a Go package reference
func isGoPackageReference(arg string) bool {
	// Explicit Go references
	if arg == "." || strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, "../") {
		return true
	}

	// Module paths (contain slash)
	if strings.Contains(arg, "/") && !strings.Contains(arg, " ") {
		return true
	}

	// Avoid common system commands
	commonCommands := []string{"echo", "ls", "cat", "grep", "awk", "sed", "sort", "uniq", "wc", "head", "tail", "curl", "wget"}
	for _, cmd := range commonCommands {
		if arg == cmd {
			return false
		}
	}

	// .go files are Go references
	if strings.HasSuffix(arg, ".go") {
		return true
	}

	// Simple names that look like Go packages (no spaces, no common extensions)
	return !strings.Contains(arg, " ") && !strings.HasSuffix(arg, ".exe") &&
		!strings.HasSuffix(arg, ".sh") && !strings.HasSuffix(arg, ".py") &&
		!strings.HasSuffix(arg, ".js") && len(arg) > 0 &&
		!strings.Contains(arg, "=") // avoid env vars
}

// runGoCommand handles Go-specific command execution with smart package resolution
func runGoCommand(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
	// If it's already a "go" command, run it as-is
	if args[0] == "go" {
		return DefaultExecFunc(ctx, args, stdin, stdout, stderr)
	}

	// Handle .go files
	if strings.HasSuffix(args[0], ".go") {
		goArgs := []string{"go", "run"}
		goArgs = append(goArgs, args...)
		return DefaultExecFunc(ctx, goArgs, stdin, stdout, stderr)
	}

	// Handle package references
	pkgRef := args[0]
	resolvedPkg, err := resolvePackageReference(pkgRef)
	if err != nil {
		return 1, fmt.Errorf("failed to resolve package %q: %w", pkgRef, err)
	}

	goArgs := []string{"go", "run", resolvedPkg}
	if len(args) > 1 {
		goArgs = append(goArgs, args[1:]...) // Add remaining args as program arguments
	}

	return DefaultExecFunc(ctx, goArgs, stdin, stdout, stderr)
}

// resolvePackageReference converts various package reference formats to something `go run` can use
func resolvePackageReference(pkgRef string) (string, error) {
	switch {
	case pkgRef == ".":
		// Current package
		return ".", nil

	case strings.HasPrefix(pkgRef, "./") || strings.HasPrefix(pkgRef, "../"):
		// Relative path - use as-is
		return pkgRef, nil

	case strings.Contains(pkgRef, "/"):
		// Already a module/package path - use as-is
		return pkgRef, nil

	default:
		// Simple package name - try to resolve within current module
		return resolveLocalPackage(pkgRef)
	}
}

// resolveLocalPackage tries to find a package within the current module
func resolveLocalPackage(pkgName string) (string, error) {
	// Try to find the module root
	moduleRoot, err := findModuleRoot()
	if err != nil {
		// If we can't find module root, assume it's a relative package
		return fmt.Sprintf("./%s", pkgName), nil
	}

	// Look for the package directory
	pkgPath := filepath.Join(moduleRoot, pkgName)
	if _, err := os.Stat(pkgPath); err == nil {
		return fmt.Sprintf("./%s", pkgName), nil
	}

	// If not found, assume it's an external package
	return pkgName, nil
}

// findModuleRoot walks up the directory tree looking for go.mod
func findModuleRoot() (string, error) {
	dir, err := os.Getwd()
	if err != nil {
		return "", err
	}

	for {
		if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
			return dir, nil
		}

		parent := filepath.Dir(dir)
		if parent == dir {
			// Reached root directory
			break
		}
		dir = parent
	}

	return "", fmt.Errorf("go.mod not found")
}

// Convenience functions for common testing scenarios

// TestGoPackage tests a Go package with default settings.
// Supports "." for current package, relative paths, and module paths.
func TestGoPackage(t *testing.T, packageRef string) {
	// Create a minimal testmark entry for the package
	entry := &DirEntry{
		name:     "go-test",
		path:     "go-test",
		children: make(map[string]*DirEntry),
		childrenOrdered: []*DirEntry{
			{
				name: "sequence",
				path: "go-test/sequence",
				hunk: &Hunk{
					name: "go-test/sequence",
					body: []byte(packageRef + "\n"),
				},
			},
		},
	}
	entry.children["sequence"] = entry.childrenOrdered[0]

	TestGo(t, entry)
}

// TestGoFile tests a single Go file with default settings.
func TestGoFile(t *testing.T, filename string, args ...string) {
	cmdLine := filename
	if len(args) > 0 {
		cmdLine += " " + strings.Join(args, " ")
	}

	entry := &DirEntry{
		name:     "go-file-test",
		path:     "go-file-test",
		children: make(map[string]*DirEntry),
		childrenOrdered: []*DirEntry{
			{
				name: "sequence",
				path: "go-file-test/sequence",
				hunk: &Hunk{
					name: "go-file-test/sequence",
					body: []byte(cmdLine + "\n"),
				},
			},
		},
	}
	entry.children["sequence"] = entry.childrenOrdered[0]

	TestGo(t, entry)
}

// TestGoScript runs Go code from a string with default settings.
func TestGoScript(t *testing.T, goCode string) {
	entry := &DirEntry{
		name:     "go-script-test",
		path:     "go-script-test",
		children: make(map[string]*DirEntry),
		childrenOrdered: []*DirEntry{
			{
				name: "script",
				path: "go-script-test/script",
				hunk: &Hunk{
					name: "go-script-test/script",
					body: []byte(goCode + "\n"),
				},
			},
		},
	}
	entry.children["script"] = entry.childrenOrdered[0]

	TestGo(t, entry)
}

v2/testexec_improved.go · 6.5 KiB · 4d0e1b6afd