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
56 changes: 56 additions & 0 deletions internal/output/inline.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package output
import (
"fmt"
"io"
"strings"

"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/term"
Expand Down Expand Up @@ -74,6 +75,61 @@ func (p *Printer) clearInlineBeforeDraw() {
eraseInlineLines(p.stderr, p.inlineEraseLineCount())
}

// ShowTransientNotice prints a short multi-line block that ClearTransientNotice can
// erase on a TTY. Use for Ctrl+C hints that should not remain in the iteration table.
func (p *Printer) ShowTransientNotice(text string) {
if p.aiOutput {
return
}
text = strings.TrimRight(text, "\n")
if text == "" {
return
}
p.ClearTransientNotice()
p.ClearInline()
if !p.liveInline {
for part := range strings.SplitSeq(text, "\n") {
_, _ = fmt.Fprintln(p.stderr, part)
}
return
}
cols := p.termColumns()
parts := strings.Split(text, "\n")
lines := 0
for i, part := range parts {
if i > 0 {
_, _ = fmt.Fprint(p.stderr, "\n")
}
_, _ = fmt.Fprint(p.stderr, part)
w := inlineVisualLines(part, cols)
if w < 1 && part != "" {
w = 1
}
lines += w
}
if lines < 1 {
lines = len(parts)
}
p.transientNoticeLines = lines
}

// ClearTransientNotice removes the last ShowTransientNotice block from a TTY.
func (p *Printer) ClearTransientNotice() {
lines := p.transientNoticeLines
if lines <= 0 {
return
}
if p.liveInline {
eraseInlineLines(p.stderr, lines)
if lines > 1 {
// eraseInlineLines leaves the cursor on the last cleared row; move back to
// the first so the next stderr write replaces the block without a gap.
_, _ = fmt.Fprintf(p.stderr, "\033[%dA", lines-1)
}
}
p.transientNoticeLines = 0
}

// TermColumns returns stderr width for progress fitting (defaults to 80 when unknown).
func (p *Printer) TermColumns() int {
return p.termColumns()
Expand Down
15 changes: 15 additions & 0 deletions internal/output/inline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,21 @@ func TestInlineEraseLineCount_usesCurrentWidthAfterShrink(t *testing.T) {
require.GreaterOrEqual(t, p.inlineEraseLineCount(), 3)
}

func TestClearTransientNotice_beforeTableRow(t *testing.T) {
t.Parallel()
var stderr strings.Builder
out := NewForTest(false, io.Discard, &stderr, true)
out.ShowTransientNotice("Stopping diagnose run\nPress Ctrl+C again")
require.Equal(t, 2, out.TransientNoticeLinesForTest())
out.ClearTransientNotice()
out.HumanStderr(" 2 pass")
got := stderr.String()
require.NotContains(t, got, "Press Ctrl+C again 2")
require.NotContains(t, got, "Stopping diagnose run\n\n 2")
require.Contains(t, got, " 2 pass\n")
require.Contains(t, got, "\x1b[1A")
}

func TestClearInline_afterShrink_clearsReflowRows(t *testing.T) {
t.Parallel()
var stderr strings.Builder
Expand Down
24 changes: 15 additions & 9 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ import (
// Printer writes CLI messages for tools/test. Child processes (go test) still
// attach os.Stdout/os.Stderr directly where passthrough is intended.
type Printer struct {
aiOutput bool
stdout io.Writer
stderr io.Writer
stderrFD uintptr
liveInline bool // human mode and stderr is a TTY (safe for \r progress)
inlineLastLines int
inlineLastCols int // terminal width when inlineLastLine was drawn
inlineLastLine string // last RedrawInline payload (for resize-aware erase)
testTermColumns int // when >0, overrides termColumns (tests only)
aiOutput bool
stdout io.Writer
stderr io.Writer
stderrFD uintptr
liveInline bool // human mode and stderr is a TTY (safe for \r progress)
inlineLastLines int
inlineLastCols int // terminal width when inlineLastLine was drawn
inlineLastLine string // last RedrawInline payload (for resize-aware erase)
transientNoticeLines int // erasable block from ShowTransientNotice (TTY only)
testTermColumns int // when >0, overrides termColumns (tests only)
}

// New builds a production Printer. liveInline is enabled when stderrFD points
Expand Down Expand Up @@ -134,3 +135,8 @@ func (p *Printer) ClearInline() {
p.clearInlineBeforeDraw()
p.resetInlineState()
}

// TransientNoticeLinesForTest returns erasable notice line count after ShowTransientNotice.
func (p *Printer) TransientNoticeLinesForTest() int {
return p.transientNoticeLines
}
42 changes: 31 additions & 11 deletions internal/runner/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,13 +653,15 @@ func buildReportSummary(rep *Report, aggs map[testKey]*aggregate, slowThreshold
// IterationDigest summarizes one iteration JSONL log for per-iteration CLI output.
// Counts match a single-iteration Analyze (same rules as the final report).
type IterationDigest struct {
Result string // pass, fail, timeout
RanTests int // distinct named tests (package.test) that executed (pass/fail/timeout), excluding skip-only
FailTests int // len(IterationSummaries[0].FailingTests)
TimeoutTests int // len(Timeouts) for this iteration
SkipTests int // distinct named tests skipped in this iteration
SlowTests int // tests over slow threshold
BuildFailure bool // compile/build failed or heuristic package-level fail with no named tests run
Result string // pass, fail, timeout
RanTests int // distinct named tests (package.test) that executed (pass/fail/timeout), excluding skip-only
FailTests int // len(IterationSummaries[0].FailingTests)
TimeoutTests int // len(Timeouts) for this iteration
SkipTests int // distinct named tests skipped in this iteration
SlowTests int // tests over slow threshold
BuildFailure bool // compile/build failed or heuristic package-level fail with no named tests run
FailingTests []string // named tests / packages that failed this iteration
TimedOutTests []string // named tests that timed out this iteration
}

// countNamedTestsRanInAggs counts distinct non-empty test keys that recorded
Expand Down Expand Up @@ -726,13 +728,31 @@ func iterationDigestFromReport(rep *Report) IterationDigest {
slowTests = rep.Summary.SlowCount
}
return IterationDigest{
Result: s.Result,
FailTests: len(s.FailingTests),
SlowTests: slowTests,
TimeoutTests: len(rep.Timeouts),
Result: s.Result,
FailTests: len(s.FailingTests),
SlowTests: slowTests,
TimeoutTests: len(rep.Timeouts),
FailingTests: append([]string(nil), s.FailingTests...),
TimedOutTests: timedOutTestNamesFromReport(rep),
}
}

func timedOutTestNamesFromReport(rep *Report) []string {
if len(rep.Timeouts) == 0 {
return nil
}
names := make([]string, 0, len(rep.Timeouts))
for _, e := range rep.Timeouts {
name := e.Test
if name == "" {
name = e.Package
}
names = append(names, name)
}
sort.Strings(names)
return names
}

// AnalyzeResults opens every `iteration-*.log.jsonl` file in resultsDir, in
// numeric-iteration order, and delegates to Analyze.
func AnalyzeResults(resultsDir string, slowThreshold time.Duration) (*Report, LogMap, error) {
Expand Down
17 changes: 14 additions & 3 deletions internal/runner/analyze_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,19 @@ func TestDigestIterationJSONL(t *testing.T) {
assert.Equal(t, "fail", d.Result)
assert.Equal(t, 0, d.RanTests)
assert.Equal(t, 1, d.FailTests)
assert.Equal(t, []string{"pkg/build"}, d.FailingTests)
assert.True(t, d.BuildFailure)
})

t.Run("named test fail", func(t *testing.T) {
t.Parallel()
failJSON := `{"Action":"fail","Package":"pkg/a","Test":"TestX","Elapsed":0.1}` + "\n"
d, err := DigestIterationJSONL(strings.NewReader(failJSON), 30*time.Second)
require.NoError(t, err)
assert.Equal(t, "fail", d.Result)
assert.Equal(t, []string{"TestX"}, d.FailingTests)
})

t.Run("failed_build_field", func(t *testing.T) {
t.Parallel()
jsonl := `{"Action":"fail","Package":"example.com/badpkg","Elapsed":0,"FailedBuild":"example.com/badpkg.test"}` + "\n"
Expand All @@ -216,14 +226,15 @@ func TestDigestIterationJSONL(t *testing.T) {

t.Run("timeout", func(t *testing.T) {
t.Parallel()
toJSON := `{"Action":"output","Package":"pkg/hang","Output":"panic: test timed out after 2m0s\n"}
{"Action":"fail","Package":"pkg/hang","Elapsed":120.0}
toJSON := `{"Action":"output","Package":"pkg/hang","Test":"TestHang","Output":"panic: test timed out after 2m0s\n"}
{"Action":"fail","Package":"pkg/hang","Test":"TestHang","Elapsed":120.0}
`
d, err := DigestIterationJSONL(strings.NewReader(toJSON), 30*time.Second)
require.NoError(t, err)
assert.Equal(t, "timeout", d.Result)
assert.Equal(t, 0, d.RanTests)
assert.Equal(t, 1, d.RanTests)
assert.GreaterOrEqual(t, d.TimeoutTests, 1)
assert.Equal(t, []string{"TestHang"}, d.TimedOutTests)
})

t.Run("two named tests", func(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions internal/runner/diagnose_interrupt_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build !unix

package runner

func isInterruptedIteration(iterErr error) bool {
return false
}
32 changes: 32 additions & 0 deletions internal/runner/diagnose_interrupt_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//go:build unix

package runner

import (
"context"
"errors"
"os/exec"
"syscall"
)

// isInterruptedIteration reports whether go test exited due to SIGINT/SIGTERM
// rather than a compile/build failure. Interrupt output is often misclassified
// as a build failure when RanTests is zero.
func isInterruptedIteration(iterErr error) bool {
if iterErr == nil {
return false
}
if errors.Is(iterErr, context.Canceled) {
return true
}
var exitErr *exec.ExitError
if !errors.As(iterErr, &exitErr) {
return false
}
st, ok := exitErr.Sys().(syscall.WaitStatus)
if !ok || !st.Signaled() {
return false
}
sig := st.Signal()
return sig == syscall.SIGINT || sig == syscall.SIGTERM
}
9 changes: 8 additions & 1 deletion internal/runner/diagnose_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ func printDiagnoseGracefulStopNotice(out *output.Printer, completed, total int)
}
out.ClearInline()
hint := diagnoseInterruptKeyHint()
out.HumanStderr(
out.ShowTransientNotice(
termstyle.Accent.Render(
fmt.Sprintf("Stopping diagnose run after current iteration — %d/%d completed.", completed, total),
) + "\n" +
Expand All @@ -182,3 +182,10 @@ func printDiagnoseGracefulStopNotice(out *output.Printer, completed, total int)
),
)
}

func clearDiagnoseGracefulStopNotice(out *output.Printer) {
if out == nil {
return
}
out.ClearTransientNotice()
}
7 changes: 7 additions & 0 deletions internal/runner/diagnose_process_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build !unix

package runner

import "os/exec"

func isolateDiagnoseChildProcessGroup(cmd *exec.Cmd) {}
18 changes: 18 additions & 0 deletions internal/runner/diagnose_process_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build unix

package runner

import (
"os/exec"
"syscall"
)

// isolateDiagnoseChildProcessGroup puts each go test child in its own process
// group so a terminal SIGINT (first Ctrl+C) stops testrig only, not in-flight
// test runs. Hard cancel still signals the child explicitly via cmd.Cancel.
func isolateDiagnoseChildProcessGroup(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
Loading
Loading