diff --git a/README.md b/README.md index 5e0473d..34a4b48 100644 --- a/README.md +++ b/README.md @@ -308,9 +308,16 @@ if summary.HasFailures() { That shape works well in ordinary Go CLIs, Mage targets, Cobra/Fang commands, and small Go helpers invoked from tools like `make`, `just`, or `task`. `laslig` stays responsible for rendering, while the caller stays responsible for process control. Callers can also disable grouped failed-test, skipped-test, package-error, or captured-output sections when they want a tighter stream. +`gotestout` also supports one spinner-only live activity footer for active test +streams. The footer defaults to `auto`, which means styled human terminal +output gets one transient spinner line while the stream is active, while plain, +unstyled human, and JSON output stay stable and non-transient. Callers can +force that footer on with `gotestout.ActivityOn` for demos or disable it +entirely with `gotestout.ActivityOff`. + This repository dogfoods that pattern in [`magefiles/magefile.go`](./magefiles/magefile.go): `mage test` runs `go test -json ./...`, renders compact package and failure output through `gotestout`, and still returns a normal Mage error on failure. The focused runnable example for that package lives in [`examples/gotestout/main.go`](./examples/gotestout/main.go). -The separate Mage-facing example in [`examples/magecheck/main.go`](./examples/magecheck/main.go) now also shows the recommended spinner handoff: keep one transient progress line alive while the command is quiet, then stop it before `gotestout` starts streaming package results. +The separate Mage-facing example in [`examples/magecheck/main.go`](./examples/magecheck/main.go) shows the same passing task-runner path with `gotestout`'s live activity footer enabled while the test stream is active. Common ways to try that surface locally: @@ -321,9 +328,9 @@ mage test The focused `gotestout` GIF and example command intentionally include passing, skipped, and failing test events plus one package build failure. The separate -`magecheck` GIF shows the passing task-runner path plus the spinner-to-stream -handoff before the live package output begins. That keeps the README honest -about both the success path and the failure path. +`magecheck` GIF shows the passing task-runner path with the live activity +footer active during the running stream. That keeps the README honest about +both the success path and the failure path. Future `gotestout` work is about smarter summaries, not basic functionality: clearer buckets for test failures vs package/build failures, @@ -350,7 +357,7 @@ go run ./examples/all --format human --style always mage test ``` -`mage demo` is the normal paced walkthrough entrypoint. `mage test` is the real Mage-facing `gotestout` dogfood path. The `magecheck` focused example demonstrates the recommended spinner handoff before the live test stream. The `go run` forms above show the focused per-item examples directly, while `go run ./examples/all` renders the aggregate example without the paced demo wrapper. +`mage demo` is the normal paced walkthrough entrypoint. `mage test` is the real Mage-facing `gotestout` dogfood path. The `magecheck` focused example demonstrates the live activity footer during the running test stream. The `go run` forms above show the focused per-item examples directly, while `go run ./examples/all` renders the aggregate example without the paced demo wrapper. The README GIFs are generated from the focused VHS tapes under [`docs/vhs/`](./docs/vhs). `mage vhs` renders all tracked tapes so the README stays aligned with the runnable examples. diff --git a/doc.go b/doc.go index 2ffcc85..1635556 100644 --- a/doc.go +++ b/doc.go @@ -25,5 +25,6 @@ // // A specialist gotestout package provides structured rendering for go test // -json streams in Mage targets, ordinary Go CLI commands, and small Go -// helpers invoked from tools such as make or just. +// helpers invoked from tools such as make or just, including an optional live +// spinner footer for styled human terminals while a test stream is active. package laslig diff --git a/docs/vhs/codeblock.gif b/docs/vhs/codeblock.gif index 6bf22ae..376f917 100644 Binary files a/docs/vhs/codeblock.gif and b/docs/vhs/codeblock.gif differ diff --git a/docs/vhs/demo.gif b/docs/vhs/demo.gif index 217ca6f..aac82c7 100644 Binary files a/docs/vhs/demo.gif and b/docs/vhs/demo.gif differ diff --git a/docs/vhs/gotestout.gif b/docs/vhs/gotestout.gif index 4bb4ffb..18b831a 100644 Binary files a/docs/vhs/gotestout.gif and b/docs/vhs/gotestout.gif differ diff --git a/docs/vhs/kv.gif b/docs/vhs/kv.gif index d2faede..30be6fa 100644 Binary files a/docs/vhs/kv.gif and b/docs/vhs/kv.gif differ diff --git a/docs/vhs/list.gif b/docs/vhs/list.gif index a47e184..cdf235d 100644 Binary files a/docs/vhs/list.gif and b/docs/vhs/list.gif differ diff --git a/docs/vhs/logblock.gif b/docs/vhs/logblock.gif index f9e501b..6f39113 100644 Binary files a/docs/vhs/logblock.gif and b/docs/vhs/logblock.gif differ diff --git a/docs/vhs/magecheck.gif b/docs/vhs/magecheck.gif index c480f45..baf47f9 100644 Binary files a/docs/vhs/magecheck.gif and b/docs/vhs/magecheck.gif differ diff --git a/docs/vhs/markdown.gif b/docs/vhs/markdown.gif index 69b4ab9..3af0b6f 100644 Binary files a/docs/vhs/markdown.gif and b/docs/vhs/markdown.gif differ diff --git a/docs/vhs/notice.gif b/docs/vhs/notice.gif index 7540220..09a35a9 100644 Binary files a/docs/vhs/notice.gif and b/docs/vhs/notice.gif differ diff --git a/docs/vhs/panel.gif b/docs/vhs/panel.gif index 5c9b070..347d69a 100644 Binary files a/docs/vhs/panel.gif and b/docs/vhs/panel.gif differ diff --git a/docs/vhs/paragraph.gif b/docs/vhs/paragraph.gif index 465790d..8894a8e 100644 Binary files a/docs/vhs/paragraph.gif and b/docs/vhs/paragraph.gif differ diff --git a/docs/vhs/record.gif b/docs/vhs/record.gif index 4c815d0..5062374 100644 Binary files a/docs/vhs/record.gif and b/docs/vhs/record.gif differ diff --git a/docs/vhs/section.gif b/docs/vhs/section.gif index a923a35..d115baf 100644 Binary files a/docs/vhs/section.gif and b/docs/vhs/section.gif differ diff --git a/docs/vhs/spinner.gif b/docs/vhs/spinner.gif index 133d63e..56043f9 100644 Binary files a/docs/vhs/spinner.gif and b/docs/vhs/spinner.gif differ diff --git a/docs/vhs/statusline.gif b/docs/vhs/statusline.gif index 0f45681..a15c0fd 100644 Binary files a/docs/vhs/statusline.gif and b/docs/vhs/statusline.gif differ diff --git a/docs/vhs/table.gif b/docs/vhs/table.gif index 2bc7bb9..382b662 100644 Binary files a/docs/vhs/table.gif and b/docs/vhs/table.gif differ diff --git a/examples/all/testdata/TestRunArgsHumanStyledGolden.golden b/examples/all/testdata/TestRunArgsHumanStyledGolden.golden index 84ad996..cdb1383 100644 --- a/examples/all/testdata/TestRunArgsHumanStyledGolden.golden +++ b/examples/all/testdata/TestRunArgsHumanStyledGolden.golden @@ -171,7 +171,7 @@ gotestout Use gotestout for attractive, structured go test output when your task runner, CLI command, or Go helper behind make/just should keep owning process control. - This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure. + This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure, and styled human terminals show gotestout's live activity footer while the stream is still active. INFO Mixed fixture demo The example command itself is expected to exit successfully so you can inspect the output shape. @@ -217,7 +217,7 @@ gotestout + Mage Use gotestout inside Mage or small Go helpers behind make, just, or task when you want caller-owned process control with a readable test stream. - The preview below matches this repository's mage check and mage test shape, including the recommended spinner handoff before the live test stream starts. + The preview below matches this repository's mage check and mage test shape, including the live gotestout activity footer while the test stream is still active. Build @@ -226,11 +226,10 @@ Build SUCCESS Built example packages (./examples/...) - [RUNNING] Waiting for first test event - [SUCCESS] Test stream detected - Tests + + INFO Started go test -json (./...) PKG PASS github.com/evanmschultz/laslig (0.00s) PKG PASS github.com/evanmschultz/laslig/examples/all (0.00s) PKG PASS github.com/evanmschultz/laslig/examples/codeblock (0.00s) @@ -268,6 +267,7 @@ Test summary SUCCESS All tests passed 178 tests passed across 23 packages. + Coverage ╭──────────────────────────────────────────────────────────────────╮ diff --git a/examples/all/testdata/TestRunArgsPlainGolden.golden b/examples/all/testdata/TestRunArgsPlainGolden.golden index a7cb9b7..1ce11c6 100644 --- a/examples/all/testdata/TestRunArgsPlainGolden.golden +++ b/examples/all/testdata/TestRunArgsPlainGolden.golden @@ -156,7 +156,7 @@ gotestout Use gotestout for attractive, structured go test output when your task runner, CLI command, or Go helper behind make/just should keep owning process control. - This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure. + This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure, and styled human terminals show gotestout's live activity footer while the stream is still active. [INFO] Mixed fixture demo The example command itself is expected to exit successfully so you can inspect the output shape. @@ -202,7 +202,7 @@ gotestout + Mage Use gotestout inside Mage or small Go helpers behind make, just, or task when you want caller-owned process control with a readable test stream. - The preview below matches this repository's mage check and mage test shape, including the recommended spinner handoff before the live test stream starts. + The preview below matches this repository's mage check and mage test shape, including the live gotestout activity footer while the test stream is still active. Build @@ -211,11 +211,10 @@ Build [SUCCESS] Built example packages (./examples/...) - [RUNNING] Waiting for first test event - [SUCCESS] Test stream detected - Tests + + [INFO] Started go test -json (./...) [PKG PASS] github.com/evanmschultz/laslig (0.00s) [PKG PASS] github.com/evanmschultz/laslig/examples/all (0.00s) [PKG PASS] github.com/evanmschultz/laslig/examples/codeblock (0.00s) @@ -253,6 +252,7 @@ Test summary [SUCCESS] All tests passed 178 tests passed across 23 packages. + Coverage package | cover diff --git a/examples/gotestout/testdata/TestRunArgsPlainGolden.golden b/examples/gotestout/testdata/TestRunArgsPlainGolden.golden index 9351fcf..ad95d67 100644 --- a/examples/gotestout/testdata/TestRunArgsPlainGolden.golden +++ b/examples/gotestout/testdata/TestRunArgsPlainGolden.golden @@ -3,7 +3,7 @@ gotestout Use gotestout for attractive, structured go test output when your task runner, CLI command, or Go helper behind make/just should keep owning process control. - This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure. + This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure, and styled human terminals show gotestout's live activity footer while the stream is still active. [INFO] Mixed fixture demo The example command itself is expected to exit successfully so you can inspect the output shape. diff --git a/examples/magecheck/main_test.go b/examples/magecheck/main_test.go index 6b0a64d..e04dd50 100644 --- a/examples/magecheck/main_test.go +++ b/examples/magecheck/main_test.go @@ -7,11 +7,11 @@ import ( ) func TestRunArgsPlain(t *testing.T) { - exampletestutil.AssertRunArgsPlainContains(t, runArgs, "gotestout + Mage", "[RUNNING] Waiting for first test event", "[SUCCESS] Test stream detected", "Coverage threshold met") + exampletestutil.AssertRunArgsPlainContains(t, runArgs, "gotestout + Mage", "[INFO] Started go test -json (./...)", "Coverage threshold met") } func TestRunArgsHumanStyled(t *testing.T) { - exampletestutil.AssertRunArgsHumanStyled(t, runArgs, "Waiting for first test event", "Test stream detected", "All tests passed") + exampletestutil.AssertRunArgsHumanStyled(t, runArgs, "Started go test -json", "All tests passed") } func TestRunArgsInvalidFlag(t *testing.T) { @@ -19,5 +19,5 @@ func TestRunArgsInvalidFlag(t *testing.T) { } func TestMain(t *testing.T) { - exampletestutil.AssertMainContains(t, main, "magecheck-example", "Test stream detected", "Coverage threshold met") + exampletestutil.AssertMainContains(t, main, "magecheck-example", "Started go test -json", "Coverage threshold met") } diff --git a/gotestout/activity.go b/gotestout/activity.go new file mode 100644 index 0000000..e0923aa --- /dev/null +++ b/gotestout/activity.go @@ -0,0 +1,349 @@ +package gotestout + +import ( + "fmt" + "io" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" + + "github.com/evanmschultz/laslig" +) + +const defaultActivityDelay = 750 * time.Millisecond +const defaultActivityInterval = 100 * time.Millisecond +const activityClearLine = "\r\x1b[2K" + +type activityState struct { + text string + frames []string + frame int + delay time.Duration + startedAt time.Time + currentPkg string + currentTest string + testsPassed int + testsFailed int + testsSkipped int + pkgsPassed int + pkgsFailed int + pkgsSkipped int + shown bool + stopCh chan struct{} + doneCh chan struct{} + err error +} + +func (r *Renderer) startActivity() error { + if !r.shouldShowActivity() { + return nil + } + + delay := r.options.Activity.Delay + if delay == 0 { + delay = defaultActivityDelay + } + + r.activity = &activityState{ + text: strings.TrimSpace(r.options.Activity.Text), + frames: activityFrames(r.options.Activity.SpinnerStyle), + delay: delay, + startedAt: time.Now(), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } + + go r.runActivity(r.activity.stopCh, r.activity.doneCh) + return nil +} + +func (r *Renderer) stopActivity() error { + r.writeMu.Lock() + activity := r.activity + if activity == nil { + r.writeMu.Unlock() + return nil + } + r.activity = nil + stopCh := activity.stopCh + doneCh := activity.doneCh + shown := activity.shown + err := activity.err + r.writeMu.Unlock() + + if stopCh != nil { + close(stopCh) + } + if doneCh != nil { + <-doneCh + } + + if shown { + r.writeMu.Lock() + if _, clearErr := io.WriteString(r.out, activityClearLine); err == nil && clearErr != nil { + err = fmt.Errorf("clear activity footer: %w", clearErr) + } + r.writeMu.Unlock() + } + return err +} + +func (r *Renderer) activityError() error { + r.writeMu.Lock() + defer r.writeMu.Unlock() + if r.activity == nil { + return nil + } + return r.activity.err +} + +func (r *Renderer) withActivityHidden(fn func() error) error { + r.writeMu.Lock() + defer r.writeMu.Unlock() + + if r.activity != nil && r.activity.err != nil { + return r.activity.err + } + if err := r.clearActivityLocked(); err != nil { + return err + } + if err := fn(); err != nil { + return err + } + return r.redrawActivityLocked() +} + +func (r *Renderer) updateActivity(event Event) { + if r.activity == nil { + return + } + + r.writeMu.Lock() + defer r.writeMu.Unlock() + if r.activity == nil { + return + } + + switch event.Action { + case ActionStart: + if event.Package != "" { + r.activity.currentPkg = event.Package + r.activity.currentTest = "" + } + case ActionRun: + r.activity.currentPkg = event.Package + r.activity.currentTest = event.Test + case ActionOutput, ActionBuildOutput: + if event.Package != "" { + r.activity.currentPkg = event.Package + if event.Test != "" { + r.activity.currentTest = event.Test + } + } + } + + r.activity.testsPassed = r.summary.TestsPassed + r.activity.testsFailed = r.summary.TestsFailed + r.activity.testsSkipped = r.summary.TestsSkipped + r.activity.pkgsPassed = r.summary.PackagesPassed + r.activity.pkgsFailed = r.summary.PackagesFailed + r.activity.pkgsSkipped = r.summary.PackagesSkipped + + if event.PackageEvent() && event.Action.IsTerminal() { + r.activity.currentTest = "" + } +} + +func (r *Renderer) shouldShowActivity() bool { + if r.mode.Format != laslig.FormatHuman || !r.mode.Styled { + return false + } + + switch r.options.Activity.Mode { + case ActivityOff: + return false + case ActivityOn: + return true + case ActivityAuto: + return writerIsTerminal(r.out) + default: + return false + } +} + +func (r *Renderer) runActivity(stopCh <-chan struct{}, doneCh chan<- struct{}) { + defer close(doneCh) + activity := r.activity + if activity == nil { + return + } + + if activity.delay > 0 { + timer := time.NewTimer(activity.delay) + defer timer.Stop() + select { + case <-stopCh: + return + case <-timer.C: + } + } + + if err := r.tickActivity(false); err != nil { + return + } + + ticker := time.NewTicker(defaultActivityInterval) + defer ticker.Stop() + for { + select { + case <-stopCh: + return + case <-ticker.C: + if err := r.tickActivity(true); err != nil { + return + } + } + } +} + +func (r *Renderer) tickActivity(advance bool) error { + r.writeMu.Lock() + defer r.writeMu.Unlock() + + if r.activity == nil { + return nil + } + if r.activity.err != nil { + return r.activity.err + } + if advance { + r.activity.frame = (r.activity.frame + 1) % len(r.activity.frames) + } + if _, err := io.WriteString(r.out, activityClearLine+r.renderActivityLineLocked()); err != nil { + r.activity.err = fmt.Errorf("write activity footer: %w", err) + return r.activity.err + } + r.activity.shown = true + return nil +} + +func (r *Renderer) clearActivityLocked() error { + if r.activity == nil || !r.activity.shown { + return nil + } + if _, err := io.WriteString(r.out, activityClearLine); err != nil { + r.activity.err = fmt.Errorf("clear activity footer: %w", err) + return r.activity.err + } + r.activity.shown = false + return nil +} + +func (r *Renderer) redrawActivityLocked() error { + if r.activity == nil || r.activity.err != nil { + if r.activity != nil { + return r.activity.err + } + return nil + } + if _, err := io.WriteString(r.out, activityClearLine+r.renderActivityLineLocked()); err != nil { + r.activity.err = fmt.Errorf("redraw activity footer: %w", err) + return r.activity.err + } + r.activity.shown = true + return nil +} + +func (r *Renderer) renderActivityLineLocked() string { + frame := r.activity.frames[r.activity.frame%len(r.activity.frames)] + subject := r.activity.currentPkg + if r.activity.currentTest != "" { + subject += " :: " + r.activity.currentTest + } + + text := strings.TrimSpace(r.activity.text) + if text == "" { + text = "Running go test -json" + } + + details := []string{ + fmt.Sprintf("tests: %d/%d/%d", r.activity.testsPassed, r.activity.testsFailed, r.activity.testsSkipped), + fmt.Sprintf("pkgs: %d/%d/%d", r.activity.pkgsPassed, r.activity.pkgsFailed, r.activity.pkgsSkipped), + formatActivityElapsed(time.Since(r.activity.startedAt)), + } + if subject != "" { + details = append([]string{"current: " + subject}, details...) + } + lineText := text + " " + strings.Join(details, " ") + + if r.mode.Width > 0 { + textWidth := r.mode.Width - lipgloss.Width(frame) - 1 + if textWidth < 0 { + textWidth = 0 + } + lineText = truncateActivityText(lineText, textWidth) + } + + return lipgloss.JoinHorizontal( + lipgloss.Top, + r.theme.Identifier.Render(frame), + " ", + r.theme.Value.Render(lineText), + ) +} + +func activityFrames(style laslig.SpinnerStyle) []string { + switch style { + case laslig.SpinnerStyleDot: + return []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} + case laslig.SpinnerStyleLine: + return []string{"-", "\\", "|", "/"} + case laslig.SpinnerStylePulse: + return []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙●∙"} + case laslig.SpinnerStyleMeter: + return []string{"[ ]", "[= ]", "[== ]", "[=== ]", "[ ===]", "[ ==]", "[ =]"} + default: + return []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + } +} + +func formatActivityElapsed(elapsed time.Duration) string { + if elapsed < time.Second { + return elapsed.Round(100 * time.Millisecond).String() + } + return elapsed.Round(time.Second).String() +} + +func truncateActivityText(value string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(value) <= width { + return value + } + + const ellipsis = "…" + if width == 1 { + return ellipsis + } + + var builder strings.Builder + for _, r := range value { + candidate := builder.String() + string(r) + if lipgloss.Width(candidate+ellipsis) > width { + break + } + builder.WriteRune(r) + } + return strings.TrimRight(builder.String(), " ") + ellipsis +} + +func writerIsTerminal(out io.Writer) bool { + file, ok := out.(term.File) + if !ok { + return false + } + return term.IsTerminal(file.Fd()) +} diff --git a/gotestout/doc.go b/gotestout/doc.go index c435ae5..474f9f2 100644 --- a/gotestout/doc.go +++ b/gotestout/doc.go @@ -4,11 +4,13 @@ // The package focuses on parsing and rendering the event stream itself. It does // not execute commands or own process lifecycle. Callers are expected to wire // exec.Command, Mage, or another runner to an io.Reader that yields go test -// events. Options allow compact or detailed views and let callers disable -// grouped failed-test, skipped-test, package-error, or captured-output -// sections when they want a tighter summary. In JSON mode, Render re-emits the -// raw go test events while still returning summary counts, and it skips the -// grouped human/plain summary blocks. This makes the package a good fit for -// Mage targets such as `mage test`, ordinary Go CLI commands, and small Go -// helpers invoked from tools such as `make`, `just`, or `task`. +// events. Options allow compact or detailed views, a spinner-only live +// activity footer with auto/on/off modes for styled human output, and grouped +// failed-test, skipped-test, package-error, or captured-output sections that +// callers can disable when they want a tighter summary. In JSON mode, Render +// re-emits the raw go test events while still returning summary counts, and it +// skips the grouped human/plain summary blocks and transient activity footer. +// This makes the package a good fit for Mage targets such as `mage test`, +// ordinary Go CLI commands, and small Go helpers invoked from tools such as +// `make`, `just`, or `task`. package gotestout diff --git a/gotestout/renderer.go b/gotestout/renderer.go index acaf419..ef8ce30 100644 --- a/gotestout/renderer.go +++ b/gotestout/renderer.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "sync" "charm.land/lipgloss/v2" @@ -33,6 +34,8 @@ type Renderer struct { packageError []outcome wroteResults bool jsonEncoder *json.Encoder + writeMu sync.Mutex + activity *activityState } // NewRenderer constructs one renderer for the provided writer and options. @@ -69,6 +72,9 @@ func (r *Renderer) Summary() Summary { // WriteEvent consumes one parsed event and writes any corresponding output. func (r *Renderer) WriteEvent(event Event) error { + if err := r.activityError(); err != nil { + return err + } if r.mode.Format == laslig.FormatJSON { if err := r.jsonEncoder.Encode(event); err != nil { return fmt.Errorf("write json event: %w", err) @@ -78,8 +84,10 @@ func (r *Renderer) WriteEvent(event Event) error { switch event.Action { case ActionOutput, ActionBuildOutput: r.recordOutput(event) + r.updateActivity(event) case ActionPass, ActionFail, ActionSkip: r.recordTerminal(event) + r.updateActivity(event) if r.mode.Format != laslig.FormatJSON { if err := r.renderTerminal(event); err != nil { return err @@ -92,6 +100,9 @@ func (r *Renderer) WriteEvent(event Event) error { // Finish writes the final summary for human and plain output. func (r *Renderer) Finish() error { + if err := r.stopActivity(); err != nil { + return err + } if r.mode.Format == laslig.FormatJSON { return nil } @@ -181,6 +192,12 @@ func Parse(in io.Reader) ([]Event, error) { // Render parses a stream, renders it, and returns a summary of terminal events. func Render(out io.Writer, in io.Reader, options Options) (Summary, error) { renderer := NewRenderer(out, options) + if err := renderer.startActivity(); err != nil { + return Summary{}, err + } + defer func() { + _ = renderer.stopActivity() + }() decoder := json.NewDecoder(bufio.NewReader(in)) for { @@ -212,6 +229,18 @@ func withDefaults(options Options) Options { if options.View == "" { options.View = ViewCompact } + if !options.Activity.Mode.Valid() { + options.Activity.Mode = ActivityAuto + } + if !options.Activity.SpinnerStyle.Valid() { + options.Activity.SpinnerStyle = laslig.DefaultSpinnerStyle() + } + if options.Activity.Delay <= 0 { + options.Activity.Delay = defaultActivityDelay + } + if options.Activity.Text == "" { + options.Activity.Text = "Running go test -json" + } return options } @@ -293,30 +322,32 @@ func (r *Renderer) recordTerminal(event Event) { } func (r *Renderer) renderTerminal(event Event) error { - if event.PackageEvent() { - if err := r.writeLine(r.renderPackageLine(event)); err != nil { + return r.withActivityHidden(func() error { + if event.PackageEvent() { + if err := r.writeLine(r.renderPackageLine(event)); err != nil { + return err + } + if r.sectionEnabled(SectionOutput) && (event.Action == ActionFail || r.options.View == ViewDetailed) { + if err := r.writeOutputLines(outputKey{pkg: event.Package}); err != nil { + return err + } + } + return nil + } + + if r.options.View == ViewCompact && event.Action != ActionFail { + return nil + } + if err := r.writeLine(r.renderTestLine(event)); err != nil { return err } if r.sectionEnabled(SectionOutput) && (event.Action == ActionFail || r.options.View == ViewDetailed) { - if err := r.writeOutputLines(outputKey{pkg: event.Package}); err != nil { + if err := r.writeOutputLines(outputKey{pkg: event.Package, test: event.Test}); err != nil { return err } } return nil - } - - if r.options.View == ViewCompact && event.Action != ActionFail { - return nil - } - if err := r.writeLine(r.renderTestLine(event)); err != nil { - return err - } - if r.sectionEnabled(SectionOutput) && (event.Action == ActionFail || r.options.View == ViewDetailed) { - if err := r.writeOutputLines(outputKey{pkg: event.Package, test: event.Test}); err != nil { - return err - } - } - return nil + }) } func (r *Renderer) outcomeList(title string, badge string, outcomes []outcome, includeOutput bool) laslig.List { diff --git a/gotestout/renderer_test.go b/gotestout/renderer_test.go index e88d631..c963d14 100644 --- a/gotestout/renderer_test.go +++ b/gotestout/renderer_test.go @@ -2,9 +2,11 @@ package gotestout import ( "bytes" + "io" "regexp" "strings" "testing" + "time" "github.com/evanmschultz/laslig" ) @@ -212,3 +214,81 @@ func TestRenderJSON(t *testing.T) { t.Fatalf("Render() JSON output should not include human summary:\n%s", buf.String()) } } + +// TestRenderHumanStyledActivityOn verifies callers can force one live activity +// footer in styled human output. +func TestRenderHumanStyledActivityOn(t *testing.T) { + var buf bytes.Buffer + _, err := Render(&buf, slowStreamReader(sampleStream, 25*time.Millisecond), Options{ + Policy: laslig.Policy{ + Format: laslig.FormatHuman, + Style: laslig.StyleAlways, + }, + View: ViewCompact, + Activity: ActivityOptions{ + Mode: ActivityOn, + Delay: time.Millisecond, + SpinnerStyle: laslig.SpinnerStyleLine, + Text: "Running go test -json", + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + + got := buf.String() + if !strings.Contains(got, "\r\x1b[2K") { + t.Fatalf("Render() output missing activity redraws:\n%q", got) + } + plain := stripANSI(got) + if !strings.Contains(plain, "Running go test -json") { + t.Fatalf("Render() output missing activity text:\n%s", plain) + } + if !strings.Contains(plain, "tests: 1/1/1") { + t.Fatalf("Render() output missing live test counts:\n%s", plain) + } +} + +// TestRenderPlainActivityOnNoFooter verifies plain output never emits the live +// activity footer even when callers force activity on. +func TestRenderPlainActivityOnNoFooter(t *testing.T) { + var buf bytes.Buffer + _, err := Render(&buf, strings.NewReader(sampleStream), Options{ + Policy: laslig.Policy{ + Format: laslig.FormatPlain, + Style: laslig.StyleNever, + }, + View: ViewCompact, + Activity: ActivityOptions{ + Mode: ActivityOn, + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + + got := buf.String() + if strings.Contains(got, "Running go test -json") { + t.Fatalf("Render() plain output unexpectedly included activity text:\n%s", got) + } + if strings.Contains(got, "\r\x1b[2K") { + t.Fatalf("Render() plain output unexpectedly included activity redraws:\n%q", got) + } +} + +func slowStreamReader(stream string, delay time.Duration) io.Reader { + reader, writer := io.Pipe() + go func() { + defer writer.Close() + for _, line := range strings.SplitAfter(stream, "\n") { + if line == "" { + continue + } + if _, err := io.WriteString(writer, line); err != nil { + return + } + time.Sleep(delay) + } + }() + return reader +} diff --git a/gotestout/types.go b/gotestout/types.go index 62ad992..0c998a1 100644 --- a/gotestout/types.go +++ b/gotestout/types.go @@ -125,6 +125,52 @@ type Options struct { Policy laslig.Policy View View DisabledSections []Section + Activity ActivityOptions +} + +// ActivityMode controls whether gotestout renders one live activity footer +// while a test stream is still running. +type ActivityMode string + +const ( + // ActivityAuto enables the activity footer only for styled human terminal + // output where transient redraws are appropriate. + ActivityAuto ActivityMode = "auto" + // ActivityOn forces the activity footer for styled human output even when + // the writer is not a terminal, which is useful for demos and tests. + ActivityOn ActivityMode = "on" + // ActivityOff disables the activity footer completely. + ActivityOff ActivityMode = "off" +) + +// Valid reports whether the activity mode is one supported built-in value. +func (m ActivityMode) Valid() bool { + switch m { + case ActivityAuto, ActivityOn, ActivityOff: + return true + default: + return false + } +} + +// ActivityOptions configures the optional live activity footer shown while a +// go test stream is still running. +// +// The footer is spinner-only. It is transient in styled human output and is +// suppressed entirely in plain, unstyled human, and JSON modes. +type ActivityOptions struct { + // Mode selects whether the footer is enabled automatically, forced on for + // styled human output, or disabled entirely. The default is auto. + Mode ActivityMode + // SpinnerStyle selects the built-in spinner frame set used when the footer + // is visible. The default is laslig.DefaultSpinnerStyle(). + SpinnerStyle laslig.SpinnerStyle + // Delay waits this long before showing the footer, which helps avoid + // flicker on very short test runs. The default is 750ms. + Delay time.Duration + // Text overrides the leading activity label. The default is + // "Running go test -json". + Text string } // Summary records the terminal outcomes seen in one stream. diff --git a/internal/examples/render.go b/internal/examples/render.go index 7dcf706..e391761 100644 --- a/internal/examples/render.go +++ b/internal/examples/render.go @@ -16,6 +16,7 @@ import ( ) const demoSpinnerStepDelay = 450 * time.Millisecond +const demoTestEventDelay = 180 * time.Millisecond // RenderAll writes the aggregate walkthrough used by mage demo. func RenderAll(out io.Writer, printer *laslig.Printer) error { @@ -317,7 +318,7 @@ func RenderGotestout(out io.Writer, printer *laslig.Printer) error { } if err := printer.Paragraph(laslig.Paragraph{ Body: "Use gotestout for attractive, structured go test output when your task runner, CLI command, or Go helper behind make/just should keep owning process control.", - Footer: "This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure.", + Footer: "This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure, and styled human terminals show gotestout's live activity footer while the stream is still active.", }); err != nil { return fmt.Errorf("render gotestout intro: %w", err) } @@ -333,12 +334,18 @@ func RenderGotestout(out io.Writer, printer *laslig.Printer) error { } } - _, err := gotestout.Render(out, strings.NewReader(focusedGotestoutSampleStream), gotestout.Options{ + _, err := gotestout.Render(out, previewStreamReader(out, focusedGotestoutSampleStream, demoTestEventDelay), gotestout.Options{ Policy: laslig.Policy{ Format: printer.Mode().Format, Style: StylePolicyForMode(printer.Mode()), }, View: gotestout.ViewDetailed, + Activity: gotestout.ActivityOptions{ + Mode: gotestout.ActivityAuto, + Delay: time.Millisecond, + SpinnerStyle: laslig.DefaultSpinnerStyle(), + Text: "Streaming mixed go test -json fixture", + }, }) if err != nil { return fmt.Errorf("render gotestout stream: %w", err) @@ -353,7 +360,7 @@ func RenderMageCheckPreview(out io.Writer, printer *laslig.Printer) error { } if err := printer.Paragraph(laslig.Paragraph{ Body: "Use gotestout inside Mage or small Go helpers behind make, just, or task when you want caller-owned process control with a readable test stream.", - Footer: "The preview below matches this repository's mage check and mage test shape, including the recommended spinner handoff before the live test stream starts.", + Footer: "The preview below matches this repository's mage check and mage test shape, including the live gotestout activity footer while the test stream is still active.", }); err != nil { return fmt.Errorf("render mage preview intro: %w", err) } @@ -397,28 +404,28 @@ func renderMageCheckPreview(out io.Writer, printer *laslig.Printer) error { }); err != nil { return fmt.Errorf("render build success: %w", err) } - spin := printer.NewSpinner() - if err := spin.Start("Waiting for first test event"); err != nil { - return fmt.Errorf("start mage spinner: %w", err) - } - pauseForAnimatedPreview(out, demoSpinnerStepDelay) - if err := spin.Update("Waiting for first test event from go test -json"); err != nil { - return fmt.Errorf("update mage spinner: %w", err) - } - pauseForAnimatedPreview(out, demoSpinnerStepDelay) - if err := spin.Stop("Test stream detected", laslig.NoticeSuccessLevel); err != nil { - return fmt.Errorf("stop mage spinner: %w", err) - } if err := printer.Section("Tests"); err != nil { return fmt.Errorf("render tests section: %w", err) } - if _, err := gotestout.Render(out, strings.NewReader(mageCheckSampleStream()), gotestout.Options{ + if err := printer.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeInfoLevel, + Text: "Started go test -json", + Detail: "./...", + }); err != nil { + return fmt.Errorf("render test start status: %w", err) + } + if _, err := gotestout.Render(out, previewStreamReader(out, mageCheckSampleStream(), demoTestEventDelay), gotestout.Options{ Policy: laslig.Policy{ Format: printer.Mode().Format, Style: StylePolicyForMode(printer.Mode()), }, View: gotestout.ViewCompact, + Activity: gotestout.ActivityOptions{ + Mode: gotestout.ActivityAuto, + Delay: time.Millisecond, + SpinnerStyle: laslig.DefaultSpinnerStyle(), + }, }); err != nil { return fmt.Errorf("render mage gotestout stream: %w", err) } @@ -529,11 +536,39 @@ func writerSupportsAnimation(out io.Writer) bool { } func pauseForAnimatedPreview(out io.Writer, delay time.Duration) { - if writerSupportsAnimation(out) { - time.Sleep(delay) + maybeSleepAnimatedPreview(writerSupportsAnimation(out), delay, time.Sleep) +} + +func previewStreamReader(out io.Writer, raw string, delay time.Duration) io.Reader { + if !writerSupportsAnimation(out) || delay <= 0 { + return strings.NewReader(raw) + } + return delayedPreviewStreamReader(raw, delay) +} + +func maybeSleepAnimatedPreview(animated bool, delay time.Duration, sleep func(time.Duration)) { + if animated { + sleep(delay) } } +func delayedPreviewStreamReader(raw string, delay time.Duration) io.Reader { + reader, writer := io.Pipe() + go func() { + defer writer.Close() + for _, line := range strings.SplitAfter(raw, "\n") { + if line == "" { + continue + } + if _, err := io.WriteString(writer, line); err != nil { + return + } + time.Sleep(delay) + } + }() + return reader +} + // transcript captures one real charm/log transcript for the LogBlock demo. func transcript() string { var writer ttyBuffer diff --git a/internal/examples/render_test.go b/internal/examples/render_test.go index c67d75d..b4fcc90 100644 --- a/internal/examples/render_test.go +++ b/internal/examples/render_test.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/charmbracelet/x/exp/golden" "github.com/evanmschultz/laslig" @@ -213,6 +214,58 @@ func TestWriterSupportsAnimation(t *testing.T) { } } +// TestMaybeSleepAnimatedPreview verifies the preview pause only sleeps when +// animation is enabled. +func TestMaybeSleepAnimatedPreview(t *testing.T) { + calls := 0 + var slept time.Duration + sleep := func(delay time.Duration) { + calls++ + slept = delay + } + + maybeSleepAnimatedPreview(false, 10*time.Millisecond, sleep) + if calls != 0 { + t.Fatalf("maybeSleepAnimatedPreview(false) sleep calls = %d, want 0", calls) + } + + maybeSleepAnimatedPreview(true, 10*time.Millisecond, sleep) + if calls != 1 { + t.Fatalf("maybeSleepAnimatedPreview(true) sleep calls = %d, want 1", calls) + } + if slept != 10*time.Millisecond { + t.Fatalf("maybeSleepAnimatedPreview(true) slept = %v, want %v", slept, 10*time.Millisecond) + } +} + +// TestDelayedPreviewStreamReader verifies the animation-only stream helper +// still replays the full input in order. +func TestDelayedPreviewStreamReader(t *testing.T) { + const raw = "{\"Action\":\"pass\"}\n{\"Action\":\"fail\"}\n" + + data, err := io.ReadAll(delayedPreviewStreamReader(raw, time.Millisecond)) + if err != nil { + t.Fatalf("ReadAll(delayedPreviewStreamReader()) error = %v", err) + } + if got := string(data); got != raw { + t.Fatalf("delayedPreviewStreamReader() = %q, want %q", got, raw) + } +} + +// TestPreviewStreamReaderNoAnimation verifies the public preview helper falls +// back to one immediate reader when animation is unavailable. +func TestPreviewStreamReaderNoAnimation(t *testing.T) { + const raw = "{\"Action\":\"pass\"}\n" + + data, err := io.ReadAll(previewStreamReader(&bytes.Buffer{}, raw, time.Second)) + if err != nil { + t.Fatalf("ReadAll(previewStreamReader()) error = %v", err) + } + if got := string(data); got != raw { + t.Fatalf("previewStreamReader() = %q, want %q", got, raw) + } +} + // TestRenderSpinnerWriteError verifies the spinner demo wraps underlying write // failures instead of swallowing them. func TestRenderSpinnerWriteError(t *testing.T) { @@ -242,7 +295,7 @@ func TestRenderMageCheckPreviewWriteError(t *testing.T) { } // TestRenderMageCheckPreviewStreamError verifies the focused Mage preview -// reports stream-writer failures after the spinner handoff. +// reports stream-writer failures during the live gotestout render. func TestRenderMageCheckPreviewStreamError(t *testing.T) { var buf bytes.Buffer printer := laslig.NewWithMode(&buf, laslig.Mode{Format: laslig.FormatPlain}) diff --git a/internal/examples/testdata/TestRenderAllHumanStyledGolden.golden b/internal/examples/testdata/TestRenderAllHumanStyledGolden.golden index f215dcb..b3759e1 100644 --- a/internal/examples/testdata/TestRenderAllHumanStyledGolden.golden +++ b/internal/examples/testdata/TestRenderAllHumanStyledGolden.golden @@ -198,7 +198,9 @@ gotestout process control. This focused example intentionally mixes passing, skipped, and failing - test events plus one package build failure. + test events plus one package build failure, and styled human terminals + show gotestout's live activity footer while the stream is still + active. INFO Mixed fixture demo The example command itself is expected to exit successfully so you can @@ -247,9 +249,9 @@ gotestout + Mage task when you want caller-owned process control with a readable test stream. - The preview below matches this repository's mage check and mage test - shape, including the recommended spinner handoff before the live test - stream starts. + The preview below matches this repository's mage check and mage test + shape, including the live gotestout activity footer while the test + stream is still active. Build @@ -258,11 +260,10 @@ Build SUCCESS Built example packages (./examples/...) - [RUNNING] Waiting for first test event - [SUCCESS] Test stream detected - Tests + + INFO Started go test -json (./...) PKG PASS github.com/evanmschultz/laslig (0.00s) PKG PASS github.com/evanmschultz/laslig/examples/all (0.00s) PKG PASS github.com/evanmschultz/laslig/examples/codeblock (0.00s) @@ -300,6 +301,7 @@ Test summary SUCCESS All tests passed 178 tests passed across 23 packages. + Coverage ╭──────────────────────────────────────────────────────────────────╮ diff --git a/internal/examples/testdata/TestRunAllPlainGolden.golden b/internal/examples/testdata/TestRunAllPlainGolden.golden index a7cb9b7..1ce11c6 100644 --- a/internal/examples/testdata/TestRunAllPlainGolden.golden +++ b/internal/examples/testdata/TestRunAllPlainGolden.golden @@ -156,7 +156,7 @@ gotestout Use gotestout for attractive, structured go test output when your task runner, CLI command, or Go helper behind make/just should keep owning process control. - This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure. + This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure, and styled human terminals show gotestout's live activity footer while the stream is still active. [INFO] Mixed fixture demo The example command itself is expected to exit successfully so you can inspect the output shape. @@ -202,7 +202,7 @@ gotestout + Mage Use gotestout inside Mage or small Go helpers behind make, just, or task when you want caller-owned process control with a readable test stream. - The preview below matches this repository's mage check and mage test shape, including the recommended spinner handoff before the live test stream starts. + The preview below matches this repository's mage check and mage test shape, including the live gotestout activity footer while the test stream is still active. Build @@ -211,11 +211,10 @@ Build [SUCCESS] Built example packages (./examples/...) - [RUNNING] Waiting for first test event - [SUCCESS] Test stream detected - Tests + + [INFO] Started go test -json (./...) [PKG PASS] github.com/evanmschultz/laslig (0.00s) [PKG PASS] github.com/evanmschultz/laslig/examples/all (0.00s) [PKG PASS] github.com/evanmschultz/laslig/examples/codeblock (0.00s) @@ -253,6 +252,7 @@ Test summary [SUCCESS] All tests passed 178 tests passed across 23 packages. + Coverage package | cover diff --git a/internal/examples/testdata/TestRunFocusedPlainGolden/gotestout.golden b/internal/examples/testdata/TestRunFocusedPlainGolden/gotestout.golden index 9351fcf..ad95d67 100644 --- a/internal/examples/testdata/TestRunFocusedPlainGolden/gotestout.golden +++ b/internal/examples/testdata/TestRunFocusedPlainGolden/gotestout.golden @@ -3,7 +3,7 @@ gotestout Use gotestout for attractive, structured go test output when your task runner, CLI command, or Go helper behind make/just should keep owning process control. - This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure. + This focused example intentionally mixes passing, skipped, and failing test events plus one package build failure, and styled human terminals show gotestout's live activity footer while the stream is still active. [INFO] Mixed fixture demo The example command itself is expected to exit successfully so you can inspect the output shape. diff --git a/internal/examples/testdata/TestRunFocusedPlainGolden/magecheck.golden b/internal/examples/testdata/TestRunFocusedPlainGolden/magecheck.golden index 32d7992..5aec203 100644 --- a/internal/examples/testdata/TestRunFocusedPlainGolden/magecheck.golden +++ b/internal/examples/testdata/TestRunFocusedPlainGolden/magecheck.golden @@ -3,7 +3,7 @@ gotestout + Mage Use gotestout inside Mage or small Go helpers behind make, just, or task when you want caller-owned process control with a readable test stream. - The preview below matches this repository's mage check and mage test shape, including the recommended spinner handoff before the live test stream starts. + The preview below matches this repository's mage check and mage test shape, including the live gotestout activity footer while the test stream is still active. Build @@ -12,11 +12,10 @@ Build [SUCCESS] Built example packages (./examples/...) - [RUNNING] Waiting for first test event - [SUCCESS] Test stream detected - Tests + + [INFO] Started go test -json (./...) [PKG PASS] github.com/evanmschultz/laslig (0.00s) [PKG PASS] github.com/evanmschultz/laslig/examples/all (0.00s) [PKG PASS] github.com/evanmschultz/laslig/examples/codeblock (0.00s) @@ -54,6 +53,7 @@ Test summary [SUCCESS] All tests passed 178 tests passed across 23 packages. + Coverage package | cover diff --git a/magefiles/magefile.go b/magefiles/magefile.go index 3580cca..655afc1 100644 --- a/magefiles/magefile.go +++ b/magefiles/magefile.go @@ -381,6 +381,10 @@ func runGoTest(packages ...string) error { args := []string{"test", "-json"} args = append(args, packages...) + printer := laslig.New(os.Stdout, laslig.Policy{ + Format: laslig.FormatAuto, + Style: laslig.StyleAuto, + }) cmd := exec.Command("go", args...) stdout, err := cmd.StdoutPipe() if err != nil { @@ -391,6 +395,13 @@ func runGoTest(packages ...string) error { if err := cmd.Start(); err != nil { return fmt.Errorf("start go test: %w", err) } + if err := printer.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeInfoLevel, + Text: "Started go test -json", + Detail: strings.Join(packages, " "), + }); err != nil { + return fmt.Errorf("write go test start status: %w", err) + } summary, renderErr := gotestout.Render(os.Stdout, stdout, gotestout.Options{ Policy: laslig.Policy{