diff --git a/internal/output/tui_sink.go b/internal/output/tui_sink.go index adfd1701..fbd71296 100644 --- a/internal/output/tui_sink.go +++ b/internal/output/tui_sink.go @@ -5,16 +5,39 @@ type Sender interface { Send(msg any) } +// LogLinePrinter abstracts Bubble Tea's Program.Println — permanent output +// that persists across renders, unlike a Sender's capped/repainted lines. +type LogLinePrinter interface { + PrintLogLine(event LogLineEvent) +} + type TUISink struct { - sender Sender + sender Sender + logPrinter LogLinePrinter } func NewTUISink(sender Sender) *TUISink { return &TUISink{sender: sender} } +// NewStreamingLogSink is like NewTUISink but routes LogLineEvent through +// logPrinter instead of sender, giving log lines real terminal scrollback +// instead of the TUI model's capped/repainted history. +func NewStreamingLogSink(sender Sender, logPrinter LogLinePrinter) *TUISink { + return &TUISink{sender: sender, logPrinter: logPrinter} +} + func (s *TUISink) Emit(event Event) { - if s == nil || s.sender == nil { + if s == nil { + return + } + if s.logPrinter != nil { + if logLine, ok := event.(LogLineEvent); ok { + s.logPrinter.PrintLogLine(logLine) + return + } + } + if s.sender == nil { return } s.sender.Send(event) diff --git a/internal/output/tui_sink_test.go b/internal/output/tui_sink_test.go index e3a2e7db..d214ea20 100644 --- a/internal/output/tui_sink_test.go +++ b/internal/output/tui_sink_test.go @@ -43,3 +43,52 @@ func TestTUISinkNilSenderNoPanic(t *testing.T) { sink := NewTUISink(nil) sink.Emit(MessageEvent{Severity: SeverityInfo, Text: "noop"}) } + +type testLogPrinter struct { + lines []LogLineEvent +} + +func (p *testLogPrinter) PrintLogLine(event LogLineEvent) { + p.lines = append(p.lines, event) +} + +// A streaming log sink routes LogLineEvent to the log printer and every +// other event to the sender, as before. +func TestStreamingLogSinkRoutesLogLinesToPrinter(t *testing.T) { + t.Parallel() + + sender := &testSender{} + printer := &testLogPrinter{} + sink := NewStreamingLogSink(sender, printer) + + sink.Emit(LogLineEvent{Source: "emulator", Line: "first", Level: LogLevelInfo}) + sink.Emit(MessageEvent{Severity: SeverityWarning, Text: "careful"}) + sink.Emit(LogLineEvent{Source: "emulator", Line: "second", Level: LogLevelInfo}) + + wantLines := []LogLineEvent{ + {Source: "emulator", Line: "first", Level: LogLevelInfo}, + {Source: "emulator", Line: "second", Level: LogLevelInfo}, + } + if !reflect.DeepEqual(printer.lines, wantLines) { + t.Fatalf("unexpected printed lines: got=%#v want=%#v", printer.lines, wantLines) + } + + wantSent := []any{MessageEvent{Severity: SeverityWarning, Text: "careful"}} + if !reflect.DeepEqual(sender.msgs, wantSent) { + t.Fatalf("unexpected sent msgs: got=%#v want=%#v", sender.msgs, wantSent) + } +} + +func TestStreamingLogSinkNilLogPrinterFallsBackToSender(t *testing.T) { + t.Parallel() + + sender := &testSender{} + sink := NewStreamingLogSink(sender, nil) + + sink.Emit(LogLineEvent{Source: "emulator", Line: "first", Level: LogLevelInfo}) + + want := []any{LogLineEvent{Source: "emulator", Line: "first", Level: LogLevelInfo}} + if !reflect.DeepEqual(sender.msgs, want) { + t.Fatalf("unexpected sent msgs: got=%#v want=%#v", sender.msgs, want) + } +} diff --git a/internal/ui/run_logs.go b/internal/ui/run_logs.go index 613d19d6..08cec3ac 100644 --- a/internal/ui/run_logs.go +++ b/internal/ui/run_logs.go @@ -12,6 +12,37 @@ import ( "github.com/localstack/lstk/internal/runtime" ) +// printlner lets tests substitute a fake *tea.Program. +type printlner interface { + Println(args ...interface{}) +} + +// programLogPrinter prints log lines permanently above the Program instead of +// the App model's capped a.lines buffer. Must be called synchronously from a +// single goroutine, not returned as a tea.Cmd — Cmds run concurrently and +// would reorder lines. +type programLogPrinter struct { + p printlner + ctx context.Context +} + +// Println, unlike Send, has no escape valve for a Program that already +// stopped reading its message channel (e.g. it quit, or a signal bypassed +// Update) — it would then block forever. Racing it against ctx.Done avoids +// wedging the caller; the abandoned goroutine only leaks in that shutdown +// race, and dies with the process. +func (l programLogPrinter) PrintLogLine(event output.LogLineEvent) { + done := make(chan struct{}) + go func() { + l.p.Println(renderLogLineEvent(event, 0)) + close(done) + }() + select { + case <-done: + case <-l.ctx.Done(): + } +} + func RunLogs(parentCtx context.Context, rt runtime.Runtime, containers []config.ContainerConfig, follow bool, tail string, verbose bool) error { ctx, cancel := context.WithCancel(parentCtx) defer cancel() @@ -21,7 +52,8 @@ func RunLogs(parentCtx context.Context, rt runtime.Runtime, containers []config. runErrCh := make(chan error, 1) go func() { - err := container.Logs(ctx, rt, output.NewTUISink(programSender{p: p}), containers, follow, tail, verbose) + sink := output.NewStreamingLogSink(programSender{p: p}, programLogPrinter{p: p, ctx: ctx}) + err := container.Logs(ctx, rt, sink, containers, follow, tail, verbose) runErrCh <- err if err != nil && !errors.Is(err, context.Canceled) { p.Send(runErrMsg{err: err}) diff --git a/internal/ui/run_logs_test.go b/internal/ui/run_logs_test.go new file mode 100644 index 00000000..d0ee3ba1 --- /dev/null +++ b/internal/ui/run_logs_test.go @@ -0,0 +1,66 @@ +package ui + +import ( + "context" + "testing" + "time" + + "github.com/localstack/lstk/internal/output" +) + +// blockingPrintlner simulates a Program that stopped draining Println, +// blocking forever like the real unbuffered channel send would. +type blockingPrintlner struct{} + +func (blockingPrintlner) Println(args ...interface{}) { + select {} +} + +func TestProgramLogPrinterReturnsPromptlyWhenCtxCancelledMidPrintln(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + printer := programLogPrinter{p: blockingPrintlner{}, ctx: ctx} + + done := make(chan struct{}) + go func() { + printer.PrintLogLine(output.LogLineEvent{Source: "emulator", Line: "hangs forever"}) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("PrintLogLine did not return after ctx was cancelled; it would hang RunLogs forever") + } +} + +type recordingPrintlner struct { + lines chan string +} + +func (r recordingPrintlner) Println(args ...interface{}) { + r.lines <- args[0].(string) +} + +func TestProgramLogPrinterWaitsForPrintlnWhenNotCancelled(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + recorder := recordingPrintlner{lines: make(chan string, 1)} + printer := programLogPrinter{p: recorder, ctx: ctx} + printer.PrintLogLine(output.LogLineEvent{Source: "emulator", Line: "hello", Level: output.LogLevelInfo}) + + select { + case line := <-recorder.lines: + if line == "" { + t.Fatal("expected rendered log line to be printed") + } + default: + t.Fatal("expected PrintLogLine to have already delivered the line to Println") + } +} diff --git a/test/integration/logs_test.go b/test/integration/logs_test.go index a49103b4..ac0916da 100644 --- a/test/integration/logs_test.go +++ b/test/integration/logs_test.go @@ -258,6 +258,29 @@ func TestLogsTailWithFollowStartsFromTail(t *testing.T) { } } +// Interactive lstk logs must preserve full scrollback like docker logs, not +// just whatever fit in the TUI's capped history. Runs under a real PTY so the +// TUI/RunLogs path is exercised. +func TestLogsInteractivePreservesFullScrollback(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + + const lineCount = 550 + writeNumberedLogLines(t, ctx, lineCount) + + configFile := writeAwsConfig(t) + out, err := runLstkInPTY(t, ctx, env.Without(), "--config", configFile, "logs") + require.NoError(t, err, "lstk logs should exit cleanly in interactive mode, output: %s", out) + + for i := 1; i <= lineCount; i++ { + assert.Contains(t, out, fmt.Sprintf("tail-marker-%d", i), "expected tail-marker-%d to survive scrollback", i) + } +} + func TestLogsFollowStreamsOutput(t *testing.T) { requireDocker(t) cleanup()