diff --git a/README.md b/README.md index 45a281c..3f31f6f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ sift is a lightweight terminal UI for displaying Go test results. It allows deve ## Installation ```bash -go install github.com/timtatt/sift@v0.11.0 +go install github.com/timtatt/sift@v0.12.1 ``` ## Try it out! @@ -23,7 +23,7 @@ You can try a demo of sift with the sample tests provided in the `samples` folde git clone github.com/timtatt/sift.git # Run sift -go test ./samples/... -v -json | sift +go test ./samples/... -json | sift ``` ## Usage @@ -31,10 +31,10 @@ go test ./samples/... -v -json | sift `sift` works by consuming the verbose json output from the `go test` command. The easiest way to use it is to pipe `|` the output straight into `sift` ```bash -go test {your-go-package} -v -json | sift +go test {your-go-package} -json | sift # eg. -go test ./... -v -json | sift +go test ./... -json | sift ``` ## Demo (v0.9.0) @@ -53,13 +53,13 @@ go test ./... -v -json | sift ```bash # Run in non-interactive mode (inline output) -go test ./... -v -json | sift -n +go test ./... -json | sift -n # Enable debug view -go test ./... -v -json | sift --debug +go test ./... -json | sift --debug # Disable log prettification -go test ./... -v -json | sift --raw +go test ./... -json | sift --raw ``` ### Keymaps diff --git a/cmd/cmd.go b/cmd/cmd.go index 452b7e2..6f95564 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -23,9 +23,10 @@ func (c *CLI) Run() error { os.Exit(0) } - return sift.Run(ctx, sift.SiftOptions{ + return sift.Run(ctx, sift.ProgramOptions{ Debug: c.Debug, NonInteractive: c.NonInteractive, PrettifyLogs: !c.RawLogs, }) + } diff --git a/internal/sift/keys.go b/internal/sift/keys.go index 82db8c7..1c4513d 100644 --- a/internal/sift/keys.go +++ b/internal/sift/keys.go @@ -24,6 +24,7 @@ type keyMap struct { ClearSearch key.Binding Help key.Binding Quit key.Binding + ForceQuit key.Binding ChangeMode key.Binding } @@ -131,8 +132,12 @@ var ( key.WithHelp("?", "toggle help"), ), Quit: key.NewBinding( - key.WithKeys("q", "ctrl+c"), + key.WithKeys("q"), key.WithHelp("q", "quit"), ), + ForceQuit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "force quit"), + ), } ) diff --git a/internal/sift/sift.go b/internal/sift/sift.go index b92dc11..42d0528 100644 --- a/internal/sift/sift.go +++ b/internal/sift/sift.go @@ -1,13 +1,12 @@ package sift import ( - "bufio" "context" - "encoding/json" - "errors" "fmt" "log/slog" "os" + "os/signal" + "syscall" "time" tea "github.com/charmbracelet/bubbletea" @@ -15,39 +14,19 @@ import ( "golang.org/x/sync/errgroup" ) -type sift struct { - program *tea.Program - model *siftModel -} - -func (s *sift) ScanStdin() error { - scanner := bufio.NewScanner(os.Stdin) - - for scanner.Scan() { - var line tests.TestOutputLine - - err := json.Unmarshal(scanner.Bytes(), &line) - if err != nil { - // TODO: write to a temp dir log - return errors.New("unable to parse json input. ensure to use the `-json` flag when running go tests") - } - - s.model.testManager.AddTestOutput(line) - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("failed to scan stdin: %w", err) +// IsStdinTerminal checks if stdin is a terminal (no piped input) +func IsStdinTerminal() bool { + stat, err := os.Stdin.Stat() + if err != nil { + return false } - - s.model.endTime = time.Now() - - return nil + return (stat.Mode() & os.ModeCharDevice) != 0 } type FrameMsg struct{} // sends a msg to bubbletea model on an interval to ensure the view is being updated according to framerate -func (s *sift) Frame(ctx context.Context, tps int) { +func FrameTicker(ctx context.Context, program *tea.Program, tps int) { tick := time.NewTicker(time.Second / time.Duration(tps)) defer tick.Stop() @@ -56,12 +35,12 @@ func (s *sift) Frame(ctx context.Context, tps int) { case <-ctx.Done(): return case <-tick.C: - s.program.Send(FrameMsg{}) + program.Send(FrameMsg{}) } } } -type SiftOptions struct { +type ProgramOptions struct { Debug bool NonInteractive bool PrettifyLogs bool @@ -83,7 +62,7 @@ func initLogging() error { return nil } -func Run(ctx context.Context, opts SiftOptions) error { +func Run(ctx context.Context, opts ProgramOptions) error { if opts.Debug { if err := initLogging(); err != nil { @@ -92,14 +71,25 @@ func Run(ctx context.Context, opts SiftOptions) error { slog.DebugContext(ctx, "starting sift", "options", opts) } - ctx, cancel := context.WithCancel(ctx) + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer cancel() fps := 120 g, ctx := errgroup.WithContext(ctx) - m := NewSiftModel(opts) + testManager := tests.NewTestManager(tests.TestManagerOpts{ + ParseLogs: opts.PrettifyLogs, + }) + + m, err := NewSiftModel(SiftModelOptions{ + ProgramOptions: opts, + TestManager: testManager, + }) + + if err != nil { + return fmt.Errorf("unable to create sift model: %w", err) + } programOpts := []tea.ProgramOption{ tea.WithFPS(fps), @@ -112,16 +102,13 @@ func Run(ctx context.Context, opts SiftOptions) error { p := tea.NewProgram(m, programOpts...) - sift := &sift{ - model: m, - program: p, - } - g.Go(func() error { - if err := sift.ScanStdin(); err != nil { + if err := testManager.ScanStdin(ctx, os.Stdin); err != nil { return err } + m.endTime = time.Now() + return nil }) @@ -135,17 +122,17 @@ func Run(ctx context.Context, opts SiftOptions) error { }) g.Go(func() error { - sift.Frame(ctx, fps) + FrameTicker(ctx, p, fps) return nil }) - err := g.Wait() + err = g.Wait() if err != nil { return err } m.quitting = false - fmt.Print(m.View()) + fmt.Println(m.View()) return nil } diff --git a/internal/sift/sift_test.go b/internal/sift/sift_test.go new file mode 100644 index 0000000..b91d837 --- /dev/null +++ b/internal/sift/sift_test.go @@ -0,0 +1,12 @@ +package sift + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsStdinTerminal(t *testing.T) { + result := IsStdinTerminal() + assert.IsType(t, true, result, "IsStdinTerminal should return a boolean") +} diff --git a/internal/sift/styles.go b/internal/sift/styles.go index 6d6fbd3..79e90ff 100644 --- a/internal/sift/styles.go +++ b/internal/sift/styles.go @@ -10,7 +10,7 @@ import ( var ( colorGreen = lipgloss.AdaptiveColor{ Light: "#2D7F1E", - Dark: "#5FD700", + Dark: "#4b9c09", } colorRed = lipgloss.AdaptiveColor{ Light: "#C41E3A", diff --git a/internal/sift/version.go b/internal/sift/version.go index f78195c..ce40638 100644 --- a/internal/sift/version.go +++ b/internal/sift/version.go @@ -1,3 +1,3 @@ package sift -var Version = "v0.12.1" +var Version = "v0.12.2" diff --git a/internal/sift/view.go b/internal/sift/view.go index 58a72a8..ea061a4 100644 --- a/internal/sift/view.go +++ b/internal/sift/view.go @@ -1,6 +1,7 @@ package sift import ( + "errors" "strings" "time" @@ -27,7 +28,7 @@ const ( ) type siftModel struct { - opts SiftOptions + opts ProgramOptions testManager *tests.TestManager testState map[tests.TestReference]*testState @@ -61,7 +62,17 @@ type cursor struct { log int // tracks the cursor log line } -func NewSiftModel(opts SiftOptions) *siftModel { +type SiftModelOptions struct { + ProgramOptions + TestManager *tests.TestManager +} + +func NewSiftModel(opts SiftModelOptions) (*siftModel, error) { + + if opts.TestManager == nil { + return nil, errors.New("missing test manager") + } + ti := textinput.New() ti.Placeholder = "search for tests" ti.PlaceholderStyle = styleSecondary @@ -74,10 +85,8 @@ func NewSiftModel(opts SiftOptions) *siftModel { } return &siftModel{ - opts: opts, - testManager: tests.NewTestManager(tests.TestManagerOpts{ - ParseLogs: opts.PrettifyLogs, - }), + opts: opts.ProgramOptions, + testManager: opts.TestManager, testState: make(map[tests.TestReference]*testState), autoToggleMode: false, compileSpinner: spinner.New(spinner.WithSpinner(spinner.Dot)), @@ -89,7 +98,7 @@ func NewSiftModel(opts SiftOptions) *siftModel { }, searchInput: ti, mode: mode, - } + }, nil } // normalizeSearchQuery removes spaces from the search query since Go replaces @@ -452,6 +461,9 @@ func (m *siftModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.searchInput.Focused() { switch { + case msg.String() == "ctrl+c": + m.quitting = true + return m, tea.Quit case msg.String() == "esc": // Exit search mode and clear query m.searchInput.Blur() @@ -575,6 +587,12 @@ func (m *siftModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Help): m.help.ShowAll = !m.help.ShowAll + case key.Matches(msg, keys.ForceQuit): + // ensure running inline mode for the final print + m.mode = viewModeInline + m.quitting = true + + return m, nil case key.Matches(msg, keys.Quit): if m.mode == viewModeAlternate { m.mode = viewModeInline diff --git a/internal/sift/view_test.go b/internal/sift/view_test.go index 2e349ff..00a6c7b 100644 --- a/internal/sift/view_test.go +++ b/internal/sift/view_test.go @@ -1,11 +1,13 @@ package sift import ( + "fmt" "testing" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/timtatt/sift/internal/tests" ) @@ -800,7 +802,14 @@ type testModelOpts struct { } func createTestModel(opts testModelOpts) *siftModel { - m := NewSiftModel(SiftOptions{}) + m, err := NewSiftModel(SiftModelOptions{ + TestManager: tests.NewTestManager(tests.TestManagerOpts{}), + }) + + if err != nil { + fmt.Println(err) + } + m.autoToggleMode = opts.autoToggleMode testCount := opts.testCount @@ -947,7 +956,12 @@ func TestIsTestVisible_SpaceHandling(t *testing.T) { for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - m := NewSiftModel(SiftOptions{}) + m, err := NewSiftModel(SiftModelOptions{ + TestManager: tests.NewTestManager(tests.TestManagerOpts{}), + }) + + require.NoError(t, err) + testRef := tests.TestReference{ Package: "test/package", Test: tt.testName, diff --git a/internal/tests/test_manager.go b/internal/tests/test_manager.go index 72007ce..459dd80 100644 --- a/internal/tests/test_manager.go +++ b/internal/tests/test_manager.go @@ -1,8 +1,14 @@ package tests import ( + "bufio" "cmp" + "context" + "encoding/json" + "errors" "fmt" + "io" + "log/slog" "slices" "strings" "sync" @@ -219,3 +225,61 @@ func (tm *TestManager) GetLogs(testRef TestReference) []logparse.LogEntry { return nil } + +func (tm *TestManager) ScanStdin(ctx context.Context, reader io.Reader) error { + + lines := make(chan []byte) + errChan := make(chan error) + + // scans in a separate channel to allow context cancellation + go func() { + scanner := bufio.NewScanner(reader) + + for scanner.Scan() { + // without a copy, the underlying array changes whilst it is being processed by the consumer + lineCopy := make([]byte, len(scanner.Bytes())) + copy(lineCopy, scanner.Bytes()) + lines <- lineCopy + } + + if err := scanner.Err(); err != nil { + errChan <- fmt.Errorf("failed to scan stdin: %w", err) + } + + close(lines) + close(errChan) + }() + +out: + for { + select { + // exit early if context is cancelled + case <-ctx.Done(): + return nil + case err := <-errChan: + return err + case line, ok := <-lines: + + // channel closed, finished processing + if !ok { + break out + } + + var testOutputLine TestOutputLine + + err := json.Unmarshal(line, &testOutputLine) + if err != nil { + slog.ErrorContext(ctx, "unable to parse json input", "err", err) + return errors.New("unable to parse json input. ensure to use the `-json` flag when running go tests") + } + + tm.AddTestOutput(testOutputLine) + } + } + + if tm.GetTestCount() == 0 { + return errors.New("no tests received, ensure to specify a package to run `go test` with") + } + + return nil +} diff --git a/main.go b/main.go index 92f5d88..4cff9ba 100644 --- a/main.go +++ b/main.go @@ -1,14 +1,27 @@ package main import ( + "fmt" + "os" + "github.com/alecthomas/kong" "github.com/timtatt/sift/cmd" + "github.com/timtatt/sift/internal/sift" ) func main() { var cli cmd.CLI ctx := kong.Parse(&cli) + + if sift.IsStdinTerminal() { + fmt.Fprintln(os.Stderr, "Warning: no input provided") + fmt.Fprintln(os.Stderr) + ctx.PrintUsage(false) + os.Exit(1) + } + err := ctx.Run() + ctx.FatalIfErrorf(err) }