Refactor exit code logic and remove Verbose flag

This commit is contained in:
Chris Wanstrath 2026-03-10 12:45:49 -07:00
parent c7730bcaa6
commit 909609f21f
8 changed files with 64 additions and 151 deletions

14
cmd.go
View File

@ -44,7 +44,7 @@ func testCmd() *cobra.Command {
Short: "Run .shout test files", Short: "Run .shout test files",
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
timeoutDur, err := parseDuration(timeout) timeoutDur, err := time.ParseDuration(timeout)
if err != nil { if err != nil {
return err return err
} }
@ -151,7 +151,6 @@ func testCmd() *cobra.Command {
PathDirs: pathDirs, PathDirs: pathDirs,
EnvVars: envVars, EnvVars: envVars,
Timeout: timeoutDur, Timeout: timeoutDur,
Verbose: verbose,
OnCommand: onCommand, OnCommand: onCommand,
}) })
@ -159,16 +158,7 @@ func testCmd() *cobra.Command {
for i := 0; i < len(setupCommands) && i < len(fileResult.Results); i++ { for i := 0; i < len(setupCommands) && i < len(fileResult.Results); i++ {
r := fileResult.Results[i] r := fileResult.Results[i]
sc := setupCommands[i] sc := setupCommands[i]
ok := false if !exitCodeOK(sc.ExitCodeType, sc.ExitCodeValue, r.ExitCode) {
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 { if keep {
fmt.Fprintln(os.Stderr, fileResult.TmpDir) fmt.Fprintln(os.Stderr, fileResult.TmpDir)
} else { } else {

View File

@ -1,29 +0,0 @@
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])
}
}

View File

@ -1,60 +0,0 @@
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")
}
}

View File

