Add shout shell output tester initial implementation

This commit is contained in:
Chris Wanstrath 2026-03-10 12:01:47 -07:00
parent 77d3fb2a55
commit 57fba7acb0
23 changed files with 1601 additions and 0 deletions

15
Makefile Normal file
View 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
View 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(&parallel, "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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

BIN
shout Executable file

Binary file not shown.

9
test/basic.shout Normal file
View 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
View File

@ -0,0 +1,5 @@
$ echo hello # this is a comment
hello
$ echo "keep # this"
keep # this

5
test/env.shout Normal file
View File

@ -0,0 +1,5 @@
@env GREETING=hello
@env TARGET=world
$ echo "$GREETING $TARGET"
hello world

28
test/features.shout Normal file
View 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
View File

@ -0,0 +1 @@
$ export READY=yes

4
test/setup-user.shout Normal file
View File

@ -0,0 +1,4 @@
@setup setup-shared.shout
$ echo $READY
yes

65
types.go Normal file
View 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
View 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")
}