diff --git a/internal/services/covgate/covgate.go b/internal/services/covgate/covgate.go index 444cbcf..455f14b 100644 --- a/internal/services/covgate/covgate.go +++ b/internal/services/covgate/covgate.go @@ -5,6 +5,7 @@ import ( "io" "os" "runtime" + "strconv" "strings" "sync" "time" @@ -89,15 +90,7 @@ func (r *runner) run(opts Opts) error { return err } - if r.emitProgress { - _, _ = fmt.Fprintf( - w, "Running %d packages with parallelism=%d; "+ - "progress will appear as packages finish:\n", - len(pkgs), parallelism, - ) - } - - printHeader(w) + r.writeRunHeader(w, len(pkgs), parallelism) ctx := checkPackageCtx{ module: module, @@ -169,6 +162,8 @@ func (r *runner) runPackages( sem := make(chan struct{}, parallelism) var wg sync.WaitGroup var progressMu sync.Mutex + countWidth := len(strconv.Itoa(total)) + colWidth := progressColWidth(total) for i, pkg := range pkgs { wg.Add(1) @@ -178,13 +173,11 @@ func (r *runner) runPackages( defer func() { <-sem }() results[idx] = r.checkPackage(p, ctx) if r.emitProgress { + label := fmt.Sprintf("[%*d/%d]", countWidth, idx+1, total) progressMu.Lock() _, _ = fmt.Fprintf( - w, "[%d/%d] %s %s %s\n", - idx+1, total, - progressStatus(results[idx].output), - gocover.RelPkg(p, ctx.module), - fmtDuration(results[idx].duration), + w, "%-*s %s", + colWidth, label, firstLine(results[idx].output), ) progressMu.Unlock() } @@ -194,10 +187,36 @@ func (r *runner) runPackages( return results } -// progressStatus extracts the first whitespace-delimited token of -// the result's first output line, which is the status keyword -// (PASS, FAIL, LOOSE) printed by checkPackage. -func progressStatus(output string) string { return strings.Fields(output)[0] } +// writeRunHeader writes the announce line and the progress table +// header when progress is enabled; otherwise it writes the +// standard table header. +func (r *runner) writeRunHeader(w io.Writer, total, parallelism int) { + if !r.emitProgress { + printHeader(w) + return + } + _, _ = fmt.Fprintf( + w, "Running %d packages with parallelism=%d; progress:\n", + total, parallelism, + ) + printProgressHeader(w, total) +} + +// progressColWidth returns the width of the COUNT column for total +// packages. The value 3 + 2*ndigits is always >= 5 ("COUNT") for +// total >= 0, so no clamp is needed. +func progressColWidth(total int) int { return 3 + 2*len(strconv.Itoa(total)) } + +// firstLine returns the first line of s, including the trailing +// newline. s must contain at least one '\n' — every checkResult's +// output is built from a "%...\n" format string, so this holds. +func firstLine(s string) string { return s[:strings.IndexByte(s, '\n')+1] } + +// restOfOutput returns everything after the first '\n' in s. Used +// in progress mode to preserve trailing FAIL detail (multi-line +// raw test output) without reprinting the row that was already +// streamed. Same '\n' precondition as firstLine. +func restOfOutput(s string) string { return s[strings.IndexByte(s, '\n')+1:] } func (r *runner) printResults( w io.Writer, @@ -208,14 +227,23 @@ func (r *runner) printResults( ) error { hasFailures := false for _, res := range results { - _, _ = fmt.Fprint(w, res.output) + if r.emitProgress { + _, _ = fmt.Fprint(w, restOfOutput(res.output)) + } else { + _, _ = fmt.Fprint(w, res.output) + } if !res.passed { hasFailures = true } } + indent := "" + if r.emitProgress { + indent = strings.Repeat(" ", progressColWidth(len(results))) + " " + } for _, pkg := range excluded { - _, _ = fmt.Fprint(w, skippedRow(gocover.RelPkg(pkg, module))) + row := skippedRow(gocover.RelPkg(pkg, module)) + _, _ = fmt.Fprintf(w, "%s%s", indent, row) } _, _ = fmt.Fprintf(w, "\nTotal time: %s\n", fmtDuration(totalTime)) @@ -243,6 +271,25 @@ func printHeader(w io.Writer) { ) } +// printProgressHeader prints the table header used during the live +// progress stream. It adds a leading COUNT column ahead of the +// standard columns so each progress line carries an [N/total] tag +// aligned under "COUNT". +func printProgressHeader(w io.Writer, total int) { + cw := progressColWidth(total) + dashes := strings.Repeat("-", cw) + _, _ = fmt.Fprintf( + w, "%-*s %-7s %8s %8s %8s %s\n", + cw, "COUNT", + "STATUS", "COVERAGE", "REQUIRED", "TIME", "PACKAGE", + ) + _, _ = fmt.Fprintf( + w, "%-*s %-7s %8s %8s %8s %s\n", + cw, dashes, + "-------", "--------", "--------", "--------", "-------", + ) +} + // checkResult holds the output and pass/fail status for a single package check. type checkResult struct { output string // formatted line(s) to print diff --git a/internal/services/covgate/covgate_test.go b/internal/services/covgate/covgate_test.go index 864e9ec..80409eb 100644 --- a/internal/services/covgate/covgate_test.go +++ b/internal/services/covgate/covgate_test.go @@ -497,18 +497,35 @@ func TestRun_EmitsProgress_WhenAutoParallelism(t *testing.T) { t.Fatalf("unexpected error: %v", err) } out := buf.String() - if !strings.Contains(out, "[1/2]") { - t.Errorf("output missing [1/2] progress line:\n%s", out) + if !hasProgressLineWith(out, "[1/2]", "90.0%") { + t.Errorf( + "expected [1/2] progress line to include coverage "+ + "data (90.0%%):\n%s", out, + ) + } + if !hasProgressLineWith(out, "[2/2]", "90.0%") { + t.Errorf( + "expected [2/2] progress line to include coverage "+ + "data (90.0%%):\n%s", out, + ) + } + if !strings.Contains(out, "COUNT") { + t.Errorf("expected progress header to include COUNT column:\n%s", out) } - if !strings.Contains(out, "[2/2]") { - t.Errorf("output missing [2/2] progress line:\n%s", out) + // STATUS header should appear exactly once — the progress + // stream is the single canonical table. + if got := strings.Count(out, "STATUS"); got != 1 { + t.Errorf( + "expected exactly one STATUS header (progress IS "+ + "the table); got %d:\n%s", got, out, + ) } - // Leading announcement should appear before the STATUS header. + // Leading announcement should appear before the COUNT header. idxAnnounce := strings.Index(out, "Running 2 packages with parallelism=") - idxHeader := strings.Index(out, "STATUS") + idxHeader := strings.Index(out, "COUNT") if idxAnnounce < 0 || idxHeader < 0 || idxAnnounce >= idxHeader { t.Errorf( - "expected leading announcement before STATUS header "+ + "expected leading announcement before COUNT header "+ "(announce=%d, header=%d):\n%s", idxAnnounce, idxHeader, out, ) @@ -527,6 +544,19 @@ func TestRun_EmitsProgress_WhenAutoParallelism(t *testing.T) { } } +// hasProgressLineWith reports whether out contains a single line +// that matches both the count marker and an additional substring +// (used to verify progress lines carry full row data, not just the +// counter). +func hasProgressLineWith(out, marker, contains string) bool { + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, marker) && strings.Contains(line, contains) { + return true + } + } + return false +} + func TestRun_SuppressesProgress_WhenExplicitParallelism(t *testing.T) { tmp := t.TempDir() t.Chdir(tmp) @@ -557,9 +587,18 @@ func TestRun_SuppressesProgress_WhenExplicitParallelism(t *testing.T) { if strings.Contains(out, "[1/2]") || strings.Contains(out, "[2/2]") { t.Errorf("output unexpectedly contains progress prefix:\n%s", out) } - if strings.Contains(out, "progress will appear") { + if strings.Contains(out, "Running 2 packages with parallelism=") { t.Errorf("output unexpectedly contains leading announcement:\n%s", out) } + if strings.Contains(out, "COUNT") { + t.Errorf("output unexpectedly contains COUNT progress header:\n%s", out) + } + if got := strings.Count(out, "STATUS"); got != 1 { + t.Errorf( + "expected exactly one STATUS header (no progress "+ + "section); got %d:\n%s", got, out, + ) + } } func TestMeasure_Pass(t *testing.T) { diff --git a/internal/services/covratchet/covratchet.go b/internal/services/covratchet/covratchet.go index 5e2e73a..7f9572b 100644 --- a/internal/services/covratchet/covratchet.go +++ b/internal/services/covratchet/covratchet.go @@ -74,16 +74,7 @@ func (r *runner) run(opts Opts) error { return err } - if r.emitProgress { - _, _ = fmt.Fprintf( - w, "Running %d packages with parallelism=%d; "+ - "progress will appear as packages finish:\n", - len(pkgs), parallelism, - ) - } - - printHeader(w) - + r.writeRunHeader(w, len(pkgs), parallelism) ctx := ratchetCtx{ module: module, srcPrefix: opts.SrcPrefix, @@ -91,11 +82,11 @@ func (r *runner) run(opts Opts) error { } results := r.runPackages(pkgs, ctx, parallelism, w) - updated := 0 - unchanged := 0 - failed := 0 + updated, unchanged, failed := 0, 0, 0 for _, res := range results { - _, _ = fmt.Fprint(w, res.output) + if !r.emitProgress { + _, _ = fmt.Fprint(w, res.output) + } updated += res.updated unchanged += res.unchanged failed += res.failed @@ -112,6 +103,21 @@ func (r *runner) run(opts Opts) error { return nil } +// writeRunHeader writes the announce line and the progress table +// header when progress is enabled; otherwise it writes the +// standard table header. +func (r *runner) writeRunHeader(w io.Writer, total, parallelism int) { + if !r.emitProgress { + printHeader(w) + return + } + _, _ = fmt.Fprintf( + w, "Running %d packages with parallelism=%d; progress:\n", + total, parallelism, + ) + printProgressHeader(w, total) +} + func printHeader(w io.Writer) { _, _ = fmt.Fprintf( w, "%-6s %8s %8s %s\n", @@ -123,6 +129,33 @@ func printHeader(w io.Writer) { ) } +// printProgressHeader prints the table header used during the live +// progress stream. It adds a leading COUNT column ahead of the +// standard columns so each progress line carries an [N/total] tag +// aligned under "COUNT". +func printProgressHeader(w io.Writer, total int) { + cw := progressColWidth(total) + dashes := strings.Repeat("-", cw) + _, _ = fmt.Fprintf( + w, "%-*s %-6s %8s %8s %s\n", + cw, "COUNT", "STATUS", "PREVIOUS", "CURRENT", "PACKAGE", + ) + _, _ = fmt.Fprintf( + w, "%-*s %-6s %8s %8s %s\n", + cw, dashes, "------", "--------", "-------", "-------", + ) +} + +// progressColWidth returns the width of the COUNT column for total +// packages. The value 3 + 2*ndigits is always >= 5 ("COUNT") for +// total >= 0, so no clamp is needed. +func progressColWidth(total int) int { return 3 + 2*len(strconv.Itoa(total)) } + +// firstLine returns the first line of s, including the trailing +// newline. s must contain at least one '\n' — every ratchetResult's +// output is built from a "%...\n" format string, so this holds. +func firstLine(s string) string { return s[:strings.IndexByte(s, '\n')+1] } + // ratchetCtx holds the per-run constants threaded into ratchetPackage. type ratchetCtx struct { module string @@ -138,6 +171,8 @@ func (r *runner) runPackages( sem := make(chan struct{}, parallelism) var wg sync.WaitGroup var progressMu sync.Mutex + countWidth := len(strconv.Itoa(total)) + colWidth := progressColWidth(total) for i, pkg := range pkgs { wg.Add(1) @@ -147,12 +182,11 @@ func (r *runner) runPackages( defer func() { <-sem }() results[idx] = r.ratchetPackage(p, ctx.module, ctx.srcPrefix, ctx.testDir) if r.emitProgress { + label := fmt.Sprintf("[%*d/%d]", countWidth, idx+1, total) progressMu.Lock() _, _ = fmt.Fprintf( - w, "[%d/%d] %s %s\n", - idx+1, total, - progressStatus(results[idx].output), - gocover.RelPkg(p, ctx.module), + w, "%-*s %s", + colWidth, label, firstLine(results[idx].output), ) progressMu.Unlock() } @@ -162,11 +196,6 @@ func (r *runner) runPackages( return results } -// progressStatus extracts the first whitespace-delimited token of -// the result's first output line, which is the status keyword -// (NEW, UP, OK, FAIL) printed by ratchetPackage. -func progressStatus(output string) string { return strings.Fields(output)[0] } - // ratchetResult holds the output and counts for a single package ratchet. type ratchetResult struct { output string diff --git a/internal/services/covratchet/covratchet_test.go b/internal/services/covratchet/covratchet_test.go index ee8b77b..ba71b4f 100644 --- a/internal/services/covratchet/covratchet_test.go +++ b/internal/services/covratchet/covratchet_test.go @@ -446,17 +446,36 @@ func TestRun_EmitsProgress_WhenAutoParallelism(t *testing.T) { t.Fatalf("unexpected error: %v", err) } out := buf.String() - if !strings.Contains(out, "[1/2]") { - t.Errorf("output missing [1/2] progress line:\n%s", out) + // Each progress line should carry the per-package package + // path (the row's last column), not just the count marker. + if !hasProgressLineWith(out, "[1/2]", "pkg/") { + t.Errorf( + "expected [1/2] progress line to include the package "+ + "path:\n%s", out, + ) + } + if !hasProgressLineWith(out, "[2/2]", "pkg/") { + t.Errorf( + "expected [2/2] progress line to include the package "+ + "path:\n%s", out, + ) + } + if !strings.Contains(out, "COUNT") { + t.Errorf("expected progress header to include COUNT column:\n%s", out) } - if !strings.Contains(out, "[2/2]") { - t.Errorf("output missing [2/2] progress line:\n%s", out) + // STATUS header should appear exactly once — the progress + // stream is the single canonical table. + if got := strings.Count(out, "STATUS"); got != 1 { + t.Errorf( + "expected exactly one STATUS header (progress IS "+ + "the table); got %d:\n%s", got, out, + ) } idxAnnounce := strings.Index(out, "Running 2 packages with parallelism=") - idxHeader := strings.Index(out, "STATUS") + idxHeader := strings.Index(out, "COUNT") if idxAnnounce < 0 || idxHeader < 0 || idxAnnounce >= idxHeader { t.Errorf( - "expected leading announcement before STATUS header "+ + "expected leading announcement before COUNT header "+ "(announce=%d, header=%d):\n%s", idxAnnounce, idxHeader, out, ) @@ -473,6 +492,19 @@ func TestRun_EmitsProgress_WhenAutoParallelism(t *testing.T) { } } +// hasProgressLineWith reports whether out contains a single line +// that matches both the count marker and an additional substring +// (used to verify progress lines carry full row data, not just the +// counter). +func hasProgressLineWith(out, marker, contains string) bool { + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, marker) && strings.Contains(line, contains) { + return true + } + } + return false +} + func TestRun_SuppressesProgress_WhenExplicitParallelism(t *testing.T) { tmp := t.TempDir() t.Chdir(tmp) @@ -503,9 +535,18 @@ func TestRun_SuppressesProgress_WhenExplicitParallelism(t *testing.T) { if strings.Contains(out, "[1/2]") || strings.Contains(out, "[2/2]") { t.Errorf("output unexpectedly contains progress prefix:\n%s", out) } - if strings.Contains(out, "progress will appear") { + if strings.Contains(out, "Running 2 packages with parallelism=") { t.Errorf("output unexpectedly contains leading announcement:\n%s", out) } + if strings.Contains(out, "COUNT") { + t.Errorf("output unexpectedly contains COUNT progress header:\n%s", out) + } + if got := strings.Count(out, "STATUS"); got != 1 { + t.Errorf( + "expected exactly one STATUS header (no progress "+ + "section); got %d:\n%s", got, out, + ) + } } func TestRun_PublicWrapper_HappyPath(t *testing.T) {