Add shout shell output tester initial implementation
This commit is contained in:
parent
77d3fb2a55
commit
57fba7acb0
15
Makefile
Normal file
15
Makefile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.PHONY: build test unit integration clean
|
||||
|
||||
build:
|
||||
go build -o shout .
|
||||
|
||||
test: unit integration
|
||||
|
||||
unit:
|
||||
go test ./...
|
||||
|
||||
integration: build
|
||||
./shout test test/
|
||||
|
||||
clean:
|
||||
rm -f shout
|
||||
361
cmd.go
Normal file
361
cmd.go
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "shout",
|
||||
Short: "$ shell output tester",
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(testCmd())
|
||||
rootCmd.AddCommand(versionCmd())
|
||||
rootCmd.AddCommand(exampleCmd())
|
||||
}
|
||||
|
||||
func testCmd() *cobra.Command {
|
||||
var (
|
||||
update bool
|
||||
keep bool
|
||||
cleanEnv bool
|
||||
pathDirs []string
|
||||
timeout string
|
||||
verbose bool
|
||||
portFrom int
|
||||
parallel bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "test [files...]",
|
||||
Short: "Run .shout test files",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
timeoutDur, err := parseDuration(timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paths := args
|
||||
if len(paths) == 0 {
|
||||
paths = []string{"."}
|
||||
}
|
||||
|
||||
files, err := findShoutFiles(paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No .shout files found")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var results []TestResult
|
||||
cwd, _ := os.Getwd()
|
||||
nextPort := portFrom
|
||||
|
||||
runOne := func(filePath string, port int) TestResult {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
relPath, _ := filepath.Rel(cwd, filePath)
|
||||
return TestResult{Path: relPath, Error: err.Error()}
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(cwd, filePath)
|
||||
parsed, err := parse(relPath, string(content))
|
||||
if err != nil {
|
||||
return TestResult{Path: relPath, Error: err.Error()}
|
||||
}
|
||||
|
||||
// Resolve directives
|
||||
envVars := make(map[string]string)
|
||||
setupEnvVars := make(map[string]string)
|
||||
userEnvVars := make(map[string]string)
|
||||
var setupCommands []Command
|
||||
|
||||
for _, d := range parsed.Directives {
|
||||
switch d.Type {
|
||||
case "setup":
|
||||
setupPath := filepath.Join(filepath.Dir(filePath), d.Path)
|
||||
setupContent, err := os.ReadFile(setupPath)
|
||||
if err != nil {
|
||||
return TestResult{Path: parsed.Path, Error: err.Error()}
|
||||
}
|
||||
setupRelPath, _ := filepath.Rel(cwd, setupPath)
|
||||
setupParsed, err := parse(setupRelPath, string(setupContent))
|
||||
if err != nil {
|
||||
return TestResult{Path: parsed.Path, Error: err.Error()}
|
||||
}
|
||||
for _, sd := range setupParsed.Directives {
|
||||
if sd.Type == "setup" {
|
||||
return TestResult{
|
||||
Path: parsed.Path,
|
||||
Error: fmt.Sprintf("%s: @setup not allowed in setup files", setupRelPath),
|
||||
}
|
||||
}
|
||||
if sd.Type == "env" {
|
||||
setupEnvVars[sd.Key] = sd.Value
|
||||
}
|
||||
}
|
||||
setupCommands = append(setupCommands, setupParsed.Commands...)
|
||||
|
||||
case "env":
|
||||
userEnvVars[d.Key] = d.Value
|
||||
}
|
||||
}
|
||||
|
||||
// Setup env < user env
|
||||
for k, v := range setupEnvVars {
|
||||
envVars[k] = v
|
||||
}
|
||||
for k, v := range userEnvVars {
|
||||
envVars[k] = v
|
||||
}
|
||||
if port > 0 {
|
||||
if _, ok := userEnvVars["PORT"]; !ok {
|
||||
if _, ok := setupEnvVars["PORT"]; !ok {
|
||||
envVars["PORT"] = strconv.Itoa(port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged := ShoutFile{
|
||||
Path: parsed.Path,
|
||||
Commands: append(setupCommands, parsed.Commands...),
|
||||
Directives: parsed.Directives,
|
||||
}
|
||||
|
||||
var onCommand func(Command)
|
||||
if verbose {
|
||||
onCommand = func(c Command) {
|
||||
fmt.Fprintf(os.Stderr, dim(" $ %s\n"), c.Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
fileResult := runFile(merged, RunOptions{
|
||||
CleanEnv: cleanEnv,
|
||||
PathDirs: pathDirs,
|
||||
EnvVars: envVars,
|
||||
Timeout: timeoutDur,
|
||||
Verbose: verbose,
|
||||
OnCommand: onCommand,
|
||||
})
|
||||
|
||||
// Check setup commands for failures
|
||||
for i := 0; i < len(setupCommands) && i < len(fileResult.Results); i++ {
|
||||
r := fileResult.Results[i]
|
||||
sc := setupCommands[i]
|
||||
ok := false
|
||||
switch sc.ExitCodeType {
|
||||
case ExitCodeNone:
|
||||
ok = r.ExitCode == 0
|
||||
case ExitCodeWildcard:
|
||||
ok = r.ExitCode != 0
|
||||
case ExitCodeExact:
|
||||
ok = r.ExitCode == sc.ExitCodeValue
|
||||
}
|
||||
if !ok {
|
||||
if keep {
|
||||
fmt.Fprintln(os.Stderr, fileResult.TmpDir)
|
||||
} else {
|
||||
cleanupTmpDir(fileResult.TmpDir)
|
||||
}
|
||||
return evaluateFile(
|
||||
parsed.Path,
|
||||
nil,
|
||||
fmt.Sprintf("setup command failed (exit %d): $ %s", r.ExitCode, sc.Cmd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fileOwnResults := fileResult.Results
|
||||
if len(setupCommands) > 0 && len(fileResult.Results) >= len(setupCommands) {
|
||||
fileOwnResults = fileResult.Results[len(setupCommands):]
|
||||
}
|
||||
|
||||
testResult := evaluateFile(parsed.Path, fileOwnResults, fileResult.Error)
|
||||
|
||||
if update && len(fileOwnResults) > 0 {
|
||||
updated := rewriteFile(parsed, fileOwnResults, string(content))
|
||||
if updated != string(content) {
|
||||
_ = os.WriteFile(filePath, []byte(updated), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
if keep {
|
||||
fmt.Fprintln(os.Stderr, fileResult.TmpDir)
|
||||
} else {
|
||||
cleanupTmpDir(fileResult.TmpDir)
|
||||
}
|
||||
|
||||
return testResult
|
||||
}
|
||||
|
||||
printDots := func(r TestResult) {
|
||||
if r.Error != "" {
|
||||
fmt.Print(red("F"))
|
||||
return
|
||||
}
|
||||
passed := r.CommandCount - len(r.Failures)
|
||||
for i := 0; i < passed; i++ {
|
||||
fmt.Print(green("."))
|
||||
}
|
||||
for i := 0; i < len(r.Failures); i++ {
|
||||
fmt.Print(red("F"))
|
||||
}
|
||||
}
|
||||
|
||||
if parallel {
|
||||
allResults := make([]TestResult, len(files))
|
||||
var wg sync.WaitGroup
|
||||
for idx, f := range files {
|
||||
wg.Add(1)
|
||||
go func(i int, filePath string, port int) {
|
||||
defer wg.Done()
|
||||
allResults[i] = runOne(filePath, port)
|
||||
}(idx, f, nextPort)
|
||||
if nextPort > 0 {
|
||||
nextPort++
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
for _, r := range allResults {
|
||||
printDots(r)
|
||||
results = append(results, r)
|
||||
}
|
||||
fmt.Println()
|
||||
} else {
|
||||
for _, filePath := range files {
|
||||
port := 0
|
||||
if nextPort > 0 {
|
||||
port = nextPort
|
||||
nextPort++
|
||||
}
|
||||
r := runOne(filePath, port)
|
||||
printDots(r)
|
||||
results = append(results, r)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Print failures
|
||||
var failures []TestResult
|
||||
for _, r := range results {
|
||||
if !r.Passed {
|
||||
failures = append(failures, r)
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
fmt.Println()
|
||||
for _, f := range failures {
|
||||
fmt.Println(formatFailure(f))
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
fmt.Println(formatSummary(results, elapsed))
|
||||
|
||||
if len(failures) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&update, "update", "u", false, "Rewrite expected output in-place with actual output")
|
||||
cmd.Flags().BoolVarP(&keep, "keep", "k", false, "Keep temp directories after run")
|
||||
cmd.Flags().BoolVar(&cleanEnv, "clean-env", false, "Start with empty environment")
|
||||
cmd.Flags().StringArrayVar(&pathDirs, "path", nil, "Prepend <path> to PATH (repeatable)")
|
||||
cmd.Flags().StringVar(&timeout, "timeout", "10s", "Per-command timeout")
|
||||
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Print each command as it runs")
|
||||
cmd.Flags().IntVar(&portFrom, "port-from", 0, "Auto-assign $PORT starting from <n>")
|
||||
cmd.Flags().BoolVar(¶llel, "parallel", false, "Run files in parallel")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println(version)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func exampleCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "example",
|
||||
Short: "Print an example .shout file",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Print(`# Example .shout file
|
||||
$ echo hello
|
||||
hello
|
||||
|
||||
$ echo "one"; echo "two"; echo "three"
|
||||
one
|
||||
...
|
||||
three
|
||||
|
||||
$ cat nonexistent
|
||||
cat: nonexistent: ...
|
||||
[1]
|
||||
|
||||
$ true
|
||||
[0]
|
||||
`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func findShoutFiles(paths []string) ([]string, error) {
|
||||
var files []string
|
||||
|
||||
for _, p := range paths {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
if strings.HasSuffix(abs, ".shout") {
|
||||
files = append(files, abs)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
_ = filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() && strings.HasSuffix(path, ".shout") {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else if strings.HasSuffix(abs, ".shout") {
|
||||
files = append(files, abs)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
40
color.go
Normal file
40
color.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
var colorEnabled = detectColor()
|
||||
|
||||
func detectColor() bool {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("TERM") == "dumb" {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
func red(s string) string {
|
||||
if !colorEnabled {
|
||||
return s
|
||||
}
|
||||
return "\033[31m" + s + "\033[0m"
|
||||
}
|
||||
|
||||
func green(s string) string {
|
||||
if !colorEnabled {
|
||||
return s
|
||||
}
|
||||
return "\033[32m" + s + "\033[0m"
|
||||
}
|
||||
|
||||
func dim(s string) string {
|
||||
if !colorEnabled {
|
||||
return s
|
||||
}
|
||||
return "\033[2m" + s + "\033[0m"
|
||||
}
|
||||
29
duration.go
Normal file
29
duration.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var durationRe = regexp.MustCompile(`^(\d+(?:\.\d+)?)(ms|s|m)$`)
|
||||
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
m := durationRe.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||
}
|
||||
|
||||
value, _ := strconv.ParseFloat(m[1], 64)
|
||||
switch m[2] {
|
||||
case "ms":
|
||||
return time.Duration(value * float64(time.Millisecond)), nil
|
||||
case "s":
|
||||
return time.Duration(value * float64(time.Second)), nil
|
||||
case "m":
|
||||
return time.Duration(value * float64(time.Minute)), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown unit: %s", m[2])
|
||||
}
|
||||
}
|
||||
60
duration_test.go
Normal file
60
duration_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseDurationMs(t *testing.T) {
|
||||
d, err := parseDuration("500ms")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d != 500*time.Millisecond {
|
||||
t.Errorf("got %v, want 500ms", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationSeconds(t *testing.T) {
|
||||
d, err := parseDuration("10s")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d != 10*time.Second {
|
||||
t.Errorf("got %v, want 10s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationDecimal(t *testing.T) {
|
||||
d, err := parseDuration("1.5s")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d != 1500*time.Millisecond {
|
||||
t.Errorf("got %v, want 1.5s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationMinutes(t *testing.T) {
|
||||
d, err := parseDuration("1m")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d != time.Minute {
|
||||
t.Errorf("got %v, want 1m", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationInvalid(t *testing.T) {
|
||||
_, err := parseDuration("abc")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDurationNoUnit(t *testing.T) {
|
||||
_, err := parseDuration("10")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing unit")
|
||||
}
|
||||
}
|
||||
139
format.go
Normal file
139
format.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func evaluateFile(path string, results []CommandResult, fileError string) TestResult {
|
||||
if fileError != "" {
|
||||
return TestResult{
|
||||
Path: path,
|
||||
Passed: false,
|
||||
CommandCount: len(results),
|
||||
Error: fileError,
|
||||
}
|
||||
}
|
||||
|
||||
var failures []FailedCommand
|
||||
|
||||
for _, r := range results {
|
||||
cmd := r.Command
|
||||
outputMatches := matchOutput(cmd.Expected, r.Actual)
|
||||
|
||||
var exitCodeMismatch bool
|
||||
switch cmd.ExitCodeType {
|
||||
case ExitCodeNone:
|
||||
exitCodeMismatch = r.ExitCode != 0
|
||||
case ExitCodeWildcard:
|
||||
exitCodeMismatch = r.ExitCode == 0
|
||||
case ExitCodeExact:
|
||||
exitCodeMismatch = r.ExitCode != cmd.ExitCodeValue
|
||||
}
|
||||
|
||||
if !outputMatches || exitCodeMismatch {
|
||||
var diffLines []DiffLine
|
||||
if !outputMatches {
|
||||
diffLines = diff(cmd.Expected, r.Actual)
|
||||
}
|
||||
failures = append(failures, FailedCommand{
|
||||
Result: r,
|
||||
DiffLines: diffLines,
|
||||
ExitCodeMismatch: exitCodeMismatch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return TestResult{
|
||||
Path: path,
|
||||
Passed: len(failures) == 0,
|
||||
CommandCount: len(results),
|
||||
Failures: failures,
|
||||
}
|
||||
}
|
||||
|
||||
func formatFailure(t TestResult) string {
|
||||
var lines []string
|
||||
|
||||
lines = append(lines, red("FAIL "+t.Path))
|
||||
|
||||
if t.Error != "" {
|
||||
lines = append(lines, " "+red(t.Error))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
for _, f := range t.Failures {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, " "+dim("$")+" "+f.Result.Command.Cmd)
|
||||
|
||||
if len(f.DiffLines) > 0 {
|
||||
lines = append(lines, red(" expected:"))
|
||||
for _, dl := range f.DiffLines {
|
||||
switch dl.Kind {
|
||||
case "expected":
|
||||
lines = append(lines, red(" > ")+dl.Text)
|
||||
case "equal":
|
||||
lines = append(lines, " "+dl.Text)
|
||||
case "context":
|
||||
lines = append(lines, " "+dim(dl.Text))
|
||||
}
|
||||
}
|
||||
lines = append(lines, green(" actual:"))
|
||||
for _, dl := range f.DiffLines {
|
||||
switch dl.Kind {
|
||||
case "actual":
|
||||
lines = append(lines, green(" > ")+dl.Text)
|
||||
case "equal":
|
||||
lines = append(lines, " "+dl.Text)
|
||||
case "context":
|
||||
lines = append(lines, " "+dim(dl.Text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if f.ExitCodeMismatch {
|
||||
cmd := f.Result.Command
|
||||
var expectedStr string
|
||||
switch cmd.ExitCodeType {
|
||||
case ExitCodeNone:
|
||||
expectedStr = "0"
|
||||
case ExitCodeWildcard:
|
||||
expectedStr = "non-zero"
|
||||
case ExitCodeExact:
|
||||
expectedStr = fmt.Sprintf("%d", cmd.ExitCodeValue)
|
||||
}
|
||||
lines = append(lines, red(fmt.Sprintf(" expected exit code: %s", expectedStr)))
|
||||
lines = append(lines, green(fmt.Sprintf(" actual exit code: %d", f.Result.ExitCode)))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatSummary(results []TestResult, elapsed time.Duration) string {
|
||||
totalCommands := 0
|
||||
failedCommands := 0
|
||||
for _, r := range results {
|
||||
totalCommands += r.CommandCount
|
||||
failedCommands += len(r.Failures)
|
||||
}
|
||||
passedCommands := totalCommands - failedCommands
|
||||
|
||||
var parts []string
|
||||
if passedCommands > 0 {
|
||||
parts = append(parts, green(fmt.Sprintf("%d passed", passedCommands)))
|
||||
}
|
||||
if failedCommands > 0 {
|
||||
parts = append(parts, red(fmt.Sprintf("%d failed", failedCommands)))
|
||||
}
|
||||
|
||||
var timeStr string
|
||||
if elapsed < time.Second {
|
||||
timeStr = fmt.Sprintf("%dms", elapsed.Milliseconds())
|
||||
} else {
|
||||
timeStr = fmt.Sprintf("%.1fs", elapsed.Seconds())
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s in %s", strings.Join(parts, ", "), timeStr)
|
||||
}
|
||||
10
go.mod
Normal file
10
go.mod
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module shout
|
||||
|
||||
go 1.24.1
|
||||
|
||||
require github.com/spf13/cobra v1.10.2
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
)
|
||||
10
go.sum
Normal file
10
go.sum
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
13
main.go
Normal file
13
main.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
103
match.go
Normal file
103
match.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func matchLine(pattern, actual string) bool {
|
||||
if !strings.Contains(pattern, "...") {
|
||||
return pattern == actual
|
||||
}
|
||||
|
||||
parts := strings.Split(pattern, "...")
|
||||
escaped := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
escaped[i] = regexp.QuoteMeta(p)
|
||||
}
|
||||
re := regexp.MustCompile("^" + strings.Join(escaped, ".*") + "$")
|
||||
return re.MatchString(actual)
|
||||
}
|
||||
|
||||
func matchOutput(expected, actual []string) bool {
|
||||
return doMatch(expected, 0, actual, 0)
|
||||
}
|
||||
|
||||
func doMatch(expected []string, ei int, actual []string, ai int) bool {
|
||||
if ei == len(expected) && ai == len(actual) {
|
||||
return true
|
||||
}
|
||||
if ei == len(expected) {
|
||||
return false
|
||||
}
|
||||
|
||||
exp := expected[ei]
|
||||
|
||||
// Multi-line wildcard
|
||||
if exp == "..." {
|
||||
for skip := ai; skip <= len(actual); skip++ {
|
||||
if doMatch(expected, ei+1, actual, skip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if ai == len(actual) {
|
||||
return false
|
||||
}
|
||||
|
||||
if matchLine(exp, actual[ai]) {
|
||||
return doMatch(expected, ei+1, actual, ai+1)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func diff(expected, actual []string) []DiffLine {
|
||||
var result []DiffLine
|
||||
ei, ai := 0, 0
|
||||
|
||||
for ei < len(expected) || ai < len(actual) {
|
||||
if ei < len(expected) && expected[ei] == "..." {
|
||||
nextExp := ""
|
||||
hasNext := ei+1 < len(expected)
|
||||
if hasNext {
|
||||
nextExp = expected[ei+1]
|
||||
}
|
||||
|
||||
if !hasNext {
|
||||
result = append(result, DiffLine{Kind: "context", Text: "..."})
|
||||
break
|
||||
}
|
||||
|
||||
result = append(result, DiffLine{Kind: "context", Text: "..."})
|
||||
ei++
|
||||
for ai < len(actual) && !matchLine(nextExp, actual[ai]) {
|
||||
ai++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ei < len(expected) && ai < len(actual) {
|
||||
if matchLine(expected[ei], actual[ai]) {
|
||||
result = append(result, DiffLine{Kind: "equal", Text: actual[ai]})
|
||||
ei++
|
||||
ai++
|
||||
} else {
|
||||
result = append(result, DiffLine{Kind: "expected", Text: expected[ei]})
|
||||
result = append(result, DiffLine{Kind: "actual", Text: actual[ai]})
|
||||
ei++
|
||||
ai++
|
||||
}
|
||||
} else if ei < len(expected) {
|
||||
result = append(result, DiffLine{Kind: "expected", Text: expected[ei]})
|
||||
ei++
|
||||
} else {
|
||||
result = append(result, DiffLine{Kind: "actual", Text: actual[ai]})
|
||||
ai++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
127
match_test.go
Normal file
127
match_test.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchLineExact(t *testing.T) {
|
||||
if !matchLine("hello", "hello") {
|
||||
t.Error("exact match should pass")
|
||||
}
|
||||
if matchLine("hello", "world") {
|
||||
t.Error("exact mismatch should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchLineInlineWildcard(t *testing.T) {
|
||||
if !matchLine("Homebrew 5...", "Homebrew 5.1.0") {
|
||||
t.Error("trailing wildcard should match")
|
||||
}
|
||||
if !matchLine("...world", "hello world") {
|
||||
t.Error("leading wildcard should match")
|
||||
}
|
||||
if !matchLine("a...b...c", "aXXbYYc") {
|
||||
t.Error("multiple wildcards should match")
|
||||
}
|
||||
if matchLine("a...c", "aXXd") {
|
||||
t.Error("should not match when suffix differs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchLinePreservesLiteralDots(t *testing.T) {
|
||||
if !matchLine("match ...", "match ...") {
|
||||
t.Error("literal ... should match itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOutputEmpty(t *testing.T) {
|
||||
if !matchOutput(nil, nil) {
|
||||
t.Error("both empty should match")
|
||||
}
|
||||
if !matchOutput([]string{}, []string{}) {
|
||||
t.Error("both empty slices should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOutputExact(t *testing.T) {
|
||||
if !matchOutput([]string{"hello", "world"}, []string{"hello", "world"}) {
|
||||
t.Error("exact match should pass")
|
||||
}
|
||||
if matchOutput([]string{"hello"}, []string{"world"}) {
|
||||
t.Error("mismatch should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOutputExtraActual(t *testing.T) {
|
||||
if matchOutput([]string{"hello"}, []string{"hello", "world"}) {
|
||||
t.Error("extra actual lines should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOutputExtraExpected(t *testing.T) {
|
||||
if matchOutput([]string{"hello", "world"}, []string{"hello"}) {
|
||||
t.Error("extra expected lines should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOutputMultilineWildcard(t *testing.T) {
|
||||
if !matchOutput([]string{"first", "...", "last"}, []string{"first", "a", "b", "c", "last"}) {
|
||||
t.Error("multiline wildcard should match multiple lines")
|
||||
}
|
||||
if !matchOutput([]string{"first", "...", "last"}, []string{"first", "last"}) {
|
||||
t.Error("multiline wildcard should match zero lines")
|
||||
}
|
||||
if !matchOutput([]string{"..."}, []string{"a", "b", "c"}) {
|
||||
t.Error("standalone wildcard should match anything")
|
||||
}
|
||||
if !matchOutput([]string{"..."}, nil) {
|
||||
t.Error("standalone wildcard should match empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOutputMixed(t *testing.T) {
|
||||
expected := []string{"one", "...", "three"}
|
||||
actual := []string{"one", "two", "three"}
|
||||
if !matchOutput(expected, actual) {
|
||||
t.Error("mixed wildcard should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOutputWildcardAtEnd(t *testing.T) {
|
||||
if !matchOutput([]string{"start", "..."}, []string{"start", "a", "b"}) {
|
||||
t.Error("trailing wildcard should match remaining lines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchOutputInlineAndMultiline(t *testing.T) {
|
||||
expected := []string{"Homebrew 5...", "..."}
|
||||
actual := []string{"Homebrew 5.1.0", "extra line"}
|
||||
if !matchOutput(expected, actual) {
|
||||
t.Error("inline + multiline wildcards should work together")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffBasic(t *testing.T) {
|
||||
d := diff([]string{"hello"}, []string{"world"})
|
||||
if len(d) != 2 {
|
||||
t.Fatalf("expected 2 diff lines, got %d", len(d))
|
||||
}
|
||||
if d[0].Kind != "expected" || d[0].Text != "hello" {
|
||||
t.Errorf("d[0] = %+v", d[0])
|
||||
}
|
||||
if d[1].Kind != "actual" || d[1].Text != "world" {
|
||||
t.Errorf("d[1] = %+v", d[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffEqual(t *testing.T) {
|
||||
d := diff([]string{"hello"}, []string{"hello"})
|
||||
if len(d) != 1 || d[0].Kind != "equal" {
|
||||
t.Errorf("expected equal, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffContext(t *testing.T) {
|
||||
d := diff([]string{"..."}, []string{"a", "b"})
|
||||
if len(d) != 1 || d[0].Kind != "context" {
|
||||
t.Errorf("expected context, got %+v", d)
|
||||
}
|
||||
}
|
||||
128
parse.go
Normal file
128
parse.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func stripComment(line string) string {
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for i := 0; i < len(line); i++ {
|
||||
ch := line[i]
|
||||
switch {
|
||||
case ch == '\'' && !inDouble:
|
||||
inSingle = !inSingle
|
||||
case ch == '"' && !inSingle:
|
||||
inDouble = !inDouble
|
||||
case ch == '#' && !inSingle && !inDouble:
|
||||
return strings.TrimRight(line[:i], " \t")
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func parseExitCode(lines []string) ([]string, ExitCodeType, int) {
|
||||
if len(lines) == 0 {
|
||||
return lines, ExitCodeNone, 0
|
||||
}
|
||||
|
||||
last := lines[len(lines)-1]
|
||||
if len(last) < 3 || last[0] != '[' || last[len(last)-1] != ']' {
|
||||
return lines, ExitCodeNone, 0
|
||||
}
|
||||
|
||||
inner := last[1 : len(last)-1]
|
||||
if inner == "*" {
|
||||
return lines[:len(lines)-1], ExitCodeWildcard, 0
|
||||
}
|
||||
|
||||
code, err := strconv.Atoi(inner)
|
||||
if err != nil {
|
||||
return lines, ExitCodeNone, 0
|
||||
}
|
||||
return lines[:len(lines)-1], ExitCodeExact, code
|
||||
}
|
||||
|
||||
func trimTrailingEmpty(lines []string) []string {
|
||||
end := len(lines)
|
||||
for end > 0 && lines[end-1] == "" {
|
||||
end--
|
||||
}
|
||||
return lines[:end]
|
||||
}
|
||||
|
||||
func parse(path, content string) (ShoutFile, error) {
|
||||
rawLines := strings.Split(content, "\n")
|
||||
|
||||
// Remove trailing newline
|
||||
if len(rawLines) > 0 && rawLines[len(rawLines)-1] == "" {
|
||||
rawLines = rawLines[:len(rawLines)-1]
|
||||
}
|
||||
|
||||
var commands []Command
|
||||
var directives []Directive
|
||||
var current *Command
|
||||
seenCommand := false
|
||||
|
||||
for i, line := range rawLines {
|
||||
lineNum := i + 1
|
||||
|
||||
if !seenCommand && strings.HasPrefix(line, "@") {
|
||||
if strings.HasPrefix(line, "@setup ") {
|
||||
setupPath := strings.TrimSpace(line[7:])
|
||||
if setupPath == "" {
|
||||
return ShoutFile{}, fmt.Errorf("%s:%d: @setup requires a file path", path, lineNum)
|
||||
}
|
||||
directives = append(directives, Directive{
|
||||
Type: "setup", Path: setupPath, Line: lineNum,
|
||||
})
|
||||
} else if strings.HasPrefix(line, "@env ") {
|
||||
rest := strings.TrimSpace(line[5:])
|
||||
eq := strings.Index(rest, "=")
|
||||
if eq <= 0 {
|
||||
return ShoutFile{}, fmt.Errorf("%s:%d: malformed @env directive (expected KEY=VALUE): %s", path, lineNum, line)
|
||||
}
|
||||
directives = append(directives, Directive{
|
||||
Type: "env", Key: rest[:eq], Value: rest[eq+1:], Line: lineNum,
|
||||
})
|
||||
} else {
|
||||
return ShoutFile{}, fmt.Errorf("%s:%d: unknown directive: %s", path, lineNum, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "$ ") {
|
||||
seenCommand = true
|
||||
if current != nil {
|
||||
trimmed := trimTrailingEmpty(current.Expected)
|
||||
remaining, ecType, ecVal := parseExitCode(trimmed)
|
||||
current.Expected = trimTrailingEmpty(remaining)
|
||||
current.ExitCodeType = ecType
|
||||
current.ExitCodeValue = ecVal
|
||||
commands = append(commands, *current)
|
||||
}
|
||||
|
||||
current = &Command{
|
||||
Line: lineNum,
|
||||
Raw: line,
|
||||
Cmd: stripComment(line[2:]),
|
||||
Expected: nil,
|
||||
}
|
||||
} else if current != nil {
|
||||
current.Expected = append(current.Expected, line)
|
||||
}
|
||||
}
|
||||
|
||||
if current != nil {
|
||||
trimmed := trimTrailingEmpty(current.Expected)
|
||||
remaining, ecType, ecVal := parseExitCode(trimmed)
|
||||
current.Expected = trimTrailingEmpty(remaining)
|
||||
current.ExitCodeType = ecType
|
||||
current.ExitCodeValue = ecVal
|
||||
commands = append(commands, *current)
|
||||
}
|
||||
|
||||
return ShoutFile{Path: path, Commands: commands, Directives: directives}, nil
|
||||
}
|
||||
167
parse_test.go
Normal file
167
parse_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBasicCommand(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ echo hello\nhello\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sf.Commands) != 1 {
|
||||
t.Fatalf("expected 1 command, got %d", len(sf.Commands))
|
||||
}
|
||||
c := sf.Commands[0]
|
||||
if c.Cmd != "echo hello" {
|
||||
t.Errorf("cmd = %q, want %q", c.Cmd, "echo hello")
|
||||
}
|
||||
if len(c.Expected) != 1 || c.Expected[0] != "hello" {
|
||||
t.Errorf("expected = %v, want [hello]", c.Expected)
|
||||
}
|
||||
if c.ExitCodeType != ExitCodeNone {
|
||||
t.Errorf("exit code type = %d, want ExitCodeNone", c.ExitCodeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultipleCommands(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ echo one\none\n\n$ echo two\ntwo\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sf.Commands) != 2 {
|
||||
t.Fatalf("expected 2 commands, got %d", len(sf.Commands))
|
||||
}
|
||||
if sf.Commands[0].Cmd != "echo one" {
|
||||
t.Errorf("cmd[0] = %q", sf.Commands[0].Cmd)
|
||||
}
|
||||
if sf.Commands[1].Cmd != "echo two" {
|
||||
t.Errorf("cmd[1] = %q", sf.Commands[1].Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExitCode(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ false\n[1]\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := sf.Commands[0]
|
||||
if c.ExitCodeType != ExitCodeExact || c.ExitCodeValue != 1 {
|
||||
t.Errorf("exit code = (%d, %d), want (Exact, 1)", c.ExitCodeType, c.ExitCodeValue)
|
||||
}
|
||||
if len(c.Expected) != 0 {
|
||||
t.Errorf("expected = %v, want []", c.Expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExitCodeWildcard(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ false\n[*]\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := sf.Commands[0]
|
||||
if c.ExitCodeType != ExitCodeWildcard {
|
||||
t.Errorf("exit code type = %d, want Wildcard", c.ExitCodeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExitCodeWithOutput(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ sh -c \"echo oops && exit 1\"\noops\n[*]\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := sf.Commands[0]
|
||||
if len(c.Expected) != 1 || c.Expected[0] != "oops" {
|
||||
t.Errorf("expected = %v, want [oops]", c.Expected)
|
||||
}
|
||||
if c.ExitCodeType != ExitCodeWildcard {
|
||||
t.Errorf("exit code type = %d, want Wildcard", c.ExitCodeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseComment(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ echo hello # this is a comment\nhello\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sf.Commands[0].Cmd != "echo hello" {
|
||||
t.Errorf("cmd = %q, want %q", sf.Commands[0].Cmd, "echo hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentInQuotes(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ echo \"keep # this\"\nkeep # this\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sf.Commands[0].Cmd != `echo "keep # this"` {
|
||||
t.Errorf("cmd = %q", sf.Commands[0].Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvDirective(t *testing.T) {
|
||||
sf, err := parse("test.shout", "@env GREETING=hello\n\n$ echo $GREETING\nhello\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sf.Directives) != 1 {
|
||||
t.Fatalf("expected 1 directive, got %d", len(sf.Directives))
|
||||
}
|
||||
d := sf.Directives[0]
|
||||
if d.Type != "env" || d.Key != "GREETING" || d.Value != "hello" {
|
||||
t.Errorf("directive = %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSetupDirective(t *testing.T) {
|
||||
sf, err := parse("test.shout", "@setup shared.shout\n\n$ echo hi\nhi\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sf.Directives) != 1 {
|
||||
t.Fatalf("expected 1 directive, got %d", len(sf.Directives))
|
||||
}
|
||||
d := sf.Directives[0]
|
||||
if d.Type != "setup" || d.Path != "shared.shout" {
|
||||
t.Errorf("directive = %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNoExpectedOutput(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ export FOO=bar\n$ echo $FOO\nbar\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sf.Commands) != 2 {
|
||||
t.Fatalf("expected 2 commands, got %d", len(sf.Commands))
|
||||
}
|
||||
if len(sf.Commands[0].Expected) != 0 {
|
||||
t.Errorf("first command expected = %v, want []", sf.Commands[0].Expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrimsTrailingBlanks(t *testing.T) {
|
||||
sf, err := parse("test.shout", "$ echo hello\nhello\n\n\n$ echo world\nworld\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sf.Commands[0].Expected) != 1 || sf.Commands[0].Expected[0] != "hello" {
|
||||
t.Errorf("expected = %v", sf.Commands[0].Expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLineNumbers(t *testing.T) {
|
||||
sf, err := parse("test.shout", "@env X=1\n\n$ echo hello\nhello\n\n$ echo world\nworld\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sf.Directives[0].Line != 1 {
|
||||
t.Errorf("directive line = %d, want 1", sf.Directives[0].Line)
|
||||
}
|
||||
if sf.Commands[0].Line != 3 {
|
||||
t.Errorf("command[0] line = %d, want 3", sf.Commands[0].Line)
|
||||
}
|
||||
if sf.Commands[1].Line != 6 {
|
||||
t.Errorf("command[1] line = %d, want 6", sf.Commands[1].Line)
|
||||
}
|
||||
}
|
||||
203
run.go
Normal file
203
run.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sentinelPrefix = "__SHOUT_SENTINEL_"
|
||||
|
||||
type RunOptions struct {
|
||||
CleanEnv bool
|
||||
PathDirs []string
|
||||
EnvVars map[string]string
|
||||
Timeout time.Duration
|
||||
Verbose bool
|
||||
OnCommand func(Command)
|
||||
}
|
||||
|
||||
func buildScript(commands []Command) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("exec 2>&1\n")
|
||||
for i, cmd := range commands {
|
||||
b.WriteString(cmd.Cmd)
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("printf '\\n" + sentinelPrefix + "%s_" + strconv.Itoa(i) + "__\\n' \"$?\"\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseSentinelOutput(raw string, commandCount int) (outputs [][]string, exitCodes []int) {
|
||||
re := regexp.MustCompile(regexp.QuoteMeta(sentinelPrefix) + `(\d+)_(\d+)__`)
|
||||
|
||||
remaining := raw
|
||||
for i := 0; i < commandCount; i++ {
|
||||
loc := re.FindStringSubmatchIndex(remaining)
|
||||
if loc == nil {
|
||||
lines := strings.Split(remaining, "\n")
|
||||
if len(lines) > 0 && lines[0] == "" {
|
||||
lines = lines[1:]
|
||||
}
|
||||
lines = trimTrailingEmpty(lines)
|
||||
outputs = append(outputs, lines)
|
||||
exitCodes = append(exitCodes, 1)
|
||||
break
|
||||
}
|
||||
|
||||
before := remaining[:loc[0]]
|
||||
exitCodeStr := remaining[loc[2]:loc[3]]
|
||||
ec, _ := strconv.Atoi(exitCodeStr)
|
||||
|
||||
lines := strings.Split(before, "\n")
|
||||
if len(lines) > 0 && lines[0] == "" {
|
||||
lines = lines[1:]
|
||||
}
|
||||
lines = trimTrailingEmpty(lines)
|
||||
if len(lines) == 1 && lines[0] == "" {
|
||||
lines = nil
|
||||
}
|
||||
|
||||
outputs = append(outputs, lines)
|
||||
exitCodes = append(exitCodes, ec)
|
||||
|
||||
afterSentinel := remaining[loc[1]:]
|
||||
if strings.HasPrefix(afterSentinel, "\n") {
|
||||
afterSentinel = afterSentinel[1:]
|
||||
}
|
||||
remaining = afterSentinel
|
||||
}
|
||||
|
||||
for len(outputs) < commandCount {
|
||||
outputs = append(outputs, nil)
|
||||
exitCodes = append(exitCodes, 1)
|
||||
}
|
||||
|
||||
return outputs, exitCodes
|
||||
}
|
||||
|
||||
func runFile(file ShoutFile, opts RunOptions) FileResult {
|
||||
tmpDir, err := os.MkdirTemp("", "shout-")
|
||||
if err != nil {
|
||||
return FileResult{File: file, TmpDir: "", Error: err.Error()}
|
||||
}
|
||||
|
||||
if len(file.Commands) == 0 {
|
||||
return FileResult{File: file, TmpDir: tmpDir}
|
||||
}
|
||||
|
||||
script := buildScript(file.Commands)
|
||||
|
||||
// Build environment
|
||||
var envMap map[string]string
|
||||
if opts.CleanEnv {
|
||||
envMap = make(map[string]string)
|
||||
} else {
|
||||
envMap = make(map[string]string)
|
||||
for _, e := range os.Environ() {
|
||||
if k, v, ok := strings.Cut(e, "="); ok {
|
||||
envMap[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envMap["HOME"] = tmpDir
|
||||
envMap["SHOUT_DIR"] = tmpDir
|
||||
|
||||
for k, v := range opts.EnvVars {
|
||||
envMap[k] = v
|
||||
}
|
||||
|
||||
if len(opts.PathDirs) > 0 {
|
||||
existing := envMap["PATH"]
|
||||
envMap["PATH"] = strings.Join(opts.PathDirs, ":") + ":" + existing
|
||||
}
|
||||
|
||||
envSlice := make([]string, 0, len(envMap))
|
||||
for k, v := range envMap {
|
||||
envSlice = append(envSlice, k+"="+v)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/bin/sh")
|
||||
cmd.Dir = tmpDir
|
||||
cmd.Env = envSlice
|
||||
cmd.Stderr = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return FileResult{File: file, TmpDir: tmpDir, Error: err.Error()}
|
||||
}
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return FileResult{File: file, TmpDir: tmpDir, Error: err.Error()}
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return FileResult{File: file, TmpDir: tmpDir, Error: err.Error()}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
}()
|
||||
|
||||
_, _ = io.WriteString(stdin, script)
|
||||
stdin.Close()
|
||||
|
||||
// Read stdout with timeout
|
||||
totalTimeout := opts.Timeout * time.Duration(len(file.Commands))
|
||||
type readResult struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan readResult, 1)
|
||||
go func() {
|
||||
data, err := io.ReadAll(stdoutPipe)
|
||||
ch <- readResult{data, err}
|
||||
}()
|
||||
|
||||
var output []byte
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.err != nil {
|
||||
return FileResult{File: file, TmpDir: tmpDir, Error: r.err.Error()}
|
||||
}
|
||||
output = r.data
|
||||
case <-time.After(totalTimeout):
|
||||
return FileResult{File: file, TmpDir: tmpDir, Error: "Timeout reading output"}
|
||||
}
|
||||
|
||||
_ = cmd.Wait()
|
||||
|
||||
outputs, exitCodesList := parseSentinelOutput(string(output), len(file.Commands))
|
||||
|
||||
results := make([]CommandResult, len(file.Commands))
|
||||
for i, c := range file.Commands {
|
||||
if opts.Verbose && opts.OnCommand != nil {
|
||||
opts.OnCommand(c)
|
||||
}
|
||||
actual := outputs[i]
|
||||
if actual == nil {
|
||||
actual = []string{}
|
||||
}
|
||||
results[i] = CommandResult{
|
||||
Command: c,
|
||||
Actual: actual,
|
||||
ExitCode: exitCodesList[i],
|
||||
}
|
||||
}
|
||||
|
||||
return FileResult{File: file, Results: results, TmpDir: tmpDir}
|
||||
}
|
||||
|
||||
func cleanupTmpDir(dir string) {
|
||||
os.RemoveAll(dir)
|
||||
}
|
||||
9
test/basic.shout
Normal file
9
test/basic.shout
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
$ echo hello
|
||||
hello
|
||||
|
||||
$ echo one && echo two
|
||||
one
|
||||
two
|
||||
|
||||
$ echo "working directory: $(basename $PWD)"
|
||||
working directory: ...
|
||||
5
test/comments.shout
Normal file
5
test/comments.shout
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
$ echo hello # this is a comment
|
||||
hello
|
||||
|
||||
$ echo "keep # this"
|
||||
keep # this
|
||||
5
test/env.shout
Normal file
5
test/env.shout
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@env GREETING=hello
|
||||
@env TARGET=world
|
||||
|
||||
$ echo "$GREETING $TARGET"
|
||||
hello world
|
||||
28
test/features.shout
Normal file
28
test/features.shout
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
$ echo "test exit codes"
|
||||
test exit codes
|
||||
|
||||
$ false
|
||||
[1]
|
||||
|
||||
$ sh -c "exit 42"
|
||||
[42]
|
||||
|
||||
$ sh -c "echo oops && exit 1"
|
||||
oops
|
||||
[*]
|
||||
|
||||
$ export MY_VAR=hello
|
||||
$ echo $MY_VAR
|
||||
hello
|
||||
|
||||
$ cd /tmp
|
||||
$ pwd
|
||||
/tmp
|
||||
|
||||
$ echo "line 1" && echo "" && echo "line 3"
|
||||
line 1
|
||||
|
||||
line 3
|
||||
|
||||
$ echo "match ..."
|
||||
match ...
|
||||
1
test/setup-shared.shout
Normal file
1
test/setup-shared.shout
Normal file
|
|
@ -0,0 +1 @@
|
|||
$ export READY=yes
|
||||
4
test/setup-user.shout
Normal file
4
test/setup-user.shout
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
@setup setup-shared.shout
|
||||
|
||||
$ echo $READY
|
||||
yes
|
||||
65
types.go
Normal file
65
types.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package main
|
||||
|
||||
// ExitCodeType describes how to match a command's exit code.
|
||||
type ExitCodeType int
|
||||
|
||||
const (
|
||||
ExitCodeNone ExitCodeType = iota // not specified — expect 0
|
||||
ExitCodeExact // expect specific code
|
||||
ExitCodeWildcard // [*] — expect any non-zero
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
Line int
|
||||
Raw string
|
||||
Cmd string
|
||||
Expected []string
|
||||
ExitCodeType ExitCodeType
|
||||
ExitCodeValue int
|
||||
}
|
||||
|
||||
type Directive struct {
|
||||
Type string // "setup" or "env"
|
||||
Path string // for setup
|
||||
Key string // for env
|
||||
Value string // for env
|
||||
Line int
|
||||
}
|
||||
|
||||
type ShoutFile struct {
|
||||
Path string
|
||||
Commands []Command
|
||||
Directives []Directive
|
||||
}
|
||||
|
||||
type CommandResult struct {
|
||||
Command Command
|
||||
Actual []string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
type FileResult struct {
|
||||
File ShoutFile
|
||||
Results []CommandResult
|
||||
TmpDir string
|
||||
Error string
|
||||
}
|
||||
|
||||
type DiffLine struct {
|
||||
Kind string // "equal", "expected", "actual", "context"
|
||||
Text string
|
||||
}
|
||||
|
||||
type TestResult struct {
|
||||
Path string
|
||||
Passed bool
|
||||
CommandCount int
|
||||
Failures []FailedCommand
|
||||
Error string
|
||||
}
|
||||
|
||||
type FailedCommand struct {
|
||||
Result CommandResult
|
||||
DiffLines []DiffLine
|
||||
ExitCodeMismatch bool
|
||||
}
|
||||
79
update.go
Normal file
79
update.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var exitCodeMarkerRe = regexp.MustCompile(`^\[(\d+|\*)\]$`)
|
||||
|
||||
func rewriteFile(file ShoutFile, results []CommandResult, originalContent string) string {
|
||||
lines := strings.Split(originalContent, "\n")
|
||||
var output []string
|
||||
|
||||
cmdIdx := 0
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
|
||||
if strings.HasPrefix(line, "$ ") {
|
||||
output = append(output, line)
|
||||
|
||||
if cmdIdx >= len(file.Commands) || cmdIdx >= len(results) {
|
||||
cmdIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := file.Commands[cmdIdx]
|
||||
result := results[cmdIdx]
|
||||
|
||||
// Skip past old expected output lines
|
||||
j := i + 1
|
||||
for j < len(lines) && !strings.HasPrefix(lines[j], "$ ") {
|
||||
j++
|
||||
}
|
||||
|
||||
oldExpectedRaw := lines[i+1 : j]
|
||||
|
||||
// Check for exit code marker
|
||||
oldTrimmed := trimTrailingEmpty(oldExpectedRaw)
|
||||
var oldExitMarker string
|
||||
if len(oldTrimmed) > 0 {
|
||||
last := oldTrimmed[len(oldTrimmed)-1]
|
||||
if exitCodeMarkerRe.MatchString(last) {
|
||||
oldExitMarker = last
|
||||
}
|
||||
}
|
||||
|
||||
// Count trailing blank lines
|
||||
trailingBlanks := 0
|
||||
for k := len(oldExpectedRaw) - 1; k >= 0; k-- {
|
||||
if oldExpectedRaw[k] == "" {
|
||||
trailingBlanks++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If wildcards match, keep original
|
||||
if matchOutput(cmd.Expected, result.Actual) {
|
||||
output = append(output, oldExpectedRaw...)
|
||||
} else {
|
||||
output = append(output, result.Actual...)
|
||||
if oldExitMarker != "" {
|
||||
output = append(output, oldExitMarker)
|
||||
}
|
||||
for k := 0; k < trailingBlanks; k++ {
|
||||
output = append(output, "")
|
||||
}
|
||||
}
|
||||
|
||||
i = j - 1
|
||||
cmdIdx++
|
||||
} else if cmdIdx == 0 {
|
||||
output = append(output, line)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(output, "\n")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user