diff --git a/cli/cmd/same/commands/commands_test.go b/cli/cmd/same/commands/commands_test.go index aff7fe1..000d092 100644 --- a/cli/cmd/same/commands/commands_test.go +++ b/cli/cmd/same/commands/commands_test.go @@ -331,3 +331,60 @@ func TestCleanCmd_All(t *testing.T) { t.Errorf("Expected environment cache directory to be removed, but it still exists") } } + +func TestRun_OutputModeFlags(t *testing.T) { + tests := []struct { + name string + args []string + }{ + { + name: "output-mode flag", + args: []string{"run", "--output-mode=linear", "build"}, + }, + { + name: "ci flag", + args: []string{"run", "--ci", "build"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLoader := mocks.NewMockConfigLoader(ctrl) + mockExecutor := mocks.NewMockExecutor(ctrl) + mockStore := mocks.NewMockBuildInfoStore(ctrl) + mockHasher := mocks.NewMockHasher(ctrl) + mockResolver := mocks.NewMockInputResolver(ctrl) + mockEnvFactory := mocks.NewMockEnvironmentFactory(ctrl) + + g := domain.NewGraph() + g.SetRoot(".") + buildTask := &domain.Task{Name: domain.NewInternedString("build"), WorkingDir: domain.NewInternedString("Root")} + _ = g.AddTask(buildTask) + + mockLogger := mocks.NewMockLogger(ctrl) + a := app.New(mockLoader, mockExecutor, mockLogger, mockStore, mockHasher, mockResolver, mockEnvFactory). + WithTeaOptions(tea.WithInput(nil), tea.WithOutput(io.Discard)) + + cli := commands.New(a) + + mockLoader.EXPECT().Load(".").Return(g, nil).Times(1) + mockResolver.EXPECT().ResolveInputs(gomock.Any(), ".").Return([]string{}, nil).Times(1) + mockHasher.EXPECT().ComputeInputHash(gomock.Any(), gomock.Any(), gomock.Any()).Return("hash123", nil).Times(1) + mockStore.EXPECT().Get("build").Return(nil, nil).Times(1) + mockExecutor.EXPECT().Execute( + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + ).Return(nil).Times(1) + mockStore.EXPECT().Put(gomock.Any()).Return(nil).Times(1) + + cli.SetArgs(tt.args) + + err := cli.Execute(context.Background()) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + } +} diff --git a/cli/cmd/same/commands/run.go b/cli/cmd/same/commands/run.go index e5ae41d..67055b9 100644 --- a/cli/cmd/same/commands/run.go +++ b/cli/cmd/same/commands/run.go @@ -18,13 +18,24 @@ func (c *CLI) newRunCmd() *cobra.Command { } noCache, _ := cmd.Flags().GetBool("no-cache") inspect, _ := cmd.Flags().GetBool("inspect") + outputMode, _ := cmd.Flags().GetString("output-mode") + ci, _ := cmd.Flags().GetBool("ci") + + // If --ci is set, override output-mode to "linear" + if ci { + outputMode = "linear" + } + return c.app.Run(cmd.Context(), args, app.RunOptions{ - NoCache: noCache, - Inspect: inspect, + NoCache: noCache, + Inspect: inspect, + OutputMode: outputMode, }) }, } cmd.Flags().BoolP("no-cache", "n", false, "Bypass the build cache and force execution") cmd.Flags().BoolP("inspect", "i", false, "Inspect the TUI after build completion (prevents auto-exit)") + cmd.Flags().StringP("output-mode", "o", "auto", "Output mode: auto, tui, or linear") + cmd.Flags().Bool("ci", false, "Use linear output mode (shorthand for --output-mode=linear)") return cmd } diff --git a/cli/go.mod b/cli/go.mod index 4fad547..02b4a60 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -50,7 +50,8 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect golang.org/x/mod v0.31.0 // indirect - golang.org/x/sys v0.39.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.3.8 // indirect golang.org/x/tools v0.40.0 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index 6f8e8b6..d037c16 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -111,6 +111,10 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= diff --git a/cli/internal/adapters/detector/env.go b/cli/internal/adapters/detector/env.go new file mode 100644 index 0000000..d0ba195 --- /dev/null +++ b/cli/internal/adapters/detector/env.go @@ -0,0 +1,49 @@ +// Package detector provides environment detection for output mode selection. +package detector + +import ( + "os" + + "golang.org/x/term" +) + +// OutputMode represents the rendering mode for the application. +type OutputMode int + +const ( + // ModeAuto automatically detects the appropriate mode. + ModeAuto OutputMode = iota + // ModeTUI forces the interactive TUI renderer. + ModeTUI + // ModeLinear forces the linear CI renderer. + ModeLinear +) + +// DetectEnvironment returns the recommended output mode based on the environment. +// It checks if stdout is a TTY and if CI environment variables are set. +func DetectEnvironment() OutputMode { + isTTY := term.IsTerminal(int(os.Stdout.Fd())) + + ci := os.Getenv("CI") + isCI := ci == "true" || ci == "1" + + if !isTTY || isCI { + return ModeLinear + } + return ModeTUI +} + +// ResolveMode applies user override flag to auto-detection. +// userFlag should be one of: "auto", "tui", "linear", "ci", or empty. +func ResolveMode(autoDetected OutputMode, userFlag string) OutputMode { + switch userFlag { + case "tui": + return ModeTUI + case "linear", "ci": + return ModeLinear + case "auto", "": + return autoDetected + default: + return autoDetected + } +} diff --git a/cli/internal/adapters/detector/env_test.go b/cli/internal/adapters/detector/env_test.go new file mode 100644 index 0000000..d79b646 --- /dev/null +++ b/cli/internal/adapters/detector/env_test.go @@ -0,0 +1,160 @@ +package detector_test + +import ( + "os" + "testing" + + "go.trai.ch/same/internal/adapters/detector" +) + +func TestDetectEnvironment(t *testing.T) { + tests := []struct { + name string + ciValue string + expected detector.OutputMode + }{ + { + name: "CI=true forces linear mode", + ciValue: "true", + expected: detector.ModeLinear, + }, + { + name: "CI=1 forces linear mode", + ciValue: "1", + expected: detector.ModeLinear, + }, + { + name: "CI=false does not force linear", + ciValue: "false", + expected: detector.ModeAuto, + }, + { + name: "No CI env var", + ciValue: "", + expected: detector.ModeAuto, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalCI := os.Getenv("CI") + defer func() { + if originalCI != "" { + _ = os.Setenv("CI", originalCI) + } else { + _ = os.Unsetenv("CI") + } + }() + + if tt.ciValue != "" { + if err := os.Setenv("CI", tt.ciValue); err != nil { + t.Fatalf("Failed to set CI: %v", err) + } + } else { + _ = os.Unsetenv("CI") + } + + mode := detector.DetectEnvironment() + + if tt.ciValue == "true" || tt.ciValue == "1" { + if mode != detector.ModeLinear { + t.Errorf("Expected ModeLinear with CI=%s, got %v", tt.ciValue, mode) + } + } + }) + } +} + +func TestResolveMode(t *testing.T) { + tests := []struct { + name string + autoDetected detector.OutputMode + userFlag string + expected detector.OutputMode + }{ + { + name: "auto respects auto-detection (TUI)", + autoDetected: detector.ModeTUI, + userFlag: "auto", + expected: detector.ModeTUI, + }, + { + name: "auto respects auto-detection (Linear)", + autoDetected: detector.ModeLinear, + userFlag: "auto", + expected: detector.ModeLinear, + }, + { + name: "empty flag respects auto-detection", + autoDetected: detector.ModeTUI, + userFlag: "", + expected: detector.ModeTUI, + }, + { + name: "tui overrides auto-detection", + autoDetected: detector.ModeLinear, + userFlag: "tui", + expected: detector.ModeTUI, + }, + { + name: "linear overrides auto-detection", + autoDetected: detector.ModeTUI, + userFlag: "linear", + expected: detector.ModeLinear, + }, + { + name: "ci is alias for linear", + autoDetected: detector.ModeTUI, + userFlag: "ci", + expected: detector.ModeLinear, + }, + { + name: "invalid flag respects auto-detection", + autoDetected: detector.ModeTUI, + userFlag: "invalid", + expected: detector.ModeTUI, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detector.ResolveMode(tt.autoDetected, tt.userFlag) + if got != tt.expected { + t.Errorf("ResolveMode(%v, %q) = %v, want %v", + tt.autoDetected, tt.userFlag, got, tt.expected) + } + }) + } +} + +func TestResolveMode_EdgeCases(t *testing.T) { + tests := []struct { + name string + autoDetected detector.OutputMode + userFlag string + expected detector.OutputMode + }{ + { + name: "unknown flag falls back to auto-detection (Linear)", + autoDetected: detector.ModeLinear, + userFlag: "unknown", + expected: detector.ModeLinear, + }, + { + name: "empty string falls back to auto-detection (Linear)", + autoDetected: detector.ModeLinear, + userFlag: "", + expected: detector.ModeLinear, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detector.ResolveMode(tt.autoDetected, tt.userFlag) + if got != tt.expected { + t.Errorf("ResolveMode(%v, %q) = %v, want %v", + tt.autoDetected, tt.userFlag, got, tt.expected) + } + }) + } +} diff --git a/cli/internal/adapters/detector/node.go b/cli/internal/adapters/detector/node.go new file mode 100644 index 0000000..dcac5be --- /dev/null +++ b/cli/internal/adapters/detector/node.go @@ -0,0 +1,10 @@ +package detector + +// Node is the graft node for dependency injection. +// The detector has no dependencies. +type Node struct{} + +// NewNode creates a new Node. +func NewNode() *Node { + return &Node{} +} diff --git a/cli/internal/adapters/linear/node.go b/cli/internal/adapters/linear/node.go new file mode 100644 index 0000000..c0ddd20 --- /dev/null +++ b/cli/internal/adapters/linear/node.go @@ -0,0 +1,15 @@ +package linear + +// Node is the graft node for dependency injection. +// The linear renderer has no dependencies. +type Node struct{} + +// NewNode creates a new Node. +func NewNode() *Node { + return &Node{} +} + +// Renderer returns a new LinearRenderer with stdout and stderr. +func (n *Node) Renderer() *Renderer { + return NewRenderer(nil, nil) +} diff --git a/cli/internal/adapters/linear/node_test.go b/cli/internal/adapters/linear/node_test.go new file mode 100644 index 0000000..554767b --- /dev/null +++ b/cli/internal/adapters/linear/node_test.go @@ -0,0 +1,22 @@ +package linear_test + +import ( + "testing" + + "go.trai.ch/same/internal/adapters/linear" +) + +func TestNode_NewNode(t *testing.T) { + node := linear.NewNode() + if node == nil { + t.Fatal("Expected non-nil node") + } +} + +func TestNode_Renderer(t *testing.T) { + node := linear.NewNode() + renderer := node.Renderer() + if renderer == nil { + t.Fatal("Expected non-nil renderer") + } +} diff --git a/cli/internal/adapters/linear/renderer.go b/cli/internal/adapters/linear/renderer.go new file mode 100644 index 0000000..01f2702 --- /dev/null +++ b/cli/internal/adapters/linear/renderer.go @@ -0,0 +1,229 @@ +// Package linear provides a synchronous, line-buffered renderer for CI environments. +package linear + +import ( + "bytes" + "context" + "fmt" + "hash/fnv" + "io" + "os" + "sync" + "time" + + "github.com/muesli/termenv" +) + +var colorPalette = []termenv.Color{ + termenv.ANSICyan, + termenv.ANSIMagenta, + termenv.ANSIYellow, + termenv.ANSIBlue, + termenv.ANSIBrightCyan, + termenv.ANSIBrightMagenta, + termenv.ANSIBrightYellow, + termenv.ANSIBrightBlue, +} + +// Renderer implements ports.Renderer for CI/non-interactive environments. +// It outputs linear, chronological logs with task name prefixes. +type Renderer struct { + stdout io.Writer + stderr io.Writer + output *termenv.Output + + mu sync.Mutex + tasks map[string]*taskState // spanID -> task state + buffers map[string]*bytes.Buffer +} + +type taskState struct { + name string + startTime time.Time + color termenv.Color +} + +// NewRenderer creates a new LinearRenderer. +func NewRenderer(stdout, stderr io.Writer) *Renderer { + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + // Create termenv.Output for color support + // WithColorCache(false) ensures TTY detection happens correctly + profile := colorProfile() + output := termenv.NewOutput(stderr, termenv.WithProfile(profile)) + + return &Renderer{ + stdout: stdout, + stderr: stderr, + output: output, + tasks: make(map[string]*taskState), + buffers: make(map[string]*bytes.Buffer), + } +} + +// colorProfile returns the color profile based on environment. +func colorProfile() termenv.Profile { + if os.Getenv("NO_COLOR") != "" { + return termenv.Ascii + } + // Use ANSI for basic color support in CI + return termenv.ANSI +} + +func assignColor(taskName string) termenv.Color { + h := fnv.New32a() + h.Write([]byte(taskName)) + hash := h.Sum32() + idx := hash % uint32(len(colorPalette)) //nolint:gosec // palette size is small and constant + return colorPalette[idx] +} + +// Start is a no-op for linear renderer (synchronous). +func (r *Renderer) Start(_ context.Context) error { + return nil +} + +// Stop flushes all remaining buffers. +func (r *Renderer) Stop() error { + r.mu.Lock() + defer r.mu.Unlock() + + // Flush all remaining buffers + for spanID := range r.buffers { + r.flushBufferLocked(spanID) + } + + return nil +} + +// Wait is a no-op for linear renderer (synchronous). +func (r *Renderer) Wait() error { + return nil +} + +// OnPlanEmit prints the planned tasks. +func (r *Renderer) OnPlanEmit(tasks []string, _ map[string][]string, targets []string) { + r.mu.Lock() + defer r.mu.Unlock() + + _, _ = fmt.Fprintf(r.stderr, "Planning to build %d task(s) for target(s): %v\n", + len(tasks), targets) +} + +// OnTaskStart prints a task start message. +func (r *Renderer) OnTaskStart(spanID, _ /* parentID */, name string, startTime time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + + color := assignColor(name) + r.tasks[spanID] = &taskState{ + name: name, + startTime: startTime, + color: color, + } + r.buffers[spanID] = new(bytes.Buffer) + + // Print start message to stderr + prefix := r.output.String(fmt.Sprintf("[%s]", name)).Foreground(color).String() + _, _ = fmt.Fprintf(r.stderr, "%s Starting...\n", prefix) +} + +// OnTaskLog buffers log data and prints complete lines with task prefix. +func (r *Renderer) OnTaskLog(spanID string, data []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + task, ok := r.tasks[spanID] + if !ok { + return + } + + buf := r.buffers[spanID] + buf.Write(data) + + // Process complete lines + for { + line, err := buf.ReadBytes('\n') + if err != nil { + // Incomplete line, put it back + if len(line) > 0 { + // Create a new buffer with the partial line + newBuf := new(bytes.Buffer) + newBuf.Write(line) + r.buffers[spanID] = newBuf + } + break + } + + // Print complete line with prefix + r.printLineLocked(task.name, task.color, line) + } +} + +// OnTaskComplete flushes remaining buffer and prints completion status. +func (r *Renderer) OnTaskComplete(spanID string, endTime time.Time, err error) { + r.mu.Lock() + defer r.mu.Unlock() + + task, ok := r.tasks[spanID] + if !ok { + return + } + + // Flush any remaining buffer + r.flushBufferLocked(spanID) + + // Print completion message + duration := endTime.Sub(task.startTime) + coloredPrefix := r.output.String(fmt.Sprintf("[%s]", task.name)).Foreground(task.color).String() + + if err != nil { + symbol := r.output.String("✗").Foreground(termenv.ANSIRed).String() + _, _ = fmt.Fprintf(r.stderr, "%s %s Failed after %v: %v\n", + coloredPrefix, symbol, duration, err) + } else { + symbol := r.output.String("✓").Foreground(termenv.ANSIGreen).String() + _, _ = fmt.Fprintf(r.stderr, "%s %s Completed in %v\n", + coloredPrefix, symbol, duration) + } + + // Cleanup + delete(r.tasks, spanID) + delete(r.buffers, spanID) +} + +// flushBufferLocked flushes any remaining data in the buffer for a task. +// Must be called with r.mu held. +func (r *Renderer) flushBufferLocked(spanID string) { + task, ok := r.tasks[spanID] + if !ok { + return + } + + buf := r.buffers[spanID] + if buf.Len() > 0 { + // Print the remaining partial line + r.printLineLocked(task.name, task.color, buf.Bytes()) + buf.Reset() + } +} + +// printLineLocked prints a line with the task name prefix. +// Must be called with r.mu held. +func (r *Renderer) printLineLocked(taskName string, color termenv.Color, line []byte) { + // Trim trailing newline for cleaner output + line = bytes.TrimSuffix(line, []byte("\n")) + line = bytes.TrimSuffix(line, []byte("\r")) + + if len(line) == 0 { + return + } + + prefix := r.output.String(fmt.Sprintf("[%s]", taskName)).Foreground(color).String() + _, _ = fmt.Fprintf(r.stdout, "%s %s\n", prefix, string(line)) +} diff --git a/cli/internal/adapters/linear/renderer_test.go b/cli/internal/adapters/linear/renderer_test.go new file mode 100644 index 0000000..fa3db8f --- /dev/null +++ b/cli/internal/adapters/linear/renderer_test.go @@ -0,0 +1,307 @@ +package linear_test + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + "time" + + "go.trai.ch/same/internal/adapters/linear" + "go.trai.ch/zerr" +) + +func TestRenderer_TaskLifecycle(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + ctx := context.Background() + if err := r.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + // Plan + r.OnPlanEmit([]string{"task1", "task2"}, map[string][]string{ + "task2": {"task1"}, + }, []string{"task2"}) + + if !strings.Contains(stderr.String(), "Planning to build 2 task(s)") { + t.Errorf("Expected plan message in stderr, got: %s", stderr.String()) + } + + // Task start + startTime := time.Now() + r.OnTaskStart("span1", "", "task1", startTime) + + if !strings.Contains(stderr.String(), "[task1]") { + t.Errorf("Expected task start message, got: %s", stderr.String()) + } + + // Task logs + r.OnTaskLog("span1", []byte("first line\n")) + r.OnTaskLog("span1", []byte("second line\n")) + + stdoutStr := stdout.String() + if !strings.Contains(stdoutStr, "task1") || !strings.Contains(stdoutStr, "first line") { + t.Errorf("Expected prefixed first line in stdout, got: %s", stdoutStr) + } + if !strings.Contains(stdoutStr, "task1") || !strings.Contains(stdoutStr, "second line") { + t.Errorf("Expected prefixed second line in stdout, got: %s", stdoutStr) + } + + // Task complete + endTime := startTime.Add(100 * time.Millisecond) + r.OnTaskComplete("span1", endTime, nil) + + if !strings.Contains(stderr.String(), "Completed") { + t.Errorf("Expected completion message, got: %s", stderr.String()) + } + + if err := r.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } +} + +func TestRenderer_PartialLines(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", "task1", startTime) + + // Send partial line + r.OnTaskLog("span1", []byte("partial")) + // Should not be printed yet + if strings.Contains(stdout.String(), "partial") { + t.Errorf("Partial line should not be printed immediately") + } + + // Complete the line + r.OnTaskLog("span1", []byte(" line\n")) + if !strings.Contains(stdout.String(), "task1") || !strings.Contains(stdout.String(), "partial line") { + t.Errorf("Expected complete line, got: %s", stdout.String()) + } + + // Flush on complete + r.OnTaskLog("span1", []byte("unflushed")) + endTime := startTime.Add(50 * time.Millisecond) + r.OnTaskComplete("span1", endTime, nil) + + if !strings.Contains(stdout.String(), "task1") || !strings.Contains(stdout.String(), "unflushed") { + t.Errorf("Expected flushed partial line on complete, got: %s", stdout.String()) + } +} + +func TestRenderer_TaskError(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", "failing-task", startTime) + + r.OnTaskLog("span1", []byte("error output\n")) + + endTime := startTime.Add(50 * time.Millisecond) + err := zerr.New("task failed") + r.OnTaskComplete("span1", endTime, err) + + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "Failed") { + t.Errorf("Expected failure message, got: %s", stderrStr) + } + if !strings.Contains(stderrStr, "task failed") { + t.Errorf("Expected error message, got: %s", stderrStr) + } +} + +func TestRenderer_ConcurrentTasks(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", "task1", startTime) + r.OnTaskStart("span2", "", "task2", startTime) + + // Interleaved logs + r.OnTaskLog("span1", []byte("task1 line 1\n")) + r.OnTaskLog("span2", []byte("task2 line 1\n")) + r.OnTaskLog("span1", []byte("task1 line 2\n")) + r.OnTaskLog("span2", []byte("task2 line 2\n")) + + stdoutStr := stdout.String() + lines := strings.Split(strings.TrimSpace(stdoutStr), "\n") + + // Verify all lines are prefixed correctly + expectedPrefixes := map[string]int{ + "[task1]": 2, + "[task2]": 2, + } + + for _, line := range lines { + for prefix := range expectedPrefixes { + if strings.Contains(line, prefix) { + expectedPrefixes[prefix]-- + } + } + } + + for prefix, count := range expectedPrefixes { + if count != 0 { + t.Errorf("Expected prefix %s to appear exactly, remaining: %d", prefix, count) + } + } + + endTime := startTime.Add(100 * time.Millisecond) + r.OnTaskComplete("span1", endTime, nil) + r.OnTaskComplete("span2", endTime, nil) +} + +func TestRenderer_NoColor(t *testing.T) { + if err := os.Setenv("NO_COLOR", "1"); err != nil { + t.Fatalf("Failed to set NO_COLOR: %v", err) + } + defer func() { + _ = os.Unsetenv("NO_COLOR") + }() + + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", "task1", startTime) + + endTime := startTime.Add(50 * time.Millisecond) + r.OnTaskComplete("span1", endTime, nil) + + // With NO_COLOR, output should not contain ANSI escape codes + stderrStr := stderr.String() + if strings.Contains(stderrStr, "\x1b[") { + t.Errorf("Expected no ANSI codes with NO_COLOR, got: %s", stderrStr) + } +} + +func TestColorAssignment(t *testing.T) { + tests := []struct { + name string + taskName string + }{ + {"task1", "task1"}, + {"task2", "task2"}, + {"build", "build"}, + {"test", "test"}, + {"deploy", "deploy"}, + } + + colorSeen := make(map[string]struct{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", tt.taskName, startTime) + + color1 := stderr.String() + + stderr.Reset() + r.OnTaskStart("span2", "", tt.taskName, startTime.Add(time.Second)) + + color2 := stderr.String() + + if color1 != color2 { + t.Errorf("Same task name %q should produce same color output", tt.taskName) + } + + if color1 != "" && !strings.Contains(color1, "\x1b[") { + t.Errorf("Expected ANSI color codes in output for task %q", tt.taskName) + } + + colorSeen[color1] = struct{}{} + }) + } + + if len(colorSeen) < 2 { + t.Errorf("Expected multiple different colors for different tasks, got %d unique colors", len(colorSeen)) + } +} + +func TestRenderer_OnTaskLogUnknownSpan(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + r.OnTaskLog("unknown-span", []byte("should be ignored\n")) + + if stdout.Len() != 0 { + t.Errorf("Expected no output for unknown span, got: %s", stdout.String()) + } +} + +func TestRenderer_OnTaskCompleteUnknownSpan(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + r.OnTaskComplete("unknown-span", time.Now(), nil) + + if stderr.Len() != 0 { + t.Errorf("Expected no output for unknown span completion, got: %s", stderr.String()) + } +} + +func TestRenderer_EmptyLines(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", "task1", startTime) + + r.OnTaskLog("span1", []byte("\n")) + r.OnTaskLog("span1", []byte("\r\n")) + + if strings.Contains(stdout.String(), "[task1]") { + t.Errorf("Expected no output for empty lines, got: %s", stdout.String()) + } +} + +func TestRenderer_StopFlushesBuffers(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + startTime := time.Now() + r.OnTaskStart("span1", "", "task1", startTime) + r.OnTaskStart("span2", "", "task2", startTime) + + r.OnTaskLog("span1", []byte("partial1")) + r.OnTaskLog("span2", []byte("partial2")) + + if err := r.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + stdoutStr := stdout.String() + if !strings.Contains(stdoutStr, "partial1") { + t.Errorf("Expected flushed partial1, got: %s", stdoutStr) + } + if !strings.Contains(stdoutStr, "partial2") { + t.Errorf("Expected flushed partial2, got: %s", stdoutStr) + } +} + +func TestRenderer_Wait(t *testing.T) { + var stdout, stderr bytes.Buffer + r := linear.NewRenderer(&stdout, &stderr) + + if err := r.Wait(); err != nil { + t.Errorf("Wait() should not error, got: %v", err) + } +} + +func TestRenderer_NilStdout(_ *testing.T) { + r := linear.NewRenderer(nil, nil) + + startTime := time.Now() + r.OnTaskStart("span1", "", "task1", startTime) + r.OnTaskLog("span1", []byte("test\n")) + r.OnTaskComplete("span1", startTime.Add(time.Second), nil) +} diff --git a/cli/internal/adapters/shell/executor.go b/cli/internal/adapters/shell/executor.go index b5c586b..9a3d93f 100644 --- a/cli/internal/adapters/shell/executor.go +++ b/cli/internal/adapters/shell/executor.go @@ -2,7 +2,6 @@ package shell import ( - "bytes" "context" "errors" "io" @@ -14,7 +13,6 @@ import ( "github.com/creack/pty" "go.trai.ch/same/internal/core/domain" - "go.trai.ch/same/internal/core/ports" "go.trai.ch/zerr" ) @@ -63,15 +61,11 @@ func (p *ptyProcess) Resize(rows, cols int) error { } // Executor implements ports.Executor using os/exec and pty. -type Executor struct { - logger ports.Logger -} +type Executor struct{} // NewExecutor creates a new ShellExecutor. -func NewExecutor(logger ports.Logger) *Executor { - return &Executor{ - logger: logger, - } +func NewExecutor() *Executor { + return &Executor{} } // Start launches the task's command in a PTY (on supported systems) or standard pipes. @@ -80,18 +74,9 @@ func (e *Executor) Start( ctx context.Context, task *domain.Task, env []string, - stdout, stderr io.Writer, + stdout, _ io.Writer, ) (Process, error) { - // Combined writers: - // 1. Structural Logger (info/error) - // 2. Output Writers (Span, etc.) - stdoutLog := &logWriter{logger: e.logger, level: "info"} - stderrLog := &logWriter{logger: e.logger, level: "error"} - - finalStdout := io.MultiWriter(stdoutLog, stdout) - finalStderr := io.MultiWriter(stderrLog, stderr) - - return start(ctx, task, env, finalStdout, finalStderr, stdoutLog, stderrLog) + return start(ctx, task, env, stdout, nil) } func start( @@ -99,7 +84,6 @@ func start( task *domain.Task, env []string, stdout, _ io.Writer, - stdoutLog, stderrLog *logWriter, ) (Process, error) { if len(task.Command) == 0 { return nil, nil @@ -141,15 +125,9 @@ func start( go func() { defer close(ioDone) defer func() { _ = ptmx.Close() }() - // Ensure any remaining buffered logs are flushed when IO is done - defer func() { - _ = stdoutLog.Close() - _ = stderrLog.Close() - }() // Copy output to both stdout and stderr (since PTY merges them) // We use io.Copy which creates a 32k buffer. This is efficient enough. - // The MultiWriter will ensure it goes to both logic logger and Span. _, _ = io.Copy(stdout, ptmx) }() @@ -189,52 +167,6 @@ func (e *Executor) Execute(ctx context.Context, task *domain.Task, env []string, return nil } -type logWriter struct { - logger ports.Logger - level string - buf []byte -} - -func (w *logWriter) Write(p []byte) (n int, err error) { - w.buf = append(w.buf, p...) - - // Scan for newlines - for { - i := bytes.IndexByte(w.buf, '\n') - if i < 0 { - break - } - - line := w.buf[:i] - w.logLine(line) - - // Advance buffer - w.buf = w.buf[i+1:] - } - - return len(p), nil -} - -func (w *logWriter) Close() error { - if len(w.buf) > 0 { - w.logLine(w.buf) - w.buf = nil - } - return nil -} - -func (w *logWriter) logLine(line []byte) { - msg := string(line) - // PTYs may introduce \r. Remove it. - msg = strings.TrimSuffix(msg, "\r") - - if w.level == "info" { - w.logger.Info(msg) - } else { - w.logger.Error(zerr.New(msg)) - } -} - // allowListedEnvVars are the system environment variables that are allowed to be // inherited by the task. This ensures the build environment is hermetic and // reproducible, while still allowing basic system tools to function. diff --git a/cli/internal/adapters/shell/executor_hermetic_test.go b/cli/internal/adapters/shell/executor_hermetic_test.go index 51ab49b..93e37d2 100644 --- a/cli/internal/adapters/shell/executor_hermetic_test.go +++ b/cli/internal/adapters/shell/executor_hermetic_test.go @@ -1,8 +1,8 @@ package shell_test import ( + "bytes" "context" - "io" "os" "path/filepath" "testing" @@ -10,20 +10,10 @@ import ( "github.com/stretchr/testify/require" "go.trai.ch/same/internal/adapters/shell" "go.trai.ch/same/internal/core/domain" - "go.trai.ch/same/internal/core/ports/mocks" - "go.uber.org/mock/gomock" ) func TestExecutor_Execute_HermeticBinaryOnly(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - // Expect implicit failure log if it fails, or success log if it succeeds - // If it succeeds, the script prints "success", which executor logs as Info - mockLogger.EXPECT().Info("success").Times(1) - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() // Create a temp directory to act as our "hermetic" bin path hermeticDir := t.TempDir() @@ -46,6 +36,10 @@ func TestExecutor_Execute_HermeticBinaryOnly(t *testing.T) { // Provide the hermetic PATH in env nixEnv := []string{"PATH=" + hermeticDir} - err = executor.Execute(context.Background(), task, nixEnv, io.Discard, io.Discard) + var stdout bytes.Buffer + err = executor.Execute(context.Background(), task, nixEnv, &stdout, &stdout) require.NoError(t, err) + + output := stdout.String() + require.Contains(t, output, "success") } diff --git a/cli/internal/adapters/shell/executor_test.go b/cli/internal/adapters/shell/executor_test.go index c4c80ea..ebc2a53 100644 --- a/cli/internal/adapters/shell/executor_test.go +++ b/cli/internal/adapters/shell/executor_test.go @@ -11,23 +11,10 @@ import ( "github.com/stretchr/testify/require" "go.trai.ch/same/internal/adapters/shell" "go.trai.ch/same/internal/core/domain" - "go.trai.ch/same/internal/core/ports/mocks" - "go.uber.org/mock/gomock" ) func TestExecutor_Execute_MultiLineOutput(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - - // Expect Info to be called twice, once for each line - // We use gomock.InOrder to ensure order, though not strictly required by prompt, it's good practice. - // However, the prompt just says "exactly twice". - mockLogger.EXPECT().Info("line1").Times(1) - mockLogger.EXPECT().Info("line2").Times(1) - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() // Use a valid temporary directory for the working directory tmpDir := t.TempDir() @@ -38,45 +25,37 @@ func TestExecutor_Execute_MultiLineOutput(t *testing.T) { WorkingDir: domain.NewInternedString(tmpDir), } - err := executor.Execute(context.Background(), task, nil, io.Discard, io.Discard) + var stdout bytes.Buffer + err := executor.Execute(context.Background(), task, nil, &stdout, io.Discard) require.NoError(t, err) + + output := stdout.String() + require.Contains(t, output, "line1") + require.Contains(t, output, "line2") } func TestExecutor_Execute_FragmentedOutput(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - - // Expect concatenated "part1part2" - mockLogger.EXPECT().Info("part1part2").Times(1) - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() tmpDir := t.TempDir() // Simulate fragmented write: "part1" then short sleep then "part2", then newline - // This ensures we test that the writer buffers until newline (or implicit close/flush) - // We use python or sh for this. Sh is simpler. task := &domain.Task{ Name: domain.NewInternedString("test-fragmented"), Command: []string{"sh", "-c", "printf part1; sleep 0.1; echo part2"}, WorkingDir: domain.NewInternedString(tmpDir), } - err := executor.Execute(context.Background(), task, nil, io.Discard, io.Discard) + var stdout bytes.Buffer + err := executor.Execute(context.Background(), task, nil, &stdout, io.Discard) require.NoError(t, err) + + output := stdout.String() + require.Contains(t, output, "part1") + require.Contains(t, output, "part2") } func TestExecutor_Execute_EnvironmentVariables(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - - // Expect the environment variable value to be logged - mockLogger.EXPECT().Info("test-value-123").Times(1) - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() // Use a valid temporary directory for the working directory tmpDir := t.TempDir() @@ -90,20 +69,16 @@ func TestExecutor_Execute_EnvironmentVariables(t *testing.T) { WorkingDir: domain.NewInternedString(tmpDir), } - err := executor.Execute(context.Background(), task, nil, io.Discard, io.Discard) + var stdout bytes.Buffer + err := executor.Execute(context.Background(), task, nil, &stdout, io.Discard) require.NoError(t, err) + + output := stdout.String() + require.Contains(t, output, "test-value-123") } func TestExecutor_Execute_InvalidCommand(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - - // For non-existent command, expect error to be logged - mockLogger.EXPECT().Error(gomock.Any()).AnyTimes() - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() tmpDir := t.TempDir() task := &domain.Task{ @@ -119,15 +94,7 @@ func TestExecutor_Execute_InvalidCommand(t *testing.T) { } func TestExecutor_Execute_CommandFailure(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - - // Command will output to stderr, so expect Error calls - mockLogger.EXPECT().Error(gomock.Any()).AnyTimes() - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() tmpDir := t.TempDir() task := &domain.Task{ @@ -148,11 +115,7 @@ func TestExecutor_Execute_CommandFailure(t *testing.T) { } func TestExecutor_Execute_EmptyCommand(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() tmpDir := t.TempDir() task := &domain.Task{ @@ -169,13 +132,7 @@ func TestExecutor_Execute_EmptyCommand(t *testing.T) { } func TestExecutor_Execute_AbsolutePath(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - mockLogger.EXPECT().Info(gomock.Any()).AnyTimes() - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() tmpDir := t.TempDir() task := &domain.Task{ @@ -189,13 +146,7 @@ func TestExecutor_Execute_AbsolutePath(t *testing.T) { } func TestExecutor_Execute_WithNixEnv(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - mockLogger.EXPECT().Info("nix-value").Times(1) - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() tmpDir := t.TempDir() task := &domain.Task{ @@ -205,29 +156,22 @@ func TestExecutor_Execute_WithNixEnv(t *testing.T) { } nixEnv := []string{"NIX_VAR=nix-value"} - err := executor.Execute(context.Background(), task, nixEnv, io.Discard, io.Discard) + var stdout bytes.Buffer + err := executor.Execute(context.Background(), task, nixEnv, &stdout, io.Discard) require.NoError(t, err) + + output := stdout.String() + require.Contains(t, output, "nix-value") } func TestExecutor_Execute_StreamsOutput(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - // We expect the logger to still be called via the logWriter, which strips ANSI - // The exact number of calls might vary depending on buffering/lines, but we expect at least one - mockLogger.EXPECT().Info(gomock.Any()).AnyTimes() - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() tmpDir := t.TempDir() // Command outputting ANSI red color ansiRed := "\033[31m" ansiReset := "\033[0m" msg := "Hello Red World" - // Ensure we echo without newline handling complications if possible, but basic echo works. - // We use 'printf' if available or just echo with codes. - // Sh usually supports printf. task := &domain.Task{ Name: domain.NewInternedString("test-ansi"), Command: []string{"sh", "-c", "printf '" + ansiRed + msg + ansiReset + "'"}, @@ -263,14 +207,7 @@ func (m *mockSpanWriter) MarkExecStart() { } func TestExecutor_Execute_WithMarkExecStartSpan(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - mockLogger.EXPECT().Info(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Error(gomock.Any()).AnyTimes() - - executor := shell.NewExecutor(mockLogger) + executor := shell.NewExecutor() tmpDir := t.TempDir() task := &domain.Task{ @@ -285,24 +222,3 @@ func TestExecutor_Execute_WithMarkExecStartSpan(t *testing.T) { assert.True(t, mockWriter.markExecCalled) } - -func TestExecutor_Execute_WithoutMarkExecStartSpan(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLogger := mocks.NewMockLogger(ctrl) - mockLogger.EXPECT().Info(gomock.Any()).AnyTimes() - - executor := shell.NewExecutor(mockLogger) - tmpDir := t.TempDir() - - task := &domain.Task{ - Name: domain.NewInternedString("test-no-mark-exec"), - Command: []string{"sh", "-c", "echo test"}, - WorkingDir: domain.NewInternedString(tmpDir), - } - - var stdout bytes.Buffer - err := executor.Execute(context.Background(), task, nil, &stdout, io.Discard) - require.NoError(t, err) -} diff --git a/cli/internal/adapters/shell/node.go b/cli/internal/adapters/shell/node.go index 7411694..c6aeeef 100644 --- a/cli/internal/adapters/shell/node.go +++ b/cli/internal/adapters/shell/node.go @@ -4,7 +4,6 @@ import ( "context" "github.com/grindlemire/graft" - "go.trai.ch/same/internal/adapters/logger" "go.trai.ch/same/internal/core/ports" ) @@ -15,13 +14,9 @@ func init() { graft.Register(graft.Node[ports.Executor]{ ID: NodeID, Cacheable: true, - DependsOn: []graft.ID{logger.NodeID}, - Run: func(ctx context.Context) (ports.Executor, error) { - log, err := graft.Dep[ports.Logger](ctx) - if err != nil { - return nil, err - } - return NewExecutor(log), nil + DependsOn: []graft.ID{}, + Run: func(_ context.Context) (ports.Executor, error) { + return NewExecutor(), nil }, }) } diff --git a/cli/internal/adapters/telemetry/bridge.go b/cli/internal/adapters/telemetry/bridge.go index 0001d2f..db56771 100644 --- a/cli/internal/adapters/telemetry/bridge.go +++ b/cli/internal/adapters/telemetry/bridge.go @@ -4,27 +4,27 @@ import ( "context" "errors" - tea "github.com/charmbracelet/bubbletea" "go.opentelemetry.io/otel/codes" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" + "go.trai.ch/same/internal/core/ports" ) -// TUIBridge implements sdktrace.SpanProcessor to bridge OTel spans to Bubble Tea messages. -type TUIBridge struct { - program *tea.Program +// Bridge implements sdktrace.SpanProcessor to bridge OTel spans to a Renderer. +type Bridge struct { + renderer ports.Renderer } -// NewTUIBridge returns a new TUIBridge. -func NewTUIBridge(program *tea.Program) *TUIBridge { - return &TUIBridge{ - program: program, +// NewBridge returns a new Bridge. +func NewBridge(renderer ports.Renderer) *Bridge { + return &Bridge{ + renderer: renderer, } } // OnStart is called when a span starts. -func (b *TUIBridge) OnStart(parent context.Context, s sdktrace.ReadWriteSpan) { - if b.program == nil { +func (b *Bridge) OnStart(parent context.Context, s sdktrace.ReadWriteSpan) { + if b.renderer == nil { return } @@ -38,17 +38,17 @@ func (b *TUIBridge) OnStart(parent context.Context, s sdktrace.ReadWriteSpan) { parentID = parentSpan.SpanContext().SpanID().String() } - b.program.Send(MsgTaskStart{ - SpanID: sc.SpanID().String(), - ParentID: parentID, - Name: s.Name(), - StartTime: s.StartTime(), - }) + b.renderer.OnTaskStart( + sc.SpanID().String(), + parentID, + s.Name(), + s.StartTime(), + ) } // OnEnd is called when a span ends. -func (b *TUIBridge) OnEnd(s sdktrace.ReadOnlySpan) { - if b.program == nil { +func (b *Bridge) OnEnd(s sdktrace.ReadOnlySpan) { + if b.renderer == nil { return } @@ -66,20 +66,19 @@ func (b *TUIBridge) OnEnd(s sdktrace.ReadOnlySpan) { err = errors.New(desc) } - b.program.Send(MsgTaskComplete{ - SpanID: sc.SpanID().String(), - EndTime: s.EndTime(), - Err: err, - }) + b.renderer.OnTaskComplete( + sc.SpanID().String(), + s.EndTime(), + err, + ) } // ForceFlush does nothing. -// ForceFlush does nothing. -func (b *TUIBridge) ForceFlush(_ context.Context) error { +func (b *Bridge) ForceFlush(_ context.Context) error { return nil } // Shutdown does nothing. -func (b *TUIBridge) Shutdown(_ context.Context) error { +func (b *Bridge) Shutdown(_ context.Context) error { return nil } diff --git a/cli/internal/adapters/telemetry/bridge_test.go b/cli/internal/adapters/telemetry/bridge_test.go new file mode 100644 index 0000000..becbff6 --- /dev/null +++ b/cli/internal/adapters/telemetry/bridge_test.go @@ -0,0 +1,120 @@ +package telemetry_test + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.trai.ch/same/internal/adapters/telemetry" + "go.trai.ch/same/internal/core/ports/mocks" + "go.uber.org/mock/gomock" +) + +func TestBridge_OnStart(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRenderer := mocks.NewMockRenderer(ctrl) + bridge := telemetry.NewBridge(mockRenderer) + + mockRenderer.EXPECT().OnTaskStart(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("test") + ctx, span := tracer.Start(context.Background(), "test-span") + defer span.End() + + if rwSpan, ok := span.(sdktrace.ReadWriteSpan); ok { + bridge.OnStart(ctx, rwSpan) + } +} + +func TestBridge_OnStartWithNilRenderer(_ *testing.T) { + bridge := telemetry.NewBridge(nil) + + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("test") + ctx, span := tracer.Start(context.Background(), "test-span") + defer span.End() + + if rwSpan, ok := span.(sdktrace.ReadWriteSpan); ok { + bridge.OnStart(ctx, rwSpan) + } +} + +func TestBridge_OnEnd(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRenderer := mocks.NewMockRenderer(ctrl) + bridge := telemetry.NewBridge(mockRenderer) + + mockRenderer.EXPECT().OnTaskComplete(gomock.Any(), gomock.Any(), nil).Times(1) + + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("test") + _, span := tracer.Start(context.Background(), "test-span") + span.End() + + if roSpan, ok := span.(sdktrace.ReadOnlySpan); ok { + bridge.OnEnd(roSpan) + } +} + +func TestBridge_OnEndWithError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRenderer := mocks.NewMockRenderer(ctrl) + bridge := telemetry.NewBridge(mockRenderer) + + mockRenderer.EXPECT().OnTaskComplete(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("test") + _, span := tracer.Start(context.Background(), "test-span") + span.SetStatus(codes.Error, "test error") + span.End() + + if roSpan, ok := span.(sdktrace.ReadOnlySpan); ok { + bridge.OnEnd(roSpan) + } +} + +func TestBridge_OnEndWithNilRenderer(_ *testing.T) { + bridge := telemetry.NewBridge(nil) + + tp := sdktrace.NewTracerProvider() + tracer := tp.Tracer("test") + _, span := tracer.Start(context.Background(), "test-span") + span.End() + + if roSpan, ok := span.(sdktrace.ReadOnlySpan); ok { + bridge.OnEnd(roSpan) + } +} + +func TestBridge_ForceFlush(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRenderer := mocks.NewMockRenderer(ctrl) + bridge := telemetry.NewBridge(mockRenderer) + + if err := bridge.ForceFlush(context.Background()); err != nil { + t.Errorf("ForceFlush() should not return error, got: %v", err) + } +} + +func TestBridge_Shutdown(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockRenderer := mocks.NewMockRenderer(ctrl) + bridge := telemetry.NewBridge(mockRenderer) + + if err := bridge.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown() should not return error, got: %v", err) + } +} diff --git a/cli/internal/adapters/telemetry/mock_test.go b/cli/internal/adapters/telemetry/mock_test.go new file mode 100644 index 0000000..01afb39 --- /dev/null +++ b/cli/internal/adapters/telemetry/mock_test.go @@ -0,0 +1,46 @@ +package telemetry_test + +import ( + "context" + "sync" + "time" +) + +// mockRenderer is a simple test double for ports.Renderer. +type mockRenderer struct { + mu sync.Mutex + planCalls int + startCalls int + logCalls int + completeCalls int + logs [][]byte +} + +func (m *mockRenderer) Start(_ context.Context) error { return nil } +func (m *mockRenderer) Stop() error { return nil } +func (m *mockRenderer) Wait() error { return nil } + +func (m *mockRenderer) OnPlanEmit(_ []string, _ map[string][]string, _ []string) { + m.mu.Lock() + defer m.mu.Unlock() + m.planCalls++ +} + +func (m *mockRenderer) OnTaskStart(_, _, _ string, _ time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + m.startCalls++ +} + +func (m *mockRenderer) OnTaskLog(_ string, data []byte) { + m.mu.Lock() + defer m.mu.Unlock() + m.logCalls++ + m.logs = append(m.logs, data) +} + +func (m *mockRenderer) OnTaskComplete(_ string, _ time.Time, _ error) { + m.mu.Lock() + defer m.mu.Unlock() + m.completeCalls++ +} diff --git a/cli/internal/adapters/telemetry/provider.go b/cli/internal/adapters/telemetry/provider.go index c2f330b..2164240 100644 --- a/cli/internal/adapters/telemetry/provider.go +++ b/cli/internal/adapters/telemetry/provider.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "sync" - "time" - tea "github.com/charmbracelet/bubbletea" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -14,98 +12,59 @@ import ( "go.trai.ch/same/internal/core/ports" ) -// LogBufferSize determines the size of the async log channel. -const LogBufferSize = 4096 - // OTelTracer is a concrete implementation of ports.Tracer using OpenTelemetry. type OTelTracer struct { - tracer trace.Tracer - program *tea.Program - logChan chan tea.Msg - mu sync.RWMutex + tracer trace.Tracer + renderer ports.Renderer + mu sync.RWMutex } // NewOTelTracer creates a new OTelTracer with the given instrumentation name. func NewOTelTracer(name string) *OTelTracer { - t := &OTelTracer{ - tracer: otel.Tracer(name), - logChan: make(chan tea.Msg, LogBufferSize), // Buffered to handle bursts - } - go t.runLoop() - return t -} - -func (t *OTelTracer) runLoop() { - for msg := range t.logChan { - t.mu.RLock() - prog := t.program - t.mu.RUnlock() - - if prog != nil { - prog.Send(msg) - } + return &OTelTracer{ + tracer: otel.Tracer(name), } } -// Shutdown stops the background log processor. +// Shutdown stops the tracer. func (t *OTelTracer) Shutdown(_ context.Context) error { - close(t.logChan) return nil } -// WithProgram sets the tea.Program to send logs to. -func (t *OTelTracer) WithProgram(p *tea.Program) *OTelTracer { +// WithRenderer sets the Renderer to send logs to. +func (t *OTelTracer) WithRenderer(r ports.Renderer) *OTelTracer { t.mu.Lock() defer t.mu.Unlock() - t.program = p + t.renderer = r return t } // Start creates a new span. func (t *OTelTracer) Start(ctx context.Context, name string, opts ...ports.SpanOption) (context.Context, ports.Span) { - // Apply internal options to SpanConfig (currently placeholder) cfg := &ports.SpanConfig{} for _, opt := range opts { opt(cfg) } - // Start OTel span ctx, span := t.tracer.Start(ctx, name) t.mu.RLock() - prog := t.program + r := t.renderer t.mu.RUnlock() var batcher *BatchProcessor - var eventSender func(tea.Msg) - if prog != nil { + if r != nil { spanID := span.SpanContext().SpanID().String() cb := func(data []byte) { - select { - case t.logChan <- MsgTaskLog{ - SpanID: spanID, - Data: data, - }: - default: - // Drop logs if buffer is full to prevent blocking the build - } + r.OnTaskLog(spanID, data) } - // Use generic defaults or smaller limits for UI responsiveness? batcher = NewBatchProcessor(0, 0, cb) - - eventSender = func(msg tea.Msg) { - select { - case t.logChan <- msg: - default: - // Drop event if buffer is full - } - } } - return ctx, &OTelSpan{span: span, batcher: batcher, eventSender: eventSender} + return ctx, &OTelSpan{span: span, batcher: batcher} } -// EmitPlan signals that a set of tasks is planned for execution by adding an event to the current span. +// EmitPlan signals that a set of tasks is planned for execution. func (t *OTelTracer) EmitPlan( ctx context.Context, taskNames []string, @@ -120,33 +79,18 @@ func (t *OTelTracer) EmitPlan( } t.mu.RLock() - prog := t.program + r := t.renderer t.mu.RUnlock() - if prog != nil { - select { - case t.logChan <- MsgInitTasks{ - Tasks: taskNames, - Dependencies: dependencies, - Targets: targets, - }: - default: - // Ensure InitTasks is sent even if buffer is full (blocking fallback) - // This is critical for UI initialization - t.logChan <- MsgInitTasks{ - Tasks: taskNames, - Dependencies: dependencies, - Targets: targets, - } - } + if r != nil { + r.OnPlanEmit(taskNames, dependencies, targets) } } // OTelSpan is a concrete implementation of ports.Span using OpenTelemetry. type OTelSpan struct { - span trace.Span - batcher *BatchProcessor - eventSender func(tea.Msg) + span trace.Span + batcher *BatchProcessor } // End completes the span. @@ -195,11 +139,4 @@ func (s *OTelSpan) Write(p []byte) (n int, err error) { // MarkExecStart signals that command execution has begun. func (s *OTelSpan) MarkExecStart() { s.span.AddEvent("exec_start") - - if s.eventSender != nil { - s.eventSender(MsgTaskExecStart{ - SpanID: s.span.SpanContext().SpanID().String(), - ExecStartTime: time.Now(), - }) - } } diff --git a/cli/internal/adapters/telemetry/provider_test.go b/cli/internal/adapters/telemetry/provider_test.go index 9311a5d..d98251a 100644 --- a/cli/internal/adapters/telemetry/provider_test.go +++ b/cli/internal/adapters/telemetry/provider_test.go @@ -3,8 +3,8 @@ package telemetry_test import ( "context" "testing" + "time" - tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -26,26 +26,18 @@ func TestOTelTracer_EmitPlan(t *testing.T) { defer func() { _ = tp.Shutdown(context.Background()) }() tracer := telemetry.NewOTelTracer("test-tracer") - - // We can use a dummy program model that records messages. - model := &testModel{ - msgs: make(chan tea.Msg, 10), - } - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(nil)) // Headless - - tracer.WithProgram(prog) + mock := &mockRenderer{} + tracer.WithRenderer(mock) ctx := context.Background() tracer.EmitPlan(ctx, []string{"task1", "task2"}, map[string][]string{}, []string{}) - // Wait for span _ = tp.ForceFlush(ctx) spans := sr.Ended() - // EmitPlan uses trace.SpanFromContext(ctx) which is empty here, - // so no attributes added to a span unless we create one. assert.Empty(t, spans) - // Create a span context + assert.Equal(t, 1, mock.planCalls) + ctx, span := tp.Tracer("test").Start(ctx, "root") tracer.EmitPlan(ctx, []string{"task1", "task2"}, map[string][]string{}, []string{}) span.End() @@ -54,31 +46,32 @@ func TestOTelTracer_EmitPlan(t *testing.T) { spans = sr.Ended() require.Len(t, spans, 1) - // Check events events := spans[0].Events() require.Len(t, events, 1) assert.Equal(t, "plan_emitted", events[0].Name) } -func TestOTelTracer_WithProgram_And_Start(t *testing.T) { - // Custom tracer to peek at internals if needed, or just use public API. +func TestOTelTracer_WithRenderer_And_Start(t *testing.T) { tracer := telemetry.NewOTelTracer("test-tracer") defer func() { _ = tracer.Shutdown(context.Background()) }() - // Let's just verify state. - prog := tea.NewProgram(nil) - tracer.WithProgram(prog) + mock := &mockRenderer{} + tracer.WithRenderer(mock) ctx, span := tracer.Start(context.Background(), "test-span") - otelSpan, ok := span.(*telemetry.OTelSpan) - require.True(t, ok) + _, err := span.Write([]byte("test log")) + require.NoError(t, err) - // If program is set, batcher should be initialized - assert.NotNil(t, otelSpan.Batcher()) + time.Sleep(100 * time.Millisecond) + + mock.mu.Lock() + logCount := mock.logCalls + mock.mu.Unlock() + + assert.Positive(t, logCount) span.End() - _ = ctx // usage to avoid unused check if needed, but span.End() doesn't need ctx - // Batcher should be closed/nil (internally). + _ = ctx } func TestOTelSpan_SetAttribute(t *testing.T) { @@ -94,7 +87,7 @@ func TestOTelSpan_SetAttribute(t *testing.T) { span.SetAttribute("float", 3.14) span.SetAttribute("bool", true) span.SetAttribute("slice", []string{"a", "b"}) - span.SetAttribute("unknown", struct{}{}) // Should fall to default case. + span.SetAttribute("unknown", struct{}{}) span.End() @@ -134,7 +127,6 @@ func TestOTelSpan_Write(t *testing.T) { tracer := telemetry.NewOTelTracer("test-tracer") - // Case 1: No program (no batcher). ctx, span := tracer.Start(context.Background(), "log-test-no-prog") n, err := span.Write([]byte("hello")) require.NoError(t, err) @@ -151,21 +143,6 @@ func TestOTelSpan_Write(t *testing.T) { assert.Equal(t, "hello", events[0].Attributes[0].Value.AsString()) } -// testModel is a dummy tea.Model to capture messages. -type testModel struct { - msgs chan tea.Msg -} - -func (m *testModel) Init() tea.Cmd { return nil } -func (m *testModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - select { - case m.msgs <- msg: - default: - } - return m, nil -} -func (m *testModel) View() string { return "" } - func TestOTelTracer_Shutdown_AfterStart(t *testing.T) { tracer := telemetry.NewOTelTracer("test-shutdown") @@ -177,33 +154,11 @@ func TestOTelTracer_Shutdown_AfterStart(t *testing.T) { span.End() } -func TestOTelTracer_WithProgram_Concurrency(_ *testing.T) { - tracer := telemetry.NewOTelTracer("test-concurrent") - defer func() { _ = tracer.Shutdown(context.Background()) }() - - prog := tea.NewProgram(nil) - - done := make(chan bool) - go func() { - tracer.WithProgram(prog) - done <- true - }() - - go func() { - _, span := tracer.Start(context.Background(), "concurrent-span") - span.End() - done <- true - }() - - <-done - <-done -} - -func TestOTelTracer_Start_WithoutProgram(t *testing.T) { - tracer := telemetry.NewOTelTracer("test-no-program") +func TestOTelTracer_Start_WithoutRenderer(t *testing.T) { + tracer := telemetry.NewOTelTracer("test-no-renderer") defer func() { _ = tracer.Shutdown(context.Background()) }() - ctx, span := tracer.Start(context.Background(), "no-program-span") + ctx, span := tracer.Start(context.Background(), "no-renderer-span") otelSpan, ok := span.(*telemetry.OTelSpan) require.True(t, ok) @@ -212,14 +167,14 @@ func TestOTelTracer_Start_WithoutProgram(t *testing.T) { _ = ctx } -func TestOTelTracer_Start_WithProgram(t *testing.T) { - tracer := telemetry.NewOTelTracer("test-with-program") +func TestOTelTracer_Start_WithRenderer(t *testing.T) { + tracer := telemetry.NewOTelTracer("test-with-renderer") defer func() { _ = tracer.Shutdown(context.Background()) }() - prog := tea.NewProgram(nil) - tracer.WithProgram(prog) + mock := &mockRenderer{} + tracer.WithRenderer(mock) - ctx, span := tracer.Start(context.Background(), "with-program-span") + ctx, span := tracer.Start(context.Background(), "with-renderer-span") otelSpan, ok := span.(*telemetry.OTelSpan) require.True(t, ok) @@ -228,53 +183,12 @@ func TestOTelTracer_Start_WithProgram(t *testing.T) { _ = ctx } -func TestOTelTracer_EmitPlan_BufferFull(_ *testing.T) { - tracer := telemetry.NewOTelTracer("test-buffer-full") - defer func() { _ = tracer.Shutdown(context.Background()) }() - - model := &testModel{msgs: make(chan tea.Msg, 10)} - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(nil)) - - go func() { - _, _ = prog.Run() - }() - - tracer.WithProgram(prog) - - ctx, span := tracer.Start(context.Background(), "test-span") - - for i := 0; i < 5; i++ { - tracer.EmitPlan(ctx, []string{"task1"}, map[string][]string{}, []string{}) - } - - span.End() - - prog.Quit() -} - -func TestOTelTracer_runLoop(_ *testing.T) { - tracer := telemetry.NewOTelTracer("test-runloop") - defer func() { _ = tracer.Shutdown(context.Background()) }() - - model := &testModel{msgs: make(chan tea.Msg, 10)} - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(nil)) - - tracer.WithProgram(prog) - - ctx, span := tracer.Start(context.Background(), "runloop-test") - _, _ = span.Write([]byte("test message")) - span.End() - - _ = ctx -} - func TestOTelSpan_Write_WithBatcher(t *testing.T) { tracer := telemetry.NewOTelTracer("test-write-batcher") defer func() { _ = tracer.Shutdown(context.Background()) }() - model := &testModel{msgs: make(chan tea.Msg, 10)} - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(nil)) - tracer.WithProgram(prog) + mock := &mockRenderer{} + tracer.WithRenderer(mock) ctx, span := tracer.Start(context.Background(), "batcher-write-test") @@ -316,29 +230,12 @@ func TestOTelSpan_MarkExecStart_WithoutEventSender(_ *testing.T) { _ = ctx } -func TestOTelSpan_MarkExecStart_WithEventSender(_ *testing.T) { - tracer := telemetry.NewOTelTracer("test-mark-exec-sender") - defer func() { _ = tracer.Shutdown(context.Background()) }() - - model := &testModel{msgs: make(chan tea.Msg, 10)} - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(nil)) - tracer.WithProgram(prog) - - ctx, span := tracer.Start(context.Background(), "event-sender-test") - - span.MarkExecStart() - - span.End() - _ = ctx -} - func TestOTelSpan_End_WithBatcher(_ *testing.T) { tracer := telemetry.NewOTelTracer("test-end-batcher") defer func() { _ = tracer.Shutdown(context.Background()) }() - model := &testModel{msgs: make(chan tea.Msg, 10)} - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(nil)) - tracer.WithProgram(prog) + mock := &mockRenderer{} + tracer.WithRenderer(mock) ctx, span := tracer.Start(context.Background(), "end-batcher-test") diff --git a/cli/internal/adapters/telemetry/telemetry_test.go b/cli/internal/adapters/telemetry/telemetry_test.go index ef5c473..9c44400 100644 --- a/cli/internal/adapters/telemetry/telemetry_test.go +++ b/cli/internal/adapters/telemetry/telemetry_test.go @@ -3,11 +3,9 @@ package telemetry_test import ( "context" "errors" - "io" "testing" "time" - tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/codes" @@ -15,155 +13,69 @@ import ( "go.trai.ch/same/internal/adapters/telemetry" ) -// TestModel captures messages for verification. -type TestModel struct { - Captured []tea.Msg - MsgCh chan tea.Msg -} - -func (m TestModel) Init() tea.Cmd { return nil } -func (m TestModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.MsgCh != nil { - select { - case m.MsgCh <- msg: - default: - } - } - return m, nil -} -func (m TestModel) View() string { return "" } - -func TestOTelTracer_WithProgram(t *testing.T) { - // Setup - msgCh := make(chan tea.Msg, 10) - model := TestModel{MsgCh: msgCh} - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(io.Discard)) - // We don't start the program because Send works even if not started? - // Actually Send returns immediately if program is updated? - // Bubbletea docs say Send is safe to call from any goroutine. - // But the program loop needs to be running to process messages if we want to Verify via Update. - // However, relying on running program is flaky. - // Ideally we trust Send works. - - // BUT, validation requires checking if Send was called. - // Since we can't mock Send, we MUST run the program. - go func() { - _, _ = prog.Run() - }() - // Allow startup - time.Sleep(10 * time.Millisecond) - defer prog.Quit() - - tracer := telemetry.NewOTelTracer("test-tracer").WithProgram(prog) +func TestOTelTracer_WithRenderer(t *testing.T) { + mock := &mockRenderer{} + tracer := telemetry.NewOTelTracer("test-tracer").WithRenderer(mock) ctx := context.Background() - // Test EmitPlan tracer.EmitPlan(ctx, []string{"task1"}, map[string][]string{}, []string{}) - select { - case msg := <-msgCh: - initMsg, ok := msg.(telemetry.MsgInitTasks) - require.True(t, ok) - assert.Equal(t, []string{"task1"}, initMsg.Tasks) - case <-time.After(100 * time.Millisecond): - t.Fatal("timeout waiting for MsgInitTasks") - } + mock.mu.Lock() + planCalls := mock.planCalls + mock.mu.Unlock() + assert.Equal(t, 1, planCalls) - // Test Start and Log _, span := tracer.Start(ctx, "test-span") _, err := span.Write([]byte("log data")) require.NoError(t, err) - // Wait for batcher (default 50ms) - select { - case msg := <-msgCh: - logMsg, ok := msg.(telemetry.MsgTaskLog) - require.True(t, ok) - assert.Equal(t, []byte("log data"), logMsg.Data) - // SpanID should be valid - assert.NotEmpty(t, logMsg.SpanID) - case <-time.After(200 * time.Millisecond): - t.Fatal("timeout waiting for MsgTaskLog") - } + time.Sleep(100 * time.Millisecond) + + mock.mu.Lock() + logCalls := mock.logCalls + mock.mu.Unlock() + assert.Positive(t, logCalls) span.End() } -func TestTUIBridge(t *testing.T) { - msgCh := make(chan tea.Msg, 10) - model := TestModel{MsgCh: msgCh} - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(io.Discard)) - go func() { - _, _ = prog.Run() - }() - time.Sleep(10 * time.Millisecond) - defer prog.Quit() - - bridge := telemetry.NewTUIBridge(prog) +func TestBridge(t *testing.T) { + mock := &mockRenderer{} + bridge := telemetry.NewBridge(mock) tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(bridge)) tracer := tp.Tracer("test-bridge") - // Test OnStart _, span := tracer.Start(context.Background(), "test-task") - select { - case msg := <-msgCh: - startMsg, ok := msg.(telemetry.MsgTaskStart) - require.True(t, ok) - assert.Equal(t, "test-task", startMsg.Name) - assert.NotEmpty(t, startMsg.SpanID) - case <-time.After(100 * time.Millisecond): - t.Fatal("timeout waiting for MsgTaskStart") - } + time.Sleep(10 * time.Millisecond) + mock.mu.Lock() + startCalls := mock.startCalls + mock.mu.Unlock() + assert.Equal(t, 1, startCalls) - // Test OnEnd (Success) span.End() - select { - case msg := <-msgCh: - endMsg, ok := msg.(telemetry.MsgTaskComplete) - require.True(t, ok) - require.NoError(t, endMsg.Err) - case <-time.After(100 * time.Millisecond): - t.Fatal("timeout waiting for MsgTaskComplete") - } + time.Sleep(10 * time.Millisecond) + mock.mu.Lock() + completeCalls := mock.completeCalls + mock.mu.Unlock() + assert.Equal(t, 1, completeCalls) - // Test OnEnd (Error) _, spanErr := tracer.Start(context.Background(), "test-error") - // Consume start msg - <-msgCh + time.Sleep(10 * time.Millisecond) spanErr.RecordError(errors.New("some error")) spanErr.SetStatus(codes.Error, "task failed explicitly") spanErr.End() - select { - case msg := <-msgCh: - endMsg, ok := msg.(telemetry.MsgTaskComplete) - require.True(t, ok) - require.Error(t, endMsg.Err) - assert.Contains(t, endMsg.Err.Error(), "task failed explicitly") - case <-time.After(100 * time.Millisecond): - t.Fatal("timeout waiting for MsgTaskComplete (Error)") - } + time.Sleep(10 * time.Millisecond) + mock.mu.Lock() + completeCalls = mock.completeCalls + mock.mu.Unlock() + assert.Equal(t, 2, completeCalls) } func TestOTelSpan_Attributes(_ *testing.T) { - // Verify SetAttribute types usage - // Using a SDK tracer to verify attributes might be complex, - // but we just want to ensure the switch case is covered and doesn't panic. - // Since OTelSpan wraps a trace.Span, we trust the underlying implementation - // but we should call SetAttribute with different types to cover the switch in provider.go:97. - - // Helper to spy on attributes? - // OTel API doesn't easily expose attributes of a recording span without an exporter/processor. - // Use a mock span? OTel interfaces are hard to mock manually due to private methods? - // Actually `trace.Span` interface can be mocked if we implement `RecordError`, `AddEvent`, etc. - // But `OTelSpan` struct has `trace.Span` field. - - // We can use a real tracer with a memory exporter? - // Or just call the methods and ensure no panic (coverage will still count). - tracer := telemetry.NewOTelTracer("test") _, span := tracer.Start(context.Background(), "test") @@ -173,23 +85,19 @@ func TestOTelSpan_Attributes(_ *testing.T) { span.SetAttribute("float64", 12.34) span.SetAttribute("bool", true) span.SetAttribute("slice", []string{"a", "b"}) - span.SetAttribute("other", complex(1, 1)) // Coverage for default case + span.SetAttribute("other", complex(1, 1)) span.End() } -func TestTracer_NoProgram(t *testing.T) { - // Cover branches where program is nil - tracer := telemetry.NewOTelTracer("test") // No WithProgram +func TestTracer_NoRenderer(t *testing.T) { + tracer := telemetry.NewOTelTracer("test") ctx := context.Background() - // EmitPlan tracer.EmitPlan(ctx, []string{"task"}, map[string][]string{}, []string{}) - // Start _, span := tracer.Start(ctx, "task") - // Write (should just add event to span) n, err := span.Write([]byte("log")) require.NoError(t, err) assert.Equal(t, 3, n) @@ -197,25 +105,15 @@ func TestTracer_NoProgram(t *testing.T) { span.End() } -func TestBridge_NoProgram(t *testing.T) { - bridge := telemetry.NewTUIBridge(nil) +func TestBridge_NoRenderer(t *testing.T) { + bridge := telemetry.NewBridge(nil) assert.NotNil(t, bridge) - // Should be safe to call methods - bridge.OnStart(context.Background(), nil) // mocked or nil span? - // OnStart takes ReadWriteSpan interface. Passing nil will likely panic if not checked, - // but the code checks `if b.program == nil`. - - // We need a span to pass to OnStart/OnEnd to adhere to interface signature if we mock it? - // Since we are using real OTel SDK in previous tests, we can use it here too. - tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(bridge)) tracer := tp.Tracer("test") _, span := tracer.Start(context.Background(), "test") span.End() - - // No panic means success } func TestOTelTracer_Shutdown(t *testing.T) { @@ -236,17 +134,9 @@ func TestOTelSpan_RecordError(_ *testing.T) { span.End() } -func TestOTelTracer_LogBufferFull(_ *testing.T) { - msgCh := make(chan tea.Msg, 1) - model := TestModel{MsgCh: msgCh} - prog := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(io.Discard)) - go func() { - _, _ = prog.Run() - }() - time.Sleep(10 * time.Millisecond) - defer prog.Quit() - - tracer := telemetry.NewOTelTracer("test").WithProgram(prog) +func TestOTelTracer_LogBatching(t *testing.T) { + mock := &mockRenderer{} + tracer := telemetry.NewOTelTracer("test").WithRenderer(mock) ctx := context.Background() _, span := tracer.Start(ctx, "test-span") @@ -258,4 +148,9 @@ func TestOTelTracer_LogBufferFull(_ *testing.T) { span.End() time.Sleep(100 * time.Millisecond) + + mock.mu.Lock() + logCalls := mock.logCalls + mock.mu.Unlock() + assert.Positive(t, logCalls) } diff --git a/cli/internal/adapters/tui/renderer.go b/cli/internal/adapters/tui/renderer.go new file mode 100644 index 0000000..360371e --- /dev/null +++ b/cli/internal/adapters/tui/renderer.go @@ -0,0 +1,87 @@ +package tui + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + "go.trai.ch/same/internal/adapters/telemetry" +) + +// Renderer wraps the TUI Bubble Tea model as a ports.Renderer. +type Renderer struct { + program *tea.Program + model *Model + errCh chan error +} + +// NewRenderer creates a new TUI renderer. +func NewRenderer(model *Model, opts ...tea.ProgramOption) *Renderer { + program := tea.NewProgram(model, opts...) + return &Renderer{ + program: program, + model: model, + errCh: make(chan error, 1), + } +} + +// Start launches the TUI in a background goroutine. +func (r *Renderer) Start(_ context.Context) error { + go func() { + _, err := r.program.Run() + r.errCh <- err + }() + return nil +} + +// Stop signals the TUI to quit. +func (r *Renderer) Stop() error { + r.program.Quit() + return nil +} + +// Wait blocks until the TUI has terminated. +func (r *Renderer) Wait() error { + return <-r.errCh +} + +// OnPlanEmit forwards plan initialization to the TUI. +func (r *Renderer) OnPlanEmit(tasks []string, deps map[string][]string, targets []string) { + r.program.Send(telemetry.MsgInitTasks{ + Tasks: tasks, + Dependencies: deps, + Targets: targets, + }) +} + +// OnTaskStart forwards task start events to the TUI. +func (r *Renderer) OnTaskStart(spanID, parentID, name string, startTime time.Time) { + r.program.Send(telemetry.MsgTaskStart{ + SpanID: spanID, + ParentID: parentID, + Name: name, + StartTime: startTime, + }) +} + +// OnTaskLog forwards task log data to the TUI. +func (r *Renderer) OnTaskLog(spanID string, data []byte) { + r.program.Send(telemetry.MsgTaskLog{ + SpanID: spanID, + Data: data, + }) +} + +// OnTaskComplete forwards task completion events to the TUI. +func (r *Renderer) OnTaskComplete(spanID string, endTime time.Time, err error) { + r.program.Send(telemetry.MsgTaskComplete{ + SpanID: spanID, + EndTime: endTime, + Err: err, + }) +} + +// Program returns the underlying tea.Program for testing. +func (r *Renderer) Program() *tea.Program { + return r.program +} diff --git a/cli/internal/adapters/tui/renderer_test.go b/cli/internal/adapters/tui/renderer_test.go new file mode 100644 index 0000000..4fe4cc8 --- /dev/null +++ b/cli/internal/adapters/tui/renderer_test.go @@ -0,0 +1,188 @@ +package tui_test + +import ( + "context" + "io" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "go.trai.ch/same/internal/adapters/tui" + "go.trai.ch/zerr" +) + +func TestRenderer_Lifecycle(t *testing.T) { + model := tui.NewModel(io.Discard) + renderer := tui.NewRenderer( + &model, + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + tea.WithoutRenderer(), + ) + + ctx := context.Background() + if err := renderer.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if err := renderer.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + if err := renderer.Wait(); err != nil { + t.Fatalf("Wait() error = %v", err) + } +} + +func TestRenderer_OnPlanEmit(t *testing.T) { + model := tui.NewModel(io.Discard) + renderer := tui.NewRenderer( + &model, + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + tea.WithoutRenderer(), + ) + + ctx := context.Background() + if err := renderer.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer func() { + _ = renderer.Stop() + _ = renderer.Wait() + }() + + tasks := []string{"task1", "task2"} + deps := map[string][]string{ + "task2": {"task1"}, + } + targets := []string{"task2"} + + renderer.OnPlanEmit(tasks, deps, targets) + + time.Sleep(10 * time.Millisecond) +} + +func TestRenderer_OnTaskStart(t *testing.T) { + model := tui.NewModel(io.Discard) + renderer := tui.NewRenderer( + &model, + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + tea.WithoutRenderer(), + ) + + ctx := context.Background() + if err := renderer.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer func() { + _ = renderer.Stop() + _ = renderer.Wait() + }() + + startTime := time.Now() + renderer.OnTaskStart("span1", "", "task1", startTime) + + time.Sleep(10 * time.Millisecond) +} + +func TestRenderer_OnTaskLog(t *testing.T) { + model := tui.NewModel(io.Discard) + renderer := tui.NewRenderer( + &model, + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + tea.WithoutRenderer(), + ) + + ctx := context.Background() + if err := renderer.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer func() { + _ = renderer.Stop() + _ = renderer.Wait() + }() + + startTime := time.Now() + renderer.OnTaskStart("span1", "", "task1", startTime) + renderer.OnTaskLog("span1", []byte("test log line\n")) + + time.Sleep(10 * time.Millisecond) +} + +func TestRenderer_OnTaskComplete(t *testing.T) { + model := tui.NewModel(io.Discard) + renderer := tui.NewRenderer( + &model, + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + tea.WithoutRenderer(), + ) + + ctx := context.Background() + if err := renderer.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer func() { + _ = renderer.Stop() + _ = renderer.Wait() + }() + + startTime := time.Now() + renderer.OnTaskStart("span1", "", "task1", startTime) + endTime := startTime.Add(100 * time.Millisecond) + renderer.OnTaskComplete("span1", endTime, nil) + + time.Sleep(10 * time.Millisecond) +} + +func TestRenderer_OnTaskCompleteWithError(t *testing.T) { + model := tui.NewModel(io.Discard) + renderer := tui.NewRenderer( + &model, + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + tea.WithoutRenderer(), + ) + + ctx := context.Background() + if err := renderer.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer func() { + _ = renderer.Stop() + _ = renderer.Wait() + }() + + startTime := time.Now() + renderer.OnTaskStart("span1", "", "task1", startTime) + endTime := startTime.Add(100 * time.Millisecond) + renderer.OnTaskComplete("span1", endTime, zerr.New("task failed")) + + time.Sleep(10 * time.Millisecond) +} + +func TestRenderer_Program(t *testing.T) { + model := tui.NewModel(io.Discard) + renderer := tui.NewRenderer( + &model, + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + tea.WithoutRenderer(), + ) + + program := renderer.Program() + if program == nil { + t.Error("Expected non-nil Program()") + } +} diff --git a/cli/internal/app/app.go b/cli/internal/app/app.go index b14d964..a50d93a 100644 --- a/cli/internal/app/app.go +++ b/cli/internal/app/app.go @@ -11,7 +11,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "go.opentelemetry.io/otel" sdktrace "go.opentelemetry.io/otel/sdk/trace" - "go.trai.ch/same/internal/adapters/logger" //nolint:depguard // concrete type assertion + "go.trai.ch/same/internal/adapters/detector" + "go.trai.ch/same/internal/adapters/linear" "go.trai.ch/same/internal/adapters/telemetry" "go.trai.ch/same/internal/adapters/tui" "go.trai.ch/same/internal/core/domain" @@ -71,33 +72,15 @@ func (a *App) WithDisableTick() *App { // RunOptions configuration for the Run method. type RunOptions struct { - NoCache bool - Inspect bool + NoCache bool + Inspect bool + OutputMode string } // Run executes the build process for the specified targets. // //nolint:cyclop // orchestration function func (a *App) Run(ctx context.Context, targetNames []string, opts RunOptions) error { - // 0. Redirect Logs for TUI - // We want to avoid polluting the terminal with app logs while the TUI is running. - if err := os.MkdirAll(domain.DefaultSamePath(), domain.DirPerm); err != nil { - return zerr.Wrap(err, "failed to create internal directory") - } - f, err := os.OpenFile(domain.DefaultDebugLogPath(), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, domain.PrivateFilePerm) - if err != nil { - return zerr.Wrap(err, "failed to open debug log") - } - defer func() { - _ = f.Close() - }() - - // If we have the concrete logger type, redirect it. - if l, ok := a.logger.(*logger.Logger); ok { - l.SetOutput(f) - defer l.SetOutput(os.Stderr) - } - // 1. Load the graph graph, err := a.configLoader.Load(".") if err != nil { @@ -109,29 +92,35 @@ func (a *App) Run(ctx context.Context, targetNames []string, opts RunOptions) er return domain.ErrNoTargetsSpecified } - // 3. Initialize TUI - // The TUI model holds the state of the UI. - tuiModel := tui.NewModel(os.Stderr) - if a.disableTick { - tuiModel = tuiModel.WithDisableTick() + // 3. Initialize Renderer + // Detect environment and resolve output mode + autoMode := detector.DetectEnvironment() + mode := detector.ResolveMode(autoMode, opts.OutputMode) + + var renderer ports.Renderer + if mode == detector.ModeTUI { + model := tui.NewModel(os.Stderr) + if a.disableTick { + model = model.WithDisableTick() + } + optsTea := append([]tea.ProgramOption{tea.WithContext(ctx)}, a.teaOptions...) + renderer = tui.NewRenderer(&model, optsTea...) + } else { + renderer = linear.NewRenderer(os.Stdout, os.Stderr) } - // The Program manages the TUI lifecycle. - // We capture the program to clean it up if needed. - optsTea := append([]tea.ProgramOption{tea.WithContext(ctx)}, a.teaOptions...) - program := tea.NewProgram(&tuiModel, optsTea...) // 4. Initialize Telemetry - // Create a bridge that sends OTel spans to the TUI program. - bridge := telemetry.NewTUIBridge(program) + // Create a bridge that sends OTel spans to the renderer. + bridge := telemetry.NewBridge(renderer) - // Configure the global OTel SDK to usage our bridge for spans. + // Configure the global OTel SDK to use our bridge for spans. // This ensures that when OTelTracer uses otel.Tracer(), it uses a provider // that forwards events to our bridge. setupOTel(bridge) // Create and configure the OTel Tracer adapter. - // We inject the program so it can stream logs directly via the batcher. - tracer := telemetry.NewOTelTracer("same").WithProgram(program) + // We inject the renderer so it can stream logs directly via the batcher. + tracer := telemetry.NewOTelTracer("same").WithRenderer(renderer) defer func() { _ = tracer.Shutdown(ctx) }() @@ -146,22 +135,16 @@ func (a *App) Run(ctx context.Context, targetNames []string, opts RunOptions) er a.envFactory, ) - // 6. Run TUI and Scheduler concurrently - // Use a cancelable context to coordinate shutdown. - ctx, cancel := context.WithCancel(ctx) - defer cancel() - + // 6. Run Renderer and Scheduler concurrently g, ctx := errgroup.WithContext(ctx) - // TUI Routine + // Renderer Routine g.Go(func() error { - // Program.Run blocks until the program exits. - if _, err := program.Run(); err != nil { + if err := renderer.Start(ctx); err != nil { return err } - // If TUI quits first (e.g. user triggers quit), ensure we cancel the scheduler. - cancel() - return nil + // Wait blocks until the renderer has terminated. + return renderer.Wait() }) // Scheduler Routine @@ -169,13 +152,12 @@ func (a *App) Run(ctx context.Context, targetNames []string, opts RunOptions) er defer func() { // Handle panic recovery for the scheduler goroutine if r := recover(); r != nil { - // We can't log easily here as TUI is running, but we should ensure quit. - // Program shutdown will restore terminal. - fmt.Printf("Scheduler panic: %v\n", r) + // Print panic info before renderer shutdown + fmt.Fprintf(os.Stderr, "Scheduler panic: %v\n", r) } - // Ensure TUI hits tea.Quit when scheduler finishes, UNLESS inspection mode is on. + // Ensure renderer stops when scheduler finishes, UNLESS inspection mode is on. if !opts.Inspect { - program.Quit() + _ = renderer.Stop() } }() @@ -221,10 +203,10 @@ func (a *App) Clean(_ context.Context, options CleanOptions) error { return errs } -// setupOTel configures the OpenTelemetry SDK with the TUI bridge. -func setupOTel(bridge *telemetry.TUIBridge) { - // Create a new TracerProvider with the TUI bridge as a SpanProcessor. - // This ensures that all started spans are reported to the TUI. +// setupOTel configures the OpenTelemetry SDK with the renderer bridge. +func setupOTel(bridge *telemetry.Bridge) { + // Create a new TracerProvider with the bridge as a SpanProcessor. + // This ensures that all started spans are reported to the renderer. tp := sdktrace.NewTracerProvider( sdktrace.WithSpanProcessor(bridge), ) diff --git a/cli/internal/app/app_test.go b/cli/internal/app/app_test.go index 5f3457f..3287228 100644 --- a/cli/internal/app/app_test.go +++ b/cli/internal/app/app_test.go @@ -251,63 +251,6 @@ func TestApp_Run_BuildExecutionFailed(t *testing.T) { }) } -func TestApp_Run_LogSetupFailure(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - // Use a temporary directory for the test - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current working directory: %v", err) - } - defer func() { - if errChdir := os.Chdir(cwd); errChdir != nil { - t.Fatalf("Failed to restore working directory: %v", errChdir) - } - }() - - tmpDir := t.TempDir() - if errChdir := os.Chdir(tmpDir); errChdir != nil { - t.Fatalf("Failed to change into temp directory: %v", errChdir) - } - - // Create a file named .same to cause MkdirAll to fail - // Note: DefaultSamePath returns ".same" - if writeErr := os.WriteFile(domain.DefaultSamePath(), []byte("conflict"), domain.PrivateFilePerm); writeErr != nil { - t.Fatalf("Failed to create conflict file: %v", writeErr) - } - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockLoader := mocks.NewMockConfigLoader(ctrl) - mockExecutor := mocks.NewMockExecutor(ctrl) - mockStore := mocks.NewMockBuildInfoStore(ctrl) - mockHasher := mocks.NewMockHasher(ctrl) - mockResolver := mocks.NewMockInputResolver(ctrl) - mockEnvFactory := mocks.NewMockEnvironmentFactory(ctrl) - mockLogger := mocks.NewMockLogger(ctrl) - - // Setup App - a := app.New(mockLoader, mockExecutor, mockLogger, mockStore, mockHasher, mockResolver, mockEnvFactory). - WithTeaOptions( - tea.WithInput(strings.NewReader("")), - tea.WithOutput(io.Discard), - tea.WithoutSignalHandler(), - ). - WithDisableTick() - - // Execute - should fail before calling Load - err = a.Run(context.Background(), []string{"task1"}, app.RunOptions{NoCache: false}) - if err == nil { - t.Error("Expected error, got nil") - } - - // Expect wrapped error - if !strings.Contains(err.Error(), "failed to create internal directory") { - t.Errorf("Expected error containing 'failed to create internal directory', got: %v", err) - } - }) -} - //nolint:cyclop // Table-driven test func TestApp_Clean(t *testing.T) { tests := []struct { @@ -438,3 +381,120 @@ func TestApp_Clean(t *testing.T) { }) } } + +func TestApp_Run_LinearMode(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current working directory: %v", err) + } + defer func() { + if errChdir := os.Chdir(cwd); errChdir != nil { + t.Fatalf("Failed to restore working directory: %v", errChdir) + } + }() + + tmpDir := t.TempDir() + if errChdir := os.Chdir(tmpDir); errChdir != nil { + t.Fatalf("Failed to change into temp directory: %v", errChdir) + } + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLoader := mocks.NewMockConfigLoader(ctrl) + mockExecutor := mocks.NewMockExecutor(ctrl) + mockStore := mocks.NewMockBuildInfoStore(ctrl) + mockHasher := mocks.NewMockHasher(ctrl) + mockResolver := mocks.NewMockInputResolver(ctrl) + mockEnvFactory := mocks.NewMockEnvironmentFactory(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + g := domain.NewGraph() + g.SetRoot(".") + task := &domain.Task{Name: domain.NewInternedString("task1"), WorkingDir: domain.NewInternedString("Root")} + _ = g.AddTask(task) + + a := app.New(mockLoader, mockExecutor, mockLogger, mockStore, mockHasher, mockResolver, mockEnvFactory). + WithTeaOptions( + tea.WithInput(strings.NewReader("")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + tea.WithoutRenderer(), + ). + WithDisableTick() + + mockResolver.EXPECT().ResolveInputs(gomock.Any(), ".").Return([]string{}, nil) + mockLoader.EXPECT().Load(".").Return(g, nil) + mockHasher.EXPECT().ComputeInputHash(task, nil, []string{}).Return("hash", nil) + mockStore.EXPECT().Get("task1").Return(nil, nil) + mockExecutor.EXPECT().Execute(gomock.Any(), task, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().Put(gomock.Any()).Return(nil) + + err = a.Run(context.Background(), []string{"task1"}, app.RunOptions{ + NoCache: false, + OutputMode: "linear", + }) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) +} + +func TestApp_Run_InspectMode(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current working directory: %v", err) + } + defer func() { + if errChdir := os.Chdir(cwd); errChdir != nil { + t.Fatalf("Failed to restore working directory: %v", errChdir) + } + }() + + tmpDir := t.TempDir() + if errChdir := os.Chdir(tmpDir); errChdir != nil { + t.Fatalf("Failed to change into temp directory: %v", errChdir) + } + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLoader := mocks.NewMockConfigLoader(ctrl) + mockExecutor := mocks.NewMockExecutor(ctrl) + mockStore := mocks.NewMockBuildInfoStore(ctrl) + mockHasher := mocks.NewMockHasher(ctrl) + mockResolver := mocks.NewMockInputResolver(ctrl) + mockEnvFactory := mocks.NewMockEnvironmentFactory(ctrl) + mockLogger := mocks.NewMockLogger(ctrl) + + g := domain.NewGraph() + g.SetRoot(".") + task := &domain.Task{Name: domain.NewInternedString("task1"), WorkingDir: domain.NewInternedString("Root")} + _ = g.AddTask(task) + + a := app.New(mockLoader, mockExecutor, mockLogger, mockStore, mockHasher, mockResolver, mockEnvFactory). + WithTeaOptions( + tea.WithInput(strings.NewReader("q")), + tea.WithOutput(io.Discard), + tea.WithoutSignalHandler(), + ). + WithDisableTick() + + mockResolver.EXPECT().ResolveInputs(gomock.Any(), ".").Return([]string{}, nil) + mockLoader.EXPECT().Load(".").Return(g, nil) + mockHasher.EXPECT().ComputeInputHash(task, nil, []string{}).Return("hash", nil) + mockStore.EXPECT().Get("task1").Return(nil, nil) + mockExecutor.EXPECT().Execute(gomock.Any(), task, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().Put(gomock.Any()).Return(nil) + + err = a.Run(context.Background(), []string{"task1"}, app.RunOptions{ + NoCache: false, + Inspect: true, + }) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) +} diff --git a/cli/internal/core/ports/renderer.go b/cli/internal/core/ports/renderer.go new file mode 100644 index 0000000..3508b22 --- /dev/null +++ b/cli/internal/core/ports/renderer.go @@ -0,0 +1,49 @@ +package ports + +import ( + "context" + "time" +) + +// Renderer is the abstraction for output rendering. +// It decouples telemetry collection from presentation logic, +// allowing the same event stream to drive either a rich TUI or linear CI logs. +// +//go:generate mockgen -source=renderer.go -destination=mocks/mock_renderer.go -package=mocks +type Renderer interface { + // Start initializes the renderer and begins its lifecycle. + // For asynchronous renderers (like TUI), this may launch background goroutines. + Start(ctx context.Context) error + + // Stop signals the renderer to stop accepting new events and prepare for shutdown. + // It should flush any buffered output. + Stop() error + + // Wait blocks until the renderer has fully terminated. + // For synchronous renderers, this may return immediately. + Wait() error + + // OnPlanEmit is called when the scheduler has planned the task graph. + // tasks: list of all task names in execution order + // deps: dependency map (task -> list of dependencies) + // targets: the user-requested target tasks + OnPlanEmit(tasks []string, deps map[string][]string, targets []string) + + // OnTaskStart is called when a task begins execution. + // spanID: unique identifier for this task execution + // parentID: spanID of the parent task (empty if root) + // name: human-readable task name + // startTime: when the task started + OnTaskStart(spanID, parentID, name string, startTime time.Time) + + // OnTaskLog is called when a task emits output. + // spanID: identifier for the task + // data: raw log bytes (may contain partial lines or ANSI sequences) + OnTaskLog(spanID string, data []byte) + + // OnTaskComplete is called when a task finishes execution. + // spanID: identifier for the task + // endTime: when the task completed + // err: nil if successful, error otherwise + OnTaskComplete(spanID string, endTime time.Time, err error) +} diff --git a/cli/internal/engine/scheduler/scheduler_env_test.go b/cli/internal/engine/scheduler/scheduler_env_test.go index 128f267..942ff7b 100644 --- a/cli/internal/engine/scheduler/scheduler_env_test.go +++ b/cli/internal/engine/scheduler/scheduler_env_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" "go.trai.ch/same/internal/core/domain" + "go.trai.ch/same/internal/core/ports" "go.trai.ch/same/internal/core/ports/mocks" "go.trai.ch/same/internal/engine/scheduler" "go.uber.org/mock/gomock" @@ -45,7 +46,7 @@ func TestScheduler_Execute_UsesEnvFactory(t *testing.T) { // 1. EmitPlan mockTracer.EXPECT().EmitPlan(gomock.Any(), []string{"build"}, gomock.Any(), gomock.Any()) - mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(ctx, mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(ctx, ports.Span(mockSpan)) // We expect hydration to be called. // 4. Env Factory Resolution expectedEnv := []string{"GO_VERSION=1.22.2", "PATH=/nix/store/go/bin"} @@ -54,7 +55,7 @@ func TestScheduler_Execute_UsesEnvFactory(t *testing.T) { mockSpan.EXPECT().End() // Hydration end // Task Execution - mockTracer.EXPECT().Start(gomock.Any(), "build").Return(ctx, mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "build").Return(ctx, ports.Span(mockSpan)) mockSpan.EXPECT().End() // Task end // 2. Input Hashing diff --git a/cli/internal/engine/scheduler/scheduler_test.go b/cli/internal/engine/scheduler/scheduler_test.go index 33657fc..a3abce8 100644 --- a/cli/internal/engine/scheduler/scheduler_test.go +++ b/cli/internal/engine/scheduler/scheduler_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.trai.ch/same/internal/core/domain" + "go.trai.ch/same/internal/core/ports" "go.trai.ch/same/internal/core/ports/mocks" "go.trai.ch/same/internal/engine/scheduler" "go.uber.org/mock/gomock" @@ -66,7 +67,7 @@ func TestScheduler_Run_Diamond(t *testing.T) { gomock.Any(), gomock.Any(), ) - mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(context.Background(), mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(context.Background(), ports.Span(mockSpan)) mockSpan.EXPECT().End().Times(4) // 1x Hydration, 3x Tasks (D, B, C) mockSpan.EXPECT().RecordError(gomock.Any()).Do(func(err error) { if err.Error() != "B failed" { @@ -75,9 +76,9 @@ func TestScheduler_Run_Diamond(t *testing.T) { }) // Tasks D, B, C run. A is skipped due to deps failing. - mockTracer.EXPECT().Start(gomock.Any(), "D").Return(context.Background(), mockSpan) - mockTracer.EXPECT().Start(gomock.Any(), "B").Return(context.Background(), mockSpan) - mockTracer.EXPECT().Start(gomock.Any(), "C").Return(context.Background(), mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "D").Return(context.Background(), ports.Span(mockSpan)) + mockTracer.EXPECT().Start(gomock.Any(), "B").Return(context.Background(), ports.Span(mockSpan)) + mockTracer.EXPECT().Start(gomock.Any(), "C").Return(context.Background(), ports.Span(mockSpan)) // Channels for synchronization dStarted := make(chan struct{}) @@ -178,12 +179,12 @@ func TestScheduler_Run_Partial(t *testing.T) { // Expectations mockTracer.EXPECT().EmitPlan(gomock.Any(), gomock.InAnyOrder([]string{"A", "B", "C"}), gomock.Any(), gomock.Any()) - mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(context.Background(), mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(context.Background(), ports.Span(mockSpan)) // Tasks C, B, A run - mockTracer.EXPECT().Start(gomock.Any(), "C").Return(context.Background(), mockSpan) - mockTracer.EXPECT().Start(gomock.Any(), "B").Return(context.Background(), mockSpan) - mockTracer.EXPECT().Start(gomock.Any(), "A").Return(context.Background(), mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "C").Return(context.Background(), ports.Span(mockSpan)) + mockTracer.EXPECT().Start(gomock.Any(), "B").Return(context.Background(), ports.Span(mockSpan)) + mockTracer.EXPECT().Start(gomock.Any(), "A").Return(context.Background(), ports.Span(mockSpan)) mockSpan.EXPECT().End().Times(4) @@ -237,11 +238,11 @@ func TestScheduler_Run_ExplicitAll(t *testing.T) { s := scheduler.NewScheduler(mockExec, mockStore, mockHasher, mockResolver, mockTracer, nil) mockTracer.EXPECT().EmitPlan(gomock.Any(), gomock.InAnyOrder([]string{"A", "B", "C"}), gomock.Any(), gomock.Any()) - mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(context.Background(), mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(context.Background(), ports.Span(mockSpan)) - mockTracer.EXPECT().Start(gomock.Any(), "A").Return(context.Background(), mockSpan) - mockTracer.EXPECT().Start(gomock.Any(), "B").Return(context.Background(), mockSpan) - mockTracer.EXPECT().Start(gomock.Any(), "C").Return(context.Background(), mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "A").Return(context.Background(), ports.Span(mockSpan)) + mockTracer.EXPECT().Start(gomock.Any(), "B").Return(context.Background(), ports.Span(mockSpan)) + mockTracer.EXPECT().Start(gomock.Any(), "C").Return(context.Background(), ports.Span(mockSpan)) mockSpan.EXPECT().End().Times(4) mockResolver.EXPECT().ResolveInputs(gomock.Any(), ".").Return([]string{}, nil).Times(3) @@ -279,7 +280,7 @@ func TestScheduler_Run_EmptyTargets(t *testing.T) { mockTracer.EXPECT().EmitPlan(gomock.Any(), []string{}, gomock.Any(), gomock.Any()) mockSpan := mocks.NewMockSpan(ctrl) - mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(context.Background(), mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(context.Background(), ports.Span(mockSpan)) mockSpan.EXPECT().End() mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) @@ -395,8 +396,8 @@ func TestScheduler_Run_Caching(t *testing.T) { // 1. First Run: Cache Miss mockTracer.EXPECT().EmitPlan(gomock.Any(), []string{"build"}, gomock.Any(), gomock.Any()) - mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(ctx, mockSpan) - mockTracer.EXPECT().Start(gomock.Any(), "build").Return(ctx, mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(ctx, ports.Span(mockSpan)) + mockTracer.EXPECT().Start(gomock.Any(), "build").Return(ctx, ports.Span(mockSpan)) mockSpan.EXPECT().End().Times(2) mockResolver.EXPECT().ResolveInputs([]string{}, ".").Return([]string{}, nil) @@ -411,8 +412,8 @@ func TestScheduler_Run_Caching(t *testing.T) { // 2. Second Run: Cache Hit mockTracer.EXPECT().EmitPlan(gomock.Any(), []string{"build"}, gomock.Any(), gomock.Any()) - mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(ctx, mockSpan) - mockTracer.EXPECT().Start(gomock.Any(), "build").Return(ctx, mockSpan) + mockTracer.EXPECT().Start(gomock.Any(), "Hydrating Environments").Return(ctx, ports.Span(mockSpan)) + mockTracer.EXPECT().Start(gomock.Any(), "build").Return(ctx, ports.Span(mockSpan)) mockSpan.EXPECT().End().Times(2) mockSpan.EXPECT().SetAttribute("same.cached", true) diff --git a/flake.nix b/flake.nix index d5e5af0..0970aa9 100644 --- a/flake.nix +++ b/flake.nix @@ -43,7 +43,7 @@ inherit version; src = ./cli; - vendorHash = "sha256-cRI4tfCLBqp8p/deZGe0t2uLiYIIkvQDP/IANDNjom4="; + vendorHash = "sha256-dtmW7rFQfLBww7mNMPTkyG3SvkcU2En+bHY2BTA8y4w="; env.CGO_ENABLED = 0;