Skip to content
This repository was archived by the owner on Apr 13, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions cmd/gitstream/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions internal/ui/session_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
32 changes: 32 additions & 0 deletions internal/updatewire/updatewire.go
Original file line number Diff line number Diff line change
@@ -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
}
117 changes: 117 additions & 0 deletions internal/updatewire/updatewire_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
31 changes: 31 additions & 0 deletions testdata/sessions/repo_feed.tuisess
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Loading
Loading