diff --git a/cmd.go b/cmd.go index 6cc9f7f..48e0489 100644 --- a/cmd.go +++ b/cmd.go @@ -44,7 +44,7 @@ func testCmd() *cobra.Command { Short: "Run .shout test files", Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { - timeoutDur, err := parseDuration(timeout) + timeoutDur, err := time.ParseDuration(timeout) if err != nil { return err } @@ -151,7 +151,6 @@ func testCmd() *cobra.Command { PathDirs: pathDirs, EnvVars: envVars, Timeout: timeoutDur, - Verbose: verbose, OnCommand: onCommand, }) @@ -159,16 +158,7 @@ func testCmd() *cobra.Command { 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 !exitCodeOK(sc.ExitCodeType, sc.ExitCodeValue, r.ExitCode) { if keep { fmt.Fprintln(os.Stderr, fileResult.TmpDir) } else { diff --git a/duration.go b/duration.go deleted file mode 100644 index 9afbaf5..0000000 --- a/duration.go +++ /dev/null @@ -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]) - } -} diff --git a/duration_test.go b/duration_test.go deleted file mode 100644 index 914e830..0000000 --- a/duration_test.go +++ /dev/null @@ -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") - } -} diff --git a/format.go b/format.go index 4c984c1..727db5f 100644 --- a/format.go +++ b/format.go @@ -21,16 +21,7 @@ func evaluateFile(path string, results []CommandResult, fileError string) TestRe 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 - } + exitCodeMismatch := !exitCodeOK(cmd.ExitCodeType, cmd.ExitCodeValue, r.ExitCode) if !outputMatches || exitCodeMismatch { var diffLines []DiffLine @@ -114,7 +105,12 @@ func formatFailure(t TestResult) string { func formatSummary(results []TestResult, elapsed time.Duration) string { totalCommands := 0 failedCommands := 0 + errorCount := 0 for _, r := range results { + if r.Error != "" { + errorCount++ + continue + } totalCommands += r.CommandCount failedCommands += len(r.Failures) } @@ -127,6 +123,9 @@ func formatSummary(results []TestResult, elapsed time.Duration) string { if failedCommands > 0 { parts = append(parts, red(fmt.Sprintf("%d failed", failedCommands))) } + if errorCount > 0 { + parts = append(parts, red(fmt.Sprintf("%d errored", errorCount))) + } var timeStr string if elapsed < time.Second { diff --git a/run.go b/run.go index 34307a8..8b5f2b1 100644 --- a/run.go +++ b/run.go @@ -24,7 +24,6 @@ type RunOptions struct { PathDirs []string EnvVars map[string]string Timeout time.Duration - Verbose bool OnCommand func(Command) } @@ -39,6 +38,18 @@ func buildScript(commands []Command, sentinelPrefix string) 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) { 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++ { 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) + // No more sentinels — assign remaining output, mark as failed + outputs = append(outputs, splitSentinelBlock(remaining)) exitCodes = append(exitCodes, 1) - break + remaining = "" + continue } - before := remaining[:loc[0]] exitCodeStr := remaining[loc[2]:loc[3]] ec, _ := strconv.Atoi(exitCodeStr) + cmdIdxStr := remaining[loc[4]:loc[5]] + cmdIdx, _ := strconv.Atoi(cmdIdxStr) + before := remaining[:loc[0]] - 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 + // If sentinel belongs to a later command, this command's sentinel is missing + if cmdIdx > i { + outputs = append(outputs, splitSentinelBlock(before)) + exitCodes = append(exitCodes, 1) + // Keep remaining from the sentinel onwards for the next iteration + remaining = remaining[loc[0]:] + continue } - outputs = append(outputs, lines) + outputs = append(outputs, splitSentinelBlock(before)) exitCodes = append(exitCodes, ec) afterSentinel := remaining[loc[1]:] @@ -101,11 +111,8 @@ func runFile(file ShoutFile, opts RunOptions) FileResult { script := buildScript(file.Commands, sentinel) // Build environment - var envMap map[string]string - if opts.CleanEnv { - envMap = make(map[string]string) - } else { - envMap = make(map[string]string) + envMap := make(map[string]string) + if !opts.CleanEnv { for _, e := range os.Environ() { if k, v, ok := strings.Cut(e, "="); ok { 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) stdin.Close() @@ -198,9 +211,6 @@ func runFile(file ShoutFile, opts RunOptions) FileResult { 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{} diff --git a/shout b/shout index 7f5643b..c7eedaf 100755 Binary files a/shout and b/shout differ diff --git a/types.go b/types.go index 2e6f295..e90157d 100644 --- a/types.go +++ b/types.go @@ -9,6 +9,19 @@ const ( 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 { Line int Raw string diff --git a/update.go b/update.go index 37fe93b..9318445 100644 --- a/update.go +++ b/update.go @@ -43,16 +43,6 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string 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-- { @@ -68,12 +58,12 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string output = append(output, oldExpectedRaw...) } else { output = append(output, result.Actual...) - if oldExitMarker != "" { - output = append(output, oldExitMarker) + if result.ExitCode != 0 { + output = append(output, fmt.Sprintf("[%d]", result.ExitCode)) } 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. - // Add an explicit marker to prevent parser from consuming it as one. - output = append(output, fmt.Sprintf("[%d]", result.ExitCode)) + // Add an explicit [0] to prevent parser from consuming it. + output = append(output, "[0]") } for k := 0; k < trailingBlanks; k++ { output = append(output, "") @@ -82,7 +72,7 @@ func rewriteFile(file ShoutFile, results []CommandResult, originalContent string i = j - 1 cmdIdx++ - } else if cmdIdx == 0 { + } else if cmdIdx == 0 || cmdIdx >= len(file.Commands) { output = append(output, line) } }