Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,8 @@ clitest [options] <path...>

`<path...>` — one or more `.clitest` files, directories, or glob patterns (quoted). Directories are scanned recursively for `*.clitest` files by default. Glob patterns (containing `*`, `?`, or `[`) are expanded by clitest itself. Non-`.clitest` files passed as arguments are skipped with a warning.

When a directory is scanned recursively, files are displayed by their path relative to that directory (e.g. `orders/import/it_handles_order.clitest`), not just the filename — this disambiguates same-named files in different subdirectories. Files sitting directly in the scanned directory show just their filename, unchanged.

### Options

| Flag | Description |
Expand Down
31 changes: 18 additions & 13 deletions cmd/clitest/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ type fileResult struct {
pass int
fail int
skip int
file string
display string
failures []display.CompactFailure
hidden bool
}
Expand Down Expand Up @@ -134,7 +134,7 @@ func runMain(_ *cobra.Command, args []string) error {
totalFail += r.fail
totalSkip += r.skip
if r.fail > 0 {
failedFiles = append(failedFiles, r.file)
failedFiles = append(failedFiles, r.display)
}
}

Expand All @@ -149,7 +149,7 @@ func runMain(_ *cobra.Command, args []string) error {
// runFiles is the unified execution function for both compact and verbose modes.
// It uses ProgressDisplay for the dynamic running-file block at the bottom,
// and emits finished file output (compact line or verbose block) permanently above.
func runFiles(files []string, workers int, isTTY bool, w io.Writer) []fileResult {
func runFiles(files []resolve.ScanFile, workers int, isTTY bool, w io.Writer) []fileResult {
results := make([]fileResult, len(files))
jobs := make(chan int, len(files))
var wg sync.WaitGroup
Expand All @@ -169,8 +169,13 @@ func runFiles(files []string, workers int, isTTY bool, w io.Writer) []fileResult
maxDynamic = 16
}

displays := make([]string, len(files))
for i, f := range files {
displays[i] = f.Display
}

pd := display.NewProgressDisplay(w, isTTY, maxDynamic)
pd.Start(files)
pd.Start(displays)

for w := 0; w < workers; w++ {
wg.Add(1)
Expand All @@ -191,16 +196,16 @@ func runFiles(files []string, workers int, isTTY bool, w io.Writer) []fileResult
if !verbose {
for _, r := range results {
if len(r.failures) > 0 {
display.PrintFailureDetails(os.Stdout, r.failures, r.file)
display.PrintFailureDetails(os.Stdout, r.failures, r.display)
}
}
}
return results
}

// processFile handles parsing, filtering, and execution for a single file.
func processFile(cfg *runConfig, f string, idx int, pd *display.ProgressDisplay) fileResult {
parsed, err := loadAndParse(f, varFlags.values)
func processFile(cfg *runConfig, f resolve.ScanFile, idx int, pd *display.ProgressDisplay) fileResult {
parsed, err := loadAndParse(f.Path, varFlags.values)
if err != nil {
errOutput := fmt.Sprintf("%s%v%s\n", display.ColorRed, err, display.ColorReset)
pd.FileError(idx, errOutput)
Expand All @@ -211,35 +216,35 @@ func processFile(cfg *runConfig, f string, idx int, pd *display.ProgressDisplay)
if cfg.FailFast {
cfg.Cancelled.Store(true)
}
return fileResult{fail: 1, file: f}
return fileResult{fail: 1, display: f.Display}
}

if parsed.Directives.Skip {
pd.UpdateProgress(idx, 0, 0)
pd.FinishFile(idx, true, "")
return fileResult{skip: len(parsed.Entries), file: f}
return fileResult{skip: len(parsed.Entries), display: f.Display}
}

entries := filter.Entries(parsed, groupFlags, excludeGroupFlags)

if len(entries) == 0 {
pd.HideFile(idx)
return fileResult{file: f, hidden: true}
return fileResult{display: f.Display, hidden: true}
}

if verbose {
var buf bytes.Buffer
vd := display.NewVerboseDisplay(&buf, true)
vd.BeginFile(f)
vd.BeginFile(f.Display)
pass, fail, skip := runEntriesVerbose(cfg, vd, pd, idx, entries)
vd.EndFile()
pd.FinishFile(idx, fail == 0, buf.String())
return fileResult{pass: pass, fail: fail, skip: skip, file: f}
return fileResult{pass: pass, fail: fail, skip: skip, display: f.Display}
}

pass, fail, skip, details := runEntriesCompact(cfg, pd, idx, entries)
pd.FinishFile(idx, fail == 0, "")
return fileResult{pass: pass, fail: fail, skip: skip, file: f, failures: details}
return fileResult{pass: pass, fail: fail, skip: skip, display: f.Display, failures: details}
}


11 changes: 11 additions & 0 deletions internal/display/display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,17 @@ func TestVerboseDisplay_ShowsFileHeader(t *testing.T) {
}
}

func TestVerboseDisplay_ShowsFileHeaderWithSubdirPath(t *testing.T) {
var buf bytes.Buffer
d := NewVerboseDisplay(&buf, false)
d.BeginFile("orders/import/it_handles_order.clitest")

output := buf.String()
if !strings.Contains(output, "▶ orders/import/it_handles_order.clitest") {
t.Errorf("expected full display path (not stripped to basename), got:\n%s", output)
}
}

func TestCountLines(t *testing.T) {
tests := []struct {
input string
Expand Down
11 changes: 5 additions & 6 deletions internal/display/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package display
import (
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -64,7 +63,7 @@ func PrintHeader(w io.Writer, version string, resolved []ResolvedArg, parallel i
fmt.Fprintln(w)
}

// PrintSummary prints the test run summary.
// PrintSummary prints the test run summary. failedFiles are display names.
func PrintSummary(w io.Writer, totalPass, totalFail, totalSkip, totalWarnings int, failedFiles []string, elapsed time.Duration) {
fmt.Fprintf(w, "%s━━━ Summary ━━━%s\n", ColorBold, ColorReset)
fmt.Fprintf(w, " %spass: %d%s\n", ColorGreen, totalPass, ColorReset)
Expand All @@ -77,15 +76,15 @@ func PrintSummary(w io.Writer, totalPass, totalFail, totalSkip, totalWarnings in
if totalFail > 0 {
fmt.Fprintf(w, " %sfail: %d%s\n", ColorRed, totalFail, ColorReset)
for _, f := range failedFiles {
fmt.Fprintf(w, " %s - %s%s\n", ColorRed, filepath.Base(f), ColorReset)
fmt.Fprintf(w, " %s - %s%s\n", ColorRed, f, ColorReset)
}
}
fmt.Fprintf(w, " %stook: %s%s\n", ColorGray, FormatDuration(elapsed), ColorReset)
}

// PrintFailureDetails prints detailed failure output for compact mode.
func PrintFailureDetails(w io.Writer, failures []CompactFailure, file string) {
fmt.Fprintf(w, "%s▶ %s%s\n", ColorBold, filepath.Base(file), ColorReset)
// PrintFailureDetails prints detailed failure output for compact mode. name is the display name.
func PrintFailureDetails(w io.Writer, failures []CompactFailure, name string) {
fmt.Fprintf(w, "%s▶ %s%s\n", ColorBold, name, ColorReset)
for _, f := range failures {
fmt.Fprintf(w, " %s✗%s %s\n", ColorRed, ColorReset, TruncateCmd(f.Command, 60))
for _, msg := range f.Failures {
Expand Down
11 changes: 6 additions & 5 deletions internal/display/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package display
import (
"fmt"
"io"
"path/filepath"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -67,13 +66,15 @@ func NewProgressDisplay(w io.Writer, dynamic bool, maxDynamic int) *ProgressDisp
return &ProgressDisplay{w: w, dynamic: dynamic, maxDynamic: maxDynamic}
}

func (d *ProgressDisplay) Start(files []string) {
// Start initializes the display list. names are the display names shown to
// the user (not necessarily the paths used to read the files).
func (d *ProgressDisplay) Start(names []string) {
d.mu.Lock()
defer d.mu.Unlock()

d.files = make([]fileState, len(files))
for i, f := range files {
d.files[i] = fileState{name: filepath.Base(f), status: StatusRun, startTime: time.Now()}
d.files = make([]fileState, len(names))
for i, name := range names {
d.files[i] = fileState{name: name, status: StatusRun, startTime: time.Now()}
}
}

Expand Down
6 changes: 3 additions & 3 deletions internal/display/verbose.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package display
import (
"fmt"
"io"
"path/filepath"
"strings"
)

Expand All @@ -21,8 +20,9 @@ func NewVerboseDisplay(w io.Writer, verbose bool) *VerboseDisplay {
return &VerboseDisplay{w: w, verbose: verbose}
}

func (d *VerboseDisplay) BeginFile(filename string) {
fmt.Fprintf(d.w, "%s▶ %s%s\n", ColorBold, filepath.Base(filename), ColorReset)
// BeginFile prints the file header. name is the display name shown to the user.
func (d *VerboseDisplay) BeginFile(name string) {
fmt.Fprintf(d.w, "%s▶ %s%s\n", ColorBold, name, ColorReset)
}

func (d *VerboseDisplay) EntryResult(info EntryInfo) {
Expand Down
34 changes: 25 additions & 9 deletions internal/resolve/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ type ResolvedArg struct {
Count int
}

// ScanFile is a resolved .clitest file. Path is used to read the file;
// Display is the name shown to the user, which for files found while
// recursively scanning a directory is the path relative to that directory
// (disambiguating same-named files in different subdirectories).
type ScanFile struct {
Path string
Display string
}

// Files resolves CLI arguments into .clitest file paths.
// It handles individual files, directories (recursively or not), and glob patterns.
// Warnings are returned (not printed) so callers can decide how to display them.
func Files(args []string, recursive bool) (files []string, resolved []ResolvedArg, warnings []string, err error) {
func Files(args []string, recursive bool) (files []ScanFile, resolved []ResolvedArg, warnings []string, err error) {
for _, arg := range args {
countBefore := len(files)

Expand All @@ -35,11 +44,11 @@ func Files(args []string, recursive bool) (files []string, resolved []ResolvedAr
resolved = append(resolved, ResolvedArg{Input: arg, Count: len(files) - countBefore})
}
}
sort.Strings(files)
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
return files, resolved, warnings, nil
}

func resolveGlobArg(pattern string, recursive bool, files, warnings []string) (outFiles, outWarnings []string, err error) {
func resolveGlobArg(pattern string, recursive bool, files []ScanFile, warnings []string) (outFiles []ScanFile, outWarnings []string, err error) {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, warnings, err
Expand All @@ -59,13 +68,13 @@ func resolveGlobArg(pattern string, recursive bool, files, warnings []string) (o
warnings = append(warnings, fmt.Sprintf("Warning: skipping non-.clitest file: %s", m))
continue
}
files = append(files, m)
files = append(files, ScanFile{Path: m, Display: filepath.Base(m)})
}
}
return files, warnings, nil
}

func resolvePathArg(arg string, recursive bool, files, warnings []string) (result []string, skipped bool, w []string, err error) {
func resolvePathArg(arg string, recursive bool, files []ScanFile, warnings []string) (result []ScanFile, skipped bool, w []string, err error) {
info, err := os.Stat(arg)
if err != nil {
return nil, false, warnings, err
Expand All @@ -78,17 +87,21 @@ func resolvePathArg(arg string, recursive bool, files, warnings []string) (resul
warnings = append(warnings, fmt.Sprintf("Warning: skipping non-.clitest file: %s", arg))
return files, true, warnings, nil
}
return append(files, arg), false, warnings, nil
return append(files, ScanFile{Path: arg, Display: filepath.Base(arg)}), false, warnings, nil
}

func collectFromDir(dir string, recursive bool, files []string) ([]string, error) {
func collectFromDir(dir string, recursive bool, files []ScanFile) ([]ScanFile, error) {
if recursive {
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(path, ".clitest") {
files = append(files, path)
display, relErr := filepath.Rel(dir, path)
if relErr != nil {
display = filepath.Base(path)
}
files = append(files, ScanFile{Path: path, Display: display})
}
return nil
})
Expand All @@ -98,5 +111,8 @@ func collectFromDir(dir string, recursive bool, files []string) ([]string, error
if err != nil {
return nil, err
}
return append(files, matches...), nil
for _, m := range matches {
files = append(files, ScanFile{Path: m, Display: filepath.Base(m)})
}
return files, nil
}
24 changes: 24 additions & 0 deletions internal/resolve/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,27 @@ func TestFiles_Directory(t *testing.T) {
t.Fatalf("expected 2 files (non-recursive), got %d", len(files))
}
}

func TestFiles_DisplayRelativeToScanRoot(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "flat.clitest"), []byte("$ echo hi\n"), 0o644)
os.MkdirAll(filepath.Join(dir, "orders", "import"), 0o755)
os.WriteFile(filepath.Join(dir, "orders", "import", "nested.clitest"), []byte("$ echo hi\n"), 0o644)

files, _, _, err := Files([]string{dir}, true)
if err != nil {
t.Fatal(err)
}

displays := make(map[string]bool)
for _, f := range files {
displays[f.Display] = true
}

if !displays["flat.clitest"] {
t.Errorf("expected flat file displayed as basename, got %v", displays)
}
if !displays[filepath.Join("orders", "import", "nested.clitest")] {
t.Errorf("expected nested file displayed relative to scan root, got %v", displays)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Nested files are displayed by their path relative to the scan root, not just the basename
./clitest --no-parallel --no-color test/_fixtures/resolve
EXIT 0
[asserts]
stdout contains "deep/deep.clitest"
stdout contains "hello.clitest"
stdout not contains "- deep.clitest ("