package commandkit import ( "bytes" "errors" "io" "strings" "testing" ) type reporterTestWriter struct { writes int n int err error output bytes.Buffer } func (w *reporterTestWriter) Write(p []byte) (int, error) { w.writes++ if w.err != nil || w.n < len(p) { n := w.n if n > len(p) { n = len(p) } if n > 0 { _, _ = w.output.Write(p[:n]) } return n, w.err } return w.output.Write(p) } func TestReporterOutputSubmitsOnWrite(t *testing.T) { var destination bytes.Buffer reporter := newReporter(Streams{Out: &destination}) writer := reporter.Output() if got := reporter.state.presentation().Output.State; got != DeliveryNone { t.Fatalf("state after obtaining output = %v, want no delivery", got) } if n, err := writer.Write([]byte("report")); err != nil || n != len("report") { t.Fatalf("Write() = %d, %v", n, err) } if destination.String() != "report" { t.Fatalf("output = %q", destination.String()) } if got := reporter.state.presentation().Output.State; got != DeliveryComplete { t.Fatalf("output state = %v, want complete", got) } } func TestReporterQuietOutputDiscardsSuccessfully(t *testing.T) { var destination bytes.Buffer reporter := newReporter(Streams{Out: &destination}) reporter.setVisibility(modeQuiet, false, false) writer := reporter.Output() if n, err := writer.Write([]byte("hidden")); err != nil || n != len("hidden") { t.Fatalf("quiet Write() = %d, %v", n, err) } if destination.Len() != 0 { t.Fatalf("quiet output reached destination: %q", destination.String()) } if got := reporter.state.presentation().Output.State; got != DeliverySuppressed { t.Fatalf("output state = %v, want suppressed", got) } } func TestReporterRetainsFirstWriteFailureAndStopsUsingDestination(t *testing.T) { first := errors.New("first write failed") second := errors.New("later write failed") destination := &reporterTestWriter{n: 1, err: first} reporter := newReporter(Streams{Out: destination}) writer := reporter.Output() if n, err := writer.Write([]byte("first")); n != 1 || !errors.Is(err, first) { t.Fatalf("first Write() = %d, %v", n, err) } if n, err := writer.Write([]byte("second")); n != 0 || !errors.Is(err, first) || errors.Is(err, second) { t.Fatalf("second Write() = %d, %v, want retained first error", n, err) } if destination.writes != 1 { t.Fatalf("destination writes = %d, want 1", destination.writes) } result := reporter.state.presentation().Output if result.State != DeliveryIncomplete || !errors.Is(result.WriteError, first) { t.Fatalf("delivery result = %#v", result) } } func TestReporterNormalizesShortWriteAndRetainsIgnoredFailure(t *testing.T) { destination := &reporterTestWriter{n: 2} reporter := newReporter(Streams{Out: destination}) _, _ = reporter.Output().Write([]byte("short")) result := reporter.state.presentation().Output if result.State != DeliveryIncomplete || !errors.Is(result.WriteError, io.ErrShortWrite) { t.Fatalf("delivery result = %#v, want short-write failure", result) } } func TestReporterRetainsFirstRenderFailurePerDestination(t *testing.T) { firstOutputErr := errors.New("first output render failed") secondOutputErr := errors.New("later output render failed") diagnosticErr := errors.New("diagnostic render failed") reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard}) reporter.state.mu.Lock() reporter.state.recordRenderErrorLocked(destinationOutput, firstOutputErr) reporter.state.recordRenderErrorLocked(destinationOutput, secondOutputErr) reporter.state.recordRenderErrorLocked(destinationDiagnostics, diagnosticErr) reporter.state.mu.Unlock() result := reporter.state.presentation() if !errors.Is(result.Output.RenderError, firstOutputErr) || errors.Is(result.Output.RenderError, secondOutputErr) { t.Fatalf("output render error = %v, want first error only", result.Output.RenderError) } if !errors.Is(result.Diagnostics.RenderError, diagnosticErr) { t.Fatalf("diagnostic render error = %v", result.Diagnostics.RenderError) } if result.Output.State != DeliveryIncomplete || result.Diagnostics.State != DeliveryIncomplete { t.Fatalf("render failures were not incomplete: %#v", result) } } func TestReporterDestinationsTrackFailuresIndependently(t *testing.T) { outputErr := errors.New("output failed") output := &reporterTestWriter{n: 1, err: outputErr} var diagnostics bytes.Buffer reporter := newReporter(Streams{Out: output, Err: &diagnostics}) _, _ = reporter.Output().Write([]byte("out")) if n, err := reporter.state.write(destinationDiagnostics, []byte("warning")); err != nil || n != len("warning") { t.Fatalf("diagnostic Write() = %d, %v", n, err) } if diagnostics.String() != "warning" { t.Fatalf("diagnostic output = %q", diagnostics.String()) } result := reporter.state.presentation() if result.Output.State != DeliveryIncomplete || !errors.Is(result.Output.WriteError, outputErr) { t.Fatalf("output result = %#v", result.Output) } if result.Diagnostics.State != DeliveryComplete || result.Diagnostics.WriteError != nil { t.Fatalf("diagnostic result = %#v", result.Diagnostics) } } func TestReporterClosedHandlesReturnClosedErrorIncludingQuiet(t *testing.T) { var destination bytes.Buffer reporter := newReporter(Streams{Out: &destination}) writer := reporter.Output() reporter.setVisibility(modeQuiet, false, false) reporter.close() if n, err := writer.Write([]byte("late")); n != 0 || !errors.Is(err, ErrReporterClosed) { t.Fatalf("late Write() = %d, %v", n, err) } if destination.Len() != 0 { t.Fatalf("late output reached destination: %q", destination.String()) } } func TestNewReporterNormalizesNilDestinationsAndStartsOrdinary(t *testing.T) { reporter := newReporter(Streams{}) if reporter.state.output.writer != io.Discard || reporter.state.diagnostics.writer != io.Discard { t.Fatalf("nil destinations were not normalized: %#v", reporter.state) } if reporter.state.mode != modeOrdinary || reporter.state.progress || reporter.state.progressSet { t.Fatalf("initial visibility = mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet) } reporter.setVisibility(modeDebug, true, true) if reporter.state.mode != modeDebug || !reporter.state.progress || !reporter.state.progressSet { t.Fatalf("visibility was not retained: mode %v, progress %v (set %v)", reporter.state.mode, reporter.state.progress, reporter.state.progressSet) } } func TestReporterMessageVisibilityAndProgressPrecedence(t *testing.T) { tests := []struct { name string mode reportingMode progress bool progressSet bool want string }{ {name: "ordinary", mode: modeOrdinary}, {name: "verbose", mode: modeVerbose, want: "not used\nWARN: not used\n"}, {name: "debug", mode: modeDebug, want: "not used\nWARN: not used\nDEBUG: DEBUG\nPROGRESS\n"}, {name: "quiet overrides progress", mode: modeQuiet, progress: true, progressSet: true}, {name: "explicit ordinary progress", mode: modeOrdinary, progress: true, progressSet: true, want: "PROGRESS\n"}, {name: "explicit debug disable", mode: modeDebug, progressSet: true, want: "not used\nWARN: not used\nDEBUG: DEBUG\n"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { var output bytes.Buffer reporter := newReporter(Streams{Err: &output}) reporter.setVisibility(test.mode, test.progress, test.progressSet) _ = reporter.Info("not used") _ = reporter.Warn("not used") _ = reporter.Debug("DEBUG") _ = reporter.Progress("PROGRESS") if got := output.String(); got != test.want { t.Fatalf("output = %q, want %q", got, test.want) } }) } } func TestReporterFormatsMessagesAndPreservesDetailOrder(t *testing.T) { var diagnostics bytes.Buffer reporter := newReporter(Streams{Err: &diagnostics}) reporter.setVisibility(modeVerbose, false, false) if err := reporter.Warn("Native warning\nwith second line\n", nil, func(w io.Writer) error { _, err := io.WriteString(w, "detail one\n"); return err }, func(w io.Writer) error { _, err := io.WriteString(w, "detail two\n"); return err }); err != nil { t.Fatal(err) } if err := reporter.Info("", func(w io.Writer) error { _, err := io.WriteString(w, "native labelled detail\n") return err }); err != nil { t.Fatal(err) } want := "WARN: Native warning\nwith second line\ndetail one\ndetail two\nnative labelled detail\n" if got := diagnostics.String(); got != want { t.Fatalf("diagnostics = %q, want %q", got, want) } } func TestReporterHiddenRenderersRunAgainstDiscardAndRetainErrors(t *testing.T) { var diagnostics bytes.Buffer reporter := newReporter(Streams{Err: &diagnostics}) reporter.setVisibility(modeQuiet, false, false) renderErr := errors.New("native renderer failed") calls := 0 err := reporter.Warn("hidden warning", func(w io.Writer) error { calls++ if _, writeErr := io.WriteString(w, "hidden detail"); writeErr != nil { return writeErr } return renderErr }) if !errors.Is(err, renderErr) { t.Fatalf("Warn() error = %v, want renderer error", err) } if calls != 1 || diagnostics.Len() != 0 { t.Fatalf("hidden renderer calls=%d, destination=%q", calls, diagnostics.String()) } result := reporter.state.presentation().Diagnostics if result.State != DeliveryIncomplete || !errors.Is(result.RenderError, renderErr) || result.WriteError != nil { t.Fatalf("diagnostic result = %#v", result) } } func TestReporterRecordsIgnoredRenderErrors(t *testing.T) { renderErr := errors.New("output renderer failed") diagnosticErr := errors.New("diagnostic renderer failed") reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard}) reporter.setVisibility(modeVerbose, false, false) _ = reporter.RenderOutput(func(io.Writer) error { return renderErr }) _ = reporter.Warn("warning", func(io.Writer) error { return diagnosticErr }) result := reporter.state.presentation() if !errors.Is(result.Output.RenderError, renderErr) || !errors.Is(result.Diagnostics.RenderError, diagnosticErr) { t.Fatalf("ignored render errors were not retained: %#v", result) } } func TestReporterRenderOutputRunsInQuietAndRetainsFailure(t *testing.T) { destination := &reporterTestWriter{} reporter := newReporter(Streams{Out: destination}) reporter.setVisibility(modeQuiet, false, false) renderErr := errors.New("quiet encoder failed") calls := 0 _ = reporter.RenderOutput(func(w io.Writer) error { calls++ if _, err := io.WriteString(w, "discarded report"); err != nil { return err } return renderErr }) result := reporter.state.presentation().Output if calls != 1 || destination.writes != 0 { t.Fatalf("quiet rendering calls=%d destination writes=%d", calls, destination.writes) } if result.State != DeliveryIncomplete || !errors.Is(result.RenderError, renderErr) || result.WriteError != nil { t.Fatalf("quiet render result = %#v", result) } } func TestReporterCountsSuccessfulZeroByteRenderAsSubmitted(t *testing.T) { reporter := newReporter(Streams{Out: io.Discard, Err: io.Discard}) if err := reporter.RenderOutput(func(io.Writer) error { return nil }); err != nil { t.Fatal(err) } reporter.setVisibility(modeVerbose, false, false) if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil { t.Fatal(err) } result := reporter.state.presentation() if result.Output.State != DeliveryComplete || result.Diagnostics.State != DeliveryComplete { t.Fatalf("zero-byte successful render states = %#v", result) } } func TestReporterCountsHiddenZeroByteRenderAsSuppressed(t *testing.T) { reporter := newReporter(Streams{Err: io.Discard}) if err := reporter.Warn("", func(io.Writer) error { return nil }); err != nil { t.Fatal(err) } if got := reporter.state.presentation().Diagnostics.State; got != DeliverySuppressed { t.Fatalf("hidden zero-byte render state = %v, want suppressed", got) } } func TestReporterRenderOutputSeparatesRenderAndWriteFailures(t *testing.T) { t.Run("renderer failure leaves destination usable", func(t *testing.T) { var output bytes.Buffer reporter := newReporter(Streams{Out: &output}) renderErr := errors.New("render failed") if err := reporter.RenderOutput(func(io.Writer) error { return renderErr }); !errors.Is(err, renderErr) { t.Fatalf("RenderOutput() error = %v", err) } if _, err := reporter.Output().Write([]byte("still usable")); err != nil { t.Fatalf("destination rejected later write: %v", err) } result := reporter.state.presentation().Output if !errors.Is(result.RenderError, renderErr) || result.WriteError != nil || output.String() != "still usable" { t.Fatalf("result=%#v output=%q", result, output.String()) } }) t.Run("propagated write error is not a renderer error", func(t *testing.T) { writeErr := errors.New("writer failed") destination := &reporterTestWriter{err: writeErr} reporter := newReporter(Streams{Out: destination}) err := reporter.RenderOutput(func(w io.Writer) error { _, writeErr := io.WriteString(w, "report") return writeErr }) if !errors.Is(err, writeErr) { t.Fatalf("RenderOutput() error = %v", err) } result := reporter.state.presentation().Output if result.RenderError != nil || !errors.Is(result.WriteError, writeErr) { t.Fatalf("result = %#v", result) } }) t.Run("ignored write error is retained and returned", func(t *testing.T) { writeErr := errors.New("writer failed") reporter := newReporter(Streams{Out: &reporterTestWriter{err: writeErr}}) err := reporter.RenderOutput(func(w io.Writer) error { _, _ = w.Write([]byte("report")) return nil }) if !errors.Is(err, writeErr) { t.Fatalf("RenderOutput() error = %v", err) } }) t.Run("independent rendering and writing failures remain separate", func(t *testing.T) { writeErr := errors.New("writer failed") renderErr := errors.New("renderer failed independently") reporter := newReporter(Streams{Out: &reporterTestWriter{err: writeErr}}) err := reporter.RenderOutput(func(w io.Writer) error { _, _ = w.Write([]byte("report")) return renderErr }) if !errors.Is(err, writeErr) || !errors.Is(err, renderErr) { t.Fatalf("RenderOutput() error = %v", err) } result := reporter.state.presentation().Output if !errors.Is(result.WriteError, writeErr) || !errors.Is(result.RenderError, renderErr) { t.Fatalf("result = %#v", result) } }) } func TestReporterConcurrentOperationsRemainWhole(t *testing.T) { t.Run("render callback and message", func(t *testing.T) { var output bytes.Buffer reporter := newReporter(Streams{Out: &output, Err: &output}) reporter.setVisibility(modeVerbose, false, false) start := make(chan struct{}) ready := make(chan struct{}, 2) done := make(chan struct{}, 2) go func() { ready <- struct{}{} <-start _ = reporter.RenderOutput(func(w io.Writer) error { _, _ = io.WriteString(w, "report-") _, err := io.WriteString(w, "complete\n") return err }) done <- struct{}{} }() go func() { ready <- struct{}{} <-start _ = reporter.Info("message") done <- struct{}{} }() <-ready <-ready close(start) <-done <-done got := output.String() if got != "report-complete\nmessage\n" && got != "message\nreport-complete\n" { t.Fatalf("concurrent output interleaved complete operations: %q", got) } }) t.Run("message details and render callback", func(t *testing.T) { var output bytes.Buffer reporter := newReporter(Streams{Out: &output, Err: &output}) reporter.setVisibility(modeVerbose, false, false) start := make(chan struct{}) ready := make(chan struct{}, 2) done := make(chan struct{}, 2) go func() { ready <- struct{}{} <-start _ = reporter.Info("first", func(w io.Writer) error { _, _ = io.WriteString(w, "detail-") _, err := io.WriteString(w, "complete\n") return err }) done <- struct{}{} }() go func() { ready <- struct{}{} <-start _ = reporter.RenderOutput(func(w io.Writer) error { _, err := io.WriteString(w, "report\n") return err }) done <- struct{}{} }() <-ready <-ready close(start) <-done <-done got := output.String() if got != "first\ndetail-complete\nreport\n" && got != "report\nfirst\ndetail-complete\n" { t.Fatalf("concurrent output interleaved complete operations: %q", got) } }) } func TestReporterEmptyHeadingDoesNotAddPrefixOrBlankLine(t *testing.T) { var diagnostics bytes.Buffer reporter := newReporter(Streams{Err: &diagnostics}) reporter.setVisibility(modeVerbose, false, false) if err := reporter.Warn("", func(w io.Writer) error { _, err := io.WriteString(w, "native multiline\nsecond line\n") return err }); err != nil { t.Fatal(err) } if got := diagnostics.String(); got != "native multiline\nsecond line\n" { t.Fatalf("diagnostics = %q", got) } if strings.HasPrefix(diagnostics.String(), "WARN:") { t.Fatal("empty heading added a category prefix") } }