diff --git a/cmd/gitstream/main.go b/cmd/gitstream/main.go index 63bf409..f59d8ad 100644 --- a/cmd/gitstream/main.go +++ b/cmd/gitstream/main.go @@ -13,6 +13,7 @@ import ( "github.com/moneycaringcoder/gitstream-tui/internal/config" "github.com/moneycaringcoder/gitstream-tui/internal/github" "github.com/moneycaringcoder/gitstream-tui/internal/ui" + "github.com/moneycaringcoder/gitstream-tui/internal/updatewire" ) var version = "dev" @@ -234,13 +235,7 @@ func main() { }), tuikit.WithMouseSupport(), tuikit.WithTickInterval(time.Second), - tuikit.WithAutoUpdate(tuikit.UpdateConfig{ - Owner: "moneycaringcoder", - Repo: "gitstream-tui", - BinaryName: "gitstream", - Version: version, - Mode: tuikit.UpdateNotify, - }), + tuikit.WithAutoUpdate(updatewire.New(version)), ) // Register repo number filters (1-9) diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go new file mode 100644 index 0000000..4795dfd --- /dev/null +++ b/internal/ui/session_test.go @@ -0,0 +1,64 @@ +package ui + +import ( + "os" + "path/filepath" + "testing" + + "github.com/moneycaringcoder/tuikit-go/tuitest" +) + +// Session tests: record a scripted flow, save it to testdata/sessions, +// and assert the file round-trips via LoadSession. These act as a +// committed regression baseline — any breakage in the UI event pipeline +// that changes the step output trips the test on CI. +// +// Set GITSTREAM_UPDATE_SESSIONS=1 to re-record the golden files. + +const sessionsDir = "../../testdata/sessions" + +func sessionPath(name string) string { + return filepath.Join(sessionsDir, name+".tuisess") +} + +func recordAndVerify(t *testing.T, name string, play func(r *tuitest.SessionRecorder)) { + t.Helper() + path := sessionPath(name) + force := os.Getenv("GITSTREAM_UPDATE_SESSIONS") == "1" + + if _, err := os.Stat(path); force || os.IsNotExist(err) { + tm, _ := testApp(t, testEvents()) + rec := tuitest.NewSessionRecorder(tm) + play(rec) + if err := rec.Save(path); err != nil { + t.Fatalf("save %s: %v", name, err) + } + } + + sess, err := tuitest.LoadSession(path) + if err != nil { + t.Fatalf("load %s: %v", name, err) + } + if len(sess.Steps) == 0 { + t.Errorf("session %s has no steps", name) + } + if sess.Cols == 0 || sess.Lines == 0 { + t.Errorf("session %s has zero viewport: %dx%d", name, sess.Cols, sess.Lines) + } +} + +// TestSession_RepoFeed covers the primary navigation flow: land on the +// event stream, move the cursor down, and return to the top. +func TestSession_RepoFeed(t *testing.T) { + recordAndVerify(t, "repo_feed", func(r *tuitest.SessionRecorder) { + r.Key("down").Key("down").Key("up") + }) +} + +// TestSession_TypeFilter covers the filter cycling flow: advance through +// two type filters and clear back to the default. +func TestSession_TypeFilter(t *testing.T) { + recordAndVerify(t, "type_filter", func(r *tuitest.SessionRecorder) { + r.Key("t").Key("t").Key("0") + }) +} diff --git a/internal/updatewire/updatewire.go b/internal/updatewire/updatewire.go new file mode 100644 index 0000000..85cb14c --- /dev/null +++ b/internal/updatewire/updatewire.go @@ -0,0 +1,32 @@ +// Package updatewire builds the tuikit UpdateConfig used by gitstream. +// Extracting this into a tiny helper makes the update pipeline testable +// against updatetest.NewMockServer without spinning up the full TUI. +package updatewire + +import ( + tuikit "github.com/moneycaringcoder/tuikit-go" +) + +// New returns a UpdateConfig wired for the gitstream binary. Callers pass +// the current version string (typically set via ldflags). The mode is +// UpdateForced so release notes carrying a `minimum_version:` marker +// trigger the full-screen update gate automatically. +func New(version string) tuikit.UpdateConfig { + return tuikit.UpdateConfig{ + Owner: "moneycaringcoder", + Repo: "gitstream-tui", + BinaryName: "gitstream", + Version: version, + Mode: tuikit.UpdateForced, + } +} + +// NewWithBaseURL is the test hook: it returns the same config as New but +// pointed at a mock server URL so update_test.go can assert the full +// CheckForUpdate flow without hitting api.github.com. +func NewWithBaseURL(version, baseURL, cacheDir string) tuikit.UpdateConfig { + cfg := New(version) + cfg.APIBaseURL = baseURL + cfg.CacheDir = cacheDir + return cfg +} diff --git a/internal/updatewire/updatewire_test.go b/internal/updatewire/updatewire_test.go new file mode 100644 index 0000000..5931ae2 --- /dev/null +++ b/internal/updatewire/updatewire_test.go @@ -0,0 +1,117 @@ +package updatewire + +import ( + "testing" + + tuikit "github.com/moneycaringcoder/tuikit-go" + "github.com/moneycaringcoder/tuikit-go/updatetest" +) + +// TestNew_DefaultsAreForced asserts the consumer wiring uses UpdateForced +// so a minimum_version marker in release notes promotes the update gate. +func TestNew_DefaultsAreForced(t *testing.T) { + cfg := New("v0.0.0") + if cfg.Mode != tuikit.UpdateForced { + t.Errorf("mode = %v, want UpdateForced", cfg.Mode) + } + if cfg.BinaryName != "gitstream" { + t.Errorf("binary = %q, want gitstream", cfg.BinaryName) + } + if cfg.Owner != "moneycaringcoder" || cfg.Repo != "gitstream-tui" { + t.Errorf("owner/repo = %s/%s", cfg.Owner, cfg.Repo) + } +} + +// TestCheckForUpdate_NewerVersionAvailable exercises the forced path end +// to end through updatetest.NewMockServer: a newer release with a +// minimum_version marker must set Required=true on the result. +func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { + srv := updatetest.NewMockServer(updatetest.Release{ + Tag: "v1.0.0", + BinaryName: "gitstream", + Body: "new stuff", + MinimumVersion: "v1.0.0", + }) + defer srv.Close() + + cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) + res, err := tuikit.CheckForUpdate(cfg) + if err != nil { + t.Fatalf("CheckForUpdate: %v", err) + } + if !res.Available { + t.Error("expected Available=true for older current version") + } + if !res.Required { + t.Error("expected Required=true when minimum_version marker is above current") + } +} + +// TestCheckForUpdate_CurrentVersionNoGate verifies the forced gate is NOT +// triggered when the consumer already runs the latest release. +func TestCheckForUpdate_CurrentVersionNoGate(t *testing.T) { + srv := updatetest.NewMockServer(updatetest.Release{ + Tag: "v1.0.0", + BinaryName: "gitstream", + Body: "same version", + }) + defer srv.Close() + + cfg := NewWithBaseURL("v1.0.0", srv.URL, t.TempDir()) + res, err := tuikit.CheckForUpdate(cfg) + if err != nil { + t.Fatalf("CheckForUpdate: %v", err) + } + if res.Available { + t.Error("expected Available=false when running latest version") + } + if res.Required { + t.Error("expected Required=false when running latest version") + } +} + +// TestCheckForUpdate_SkippedVersionNoGate verifies that writing a +// skipped-versions entry suppresses the gate for a non-required update. +// (Required updates from minimum_version markers override skip on +// purpose — tested in TestCheckForUpdate_NewerVersionAvailable.) +func TestCheckForUpdate_SkippedVersionNoGate(t *testing.T) { + srv := updatetest.NewMockServer(updatetest.Release{ + Tag: "v1.0.0", + BinaryName: "gitstream", + Body: "skip me", + }) + defer srv.Close() + + cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) + if err := tuikit.SkipVersion(cfg, "v1.0.0"); err != nil { + t.Fatalf("SkipVersion: %v", err) + } + res, err := tuikit.CheckForUpdate(cfg) + if err != nil { + t.Fatalf("CheckForUpdate: %v", err) + } + if res.Available { + t.Error("expected Available=false when target version is skipped") + } +} + +// TestCheckForUpdate_DisabledShortCircuits verifies the Disabled kill +// switch suppresses the gate even when a newer release exists. +func TestCheckForUpdate_DisabledShortCircuits(t *testing.T) { + srv := updatetest.NewMockServer(updatetest.Release{ + Tag: "v1.0.0", + BinaryName: "gitstream", + MinimumVersion: "v1.0.0", + }) + defer srv.Close() + + cfg := NewWithBaseURL("v0.5.0", srv.URL, t.TempDir()) + cfg.Disabled = true + res, err := tuikit.CheckForUpdate(cfg) + if err != nil { + t.Fatalf("CheckForUpdate: %v", err) + } + if res.Available || res.Required { + t.Errorf("expected disabled short-circuit, got %+v", res) + } +} diff --git a/testdata/sessions/repo_feed.tuisess b/testdata/sessions/repo_feed.tuisess new file mode 100644 index 0000000..cbf2912 --- /dev/null +++ b/testdata/sessions/repo_feed.tuisess @@ -0,0 +1,31 @@ +{ + "version": 1, + "cols": 120, + "lines": 40, + "steps": [ + { + "kind": "key", + "key": "down" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\n 16:42:07 5m ago repo-a PUSH alice pushed to │\n 16:44:07 3m ago repo-b PR bob opened #4 │\n▌ 16:46:07 1m ago repo-a ISSUE charlie opened #1 │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-09 16:46:07 │\n opened #10: Something broken ↵ open │\n test status left test status right" + }, + { + "kind": "key", + "key": "down" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\n 16:42:07 5m ago repo-a PUSH alice pushed to │\n 16:44:07 3m ago repo-b PR bob opened #4 │\n▌ 16:46:07 1m ago repo-a ISSUE charlie opened #1 │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-09 16:46:07 │\n opened #10: Something broken ↵ open │\n test status left test status right" + }, + { + "kind": "key", + "key": "up" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\n 16:42:07 5m ago repo-a PUSH alice pushed to │\n▌ 16:44:07 3m ago repo-b PR bob opened #4 │\n 16:46:07 1m ago repo-a ISSUE charlie opened #1 │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n───────────────────────────────────────────────────────────────────────────────────── │\n PR owner/repo-b bob 2026-04-09 16:44:07 │\n opened #42: Fix the bug ↵ open │\n test status left test status right" + } + ] +} \ No newline at end of file diff --git a/testdata/sessions/type_filter.tuisess b/testdata/sessions/type_filter.tuisess new file mode 100644 index 0000000..bef32db --- /dev/null +++ b/testdata/sessions/type_filter.tuisess @@ -0,0 +1,31 @@ +{ + "version": 1, + "cols": 120, + "lines": 40, + "steps": [ + { + "kind": "key", + "key": "t" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\n 16:42:07 5m ago repo-a PUSH alice pushed to │\n 16:44:07 3m ago repo-b PR bob opened #4 │\n▌ 16:46:07 1m ago repo-a ISSUE charlie opened #1 │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-09 16:46:07 │\n opened #10: Something broken ↵ open │\n test status left test status right" + }, + { + "kind": "key", + "key": "t" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\n 16:42:07 5m ago repo-a PUSH alice pushed to │\n 16:44:07 3m ago repo-b PR bob opened #4 │\n▌ 16:46:07 1m ago repo-a ISSUE charlie opened #1 │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-09 16:46:07 │\n opened #10: Something broken ↵ open │\n test status left test status right" + }, + { + "kind": "key", + "key": "0" + }, + { + "kind": "screen", + "screen": " Main │ Side\n gitstream │\n Watching: owner/repo-a, owner/repo-b │\n ○ repo-a ○ repo-b │\n │\n 16:42:07 5m ago repo-a PUSH alice pushed to │\n 16:44:07 3m ago repo-b PR bob opened #4 │\n▌ 16:46:07 1m ago repo-a ISSUE charlie opened #1 │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n │\n───────────────────────────────────────────────────────────────────────────────────── │\n ISSUE owner/repo-a charlie 2026-04-09 16:46:07 │\n opened #10: Something broken ↵ open │\n test status left test status right" + } + ] +} \ No newline at end of file