@ -21,16 +21,7 @@ func evaluateFile(path string, results []CommandResult, fileError string) TestRe
for _, r := range results { for _, r := range results {
cmd := r.Command cmd := r.Command
outputMatches := matchOutput(cmd.Expected, r.Actual) outputMatches := matchOutput(cmd.Expected, r.Actual)
exitCodeMismatch := !exitCodeOK(cmd.ExitCodeType, cmd.ExitCodeValue, r.ExitCode)
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 { if !outputMatches || exitCodeMismatch {
var diffLines []DiffLine var diffLines []DiffLine
@ -114,7 +105,12 @@ func formatFailure(t TestResult) string {
func formatSummary(results []TestResult, elapsed time.Duration) string { func formatSummary(results []TestResult, elapsed time.Duration) string {
totalCommands := 0 totalCommands := 0
failedCommands := 0 failedCommands := 0
errorCount := 0
for _, r := range results { for _, r := range results {
if r.Error != "" {
errorCount++
continue
}
totalCommands += r.CommandCount totalCommands += r.CommandCount
failedCommands += len(r.Failures) failedCommands += len(r.Failures)
} }
@ -127,6 +123,9 @@ func formatSummary(results []TestResult, elapsed time.Duration) string {
if failedCommands > 0 { if failedCommands > 0 {
parts = append(parts, red(fmt.Sprintf("%d failed", failedCommands))) parts = append(parts, red(fmt.Sprintf("%d failed", failedCommands)))
} }
if errorCount > 0 {
parts = append(parts, red(fmt.Sprintf("%d errored", errorCount)))
}
var timeStr string var timeStr string
if elapsed < time.Second { if elapsed < time.Second {

60
run.go
View File

@ -24,7 +24,6 @@ type RunOptions struct {
PathDirs []string PathDirs []string
EnvVars map[string]string EnvVars map[string]string
Timeout time.Duration Timeout time.Duration
Verbose bool
OnCommand func(Command) OnCommand func(Command)
} }
@ -39,6 +38,18 @@ func buildScript(commands []Command, sentinelPrefix string) string {
return b.String() return b.String()
} }
func splitSentinelBlock(s string) []string {
lines := strings.Split(s, "\n")
if len(lines) > 0 && lines[0] == "" {
lines = lines[1:]
}
lines = trimTrailingEmpty(lines)
if len(lines) == 1 && lines[0] == "" {
lines = nil
}
return lines
}
func parseSentinelOutput(raw string, commandCount int, sentinelPrefix string) (outputs [][]string, exitCodes []int) { func parseSentinelOutput(raw string, commandCount int, sentinelPrefix string) (outputs [][]string, exitCodes []int) {
re := regexp.MustCompile(regexp.QuoteMeta(sentinelPrefix) + `(\d+)_(\d+)__`) re := regexp.MustCompile(regexp.QuoteMeta(sentinelPrefix) + `(\d+)_(\d+)__`)
@ -46,30 +57,29 @@ func parseSentinelOutput(raw string, commandCount int, sentinelPrefix string) (o
for i := 0; i < commandCount; i++ { for i := 0; i < commandCount; i++ {
loc := re.FindStringSubmatchIndex(remaining) loc := re.FindStringSubmatchIndex(remaining)
if loc == nil { if loc == nil {
lines := strings.Split(remaining, "\n") // No more sentinels — assign remaining output, mark as failed
if len(lines) > 0 && lines[0] == "" { outputs = append(outputs, splitSentinelBlock(remaining))
lines = lines[1:]
}
lines = trimTrailingEmpty(lines)
outputs = append(outputs, lines)
exitCodes = append(exitCodes, 1) exitCodes = append(exitCodes, 1)
break remaining = ""
continue
} }
before := remaining[:loc[0]]
exitCodeStr := remaining[loc[2]:loc[3]] exitCodeStr := remaining[loc[2]:loc[3]]
ec, _ := strconv.Atoi(exitCodeStr) ec, _ := strconv.Atoi(exitCodeStr)
cmdIdxStr := remaining[loc[4]:loc[5]]
cmdIdx, _ := strconv.Atoi(cmdIdxStr)
before := remaining[:loc[0]]
lines := strings.Split(before, "\n") // If sentinel belongs to a later command, this command's sentinel is missing
if len(lines) > 0 && lines[0] == "" { if cmdIdx > i {
lines = lines[1:] outputs = append(outputs, splitSentinelBlock(before))
} exitCodes = append(exitCodes, 1)
lines = trimTrailingEmpty(lines) // Keep remaining from the sentinel onwards for the next iteration
if len(lines) == 1 && lines[0] == "" { remaining = remaining[loc[0]:]
lines = nil continue
} }
outputs = append(outputs, lines) outputs = append(outputs, splitSentinelBlock(before))
exitCodes = append(exitCodes, ec) exitCodes = append(exitCodes, ec)
afterSentinel := remaining[loc[1]:] afterSentinel := remaining[loc[1]:]
@ -101,11 +111,8 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
script := buildScript(file.Commands, sentinel) script := buildScript(file.Commands, sentinel)
// Build environment // Build environment
var envMap map[string]string envMap := make(map[string]string)
if opts.CleanEnv { if !opts.CleanEnv {
envMap = make(map[string]string)
} else {
envMap = make(map[string]string)
for _, e := range os.Environ() { for _, e := range os.Environ() {
if k, v, ok := strings.Cut(e, "="); ok { if k, v, ok := strings.Cut(e, "="); ok {
envMap[k] = v envMap[k] = v
@ -161,6 +168,12 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
} }
}() }()
if opts.OnCommand != nil {
for _, c := range file.Commands {
opts.OnCommand(c)
}
}
_, _ = io.WriteString(stdin, script) _, _ = io.WriteString(stdin, script)
stdin.Close() stdin.Close()
@ -198,9 +211,6 @@ func runFile(file ShoutFile, opts RunOptions) FileResult {
results := make([]CommandResult, len(file.Commands)) results := make([]CommandResult, len(file.Commands))
for i, c := range file.Commands { for i, c := range file.Commands {
if opts.Verbose && opts.OnCommand != nil {
opts.OnCommand(c)
}
actual := outputs[i] actual := outputs[i]
if actual == nil { if actual == nil {
actual = []string{} actual = []string{}

BIN
shout

Binary file not shown.

View File

@ -9,6 +9,19 @@ const (
ExitCodeWildcard // [*] — expect any non-zero ExitCodeWildcard // [*] — expect any non-zero
) )
func exitCodeOK(ecType ExitCodeType, ecValue int, actual int) bool {
switch ecType {
case ExitCodeNone:
return actual == 0
case ExitCodeWildcard:
return actual != 0
case ExitCodeExact:
return actual == ecValue
default:
return actual == 0
}
}
type Command struct { type Command struct {
Line int Line int
Raw string Raw string

View File

@ -43,16 +43,6 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string
oldExpectedRaw := lines[i+1 : 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 // Count trailing blank lines
trailingBlanks := 0 trailingBlanks := 0
for k := len(oldExpectedRaw) - 1; k >= 0; k-- { for k := len(oldExpectedRaw) - 1; k >= 0; k-- {
@ -68,12 +58,12 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string
output = append(output, oldExpectedRaw...) output = append(output, oldExpectedRaw...)
} else { } else {
output = append(output, result.Actual...) output = append(output, result.Actual...)
if oldExitMarker != "" { if result.ExitCode != 0 {
output = append(output, oldExitMarker) output = append(output, fmt.Sprintf("[%d]", result.ExitCode))
} else if len(result.Actual) > 0 && exitCodeMarkerRe.MatchString(result.Actual[len(result.Actual)-1]) { } else if len(result.Actual) > 0 && exitCodeMarkerRe.MatchString(result.Actual[len(result.Actual)-1]) {
// Actual output's last line looks like an exit code marker. // Actual output's last line looks like an exit code marker.
// Add an explicit marker to prevent parser from consuming it as one. // Add an explicit [0] to prevent parser from consuming it.
output = append(output, fmt.Sprintf("[%d]", result.ExitCode)) output = append(output, "[0]")
} }
for k := 0; k < trailingBlanks; k++ { for k := 0; k < trailingBlanks; k++ {
output = append(output, "") output = append(output, "")
@ -82,7 +72,7 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string
i = j - 1 i = j - 1
cmdIdx++ cmdIdx++
} else if cmdIdx == 0 { } else if cmdIdx == 0 || cmdIdx >= len(file.Commands) {
output = append(output, line) output = append(output, line)
} }
} }