From c214f18c6023651ba805e1feda316d067ff1d043 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Sun, 22 Dec 2024 17:18:25 -0500 Subject: [PATCH] uitest: Add system for unit testing widgets Adds a testscript-based system for testing terminal widgets. This is a generalized, in-memory, Windows-friendly version of with-term intended for use in unit tests instead of end-to-end tests. It uses testscript instead of a the custom commands format. With this in place, we're able to unit test a bunch of the terminal interactions that were previously untested, or became untested following RobotView in #480. --- .golangci.yml | 2 + internal/forge/github/auth_test.go | 99 ++----- internal/forge/github/flag_test.go | 5 + internal/forge/github/integration_test.go | 3 +- internal/forge/github/testdata/auth/pat.txt | 64 +++++ .../testdata/auth/select/github_app.txt | 0 .../github/testdata/auth/select/oauth.txt | 91 ++++++ .../testdata/auth/select/oauth_public.txt | 85 ++++++ internal/forge/gitlab/auth.go | 4 +- internal/forge/gitlab/auth_test.go | 70 +++-- internal/forge/gitlab/flag_test.go | 5 + internal/forge/gitlab/integration_test.go | 5 +- internal/forge/gitlab/testdata/auth/pat.txt | 47 ++++ .../gitlab/testdata/auth/select_oauth.txt | 57 ++++ internal/ui/form.go | 43 ++- internal/ui/uitest/emulator.go | 119 ++++---- internal/ui/uitest/script.go | 261 ++++++++++++++++++ internal/ui/view.go | 5 +- internal/ui/widget/branch_select_test.go | 59 ++++ internal/ui/widget/branch_split_test.go | 76 +++++ internal/ui/widget/flag_test.go | 5 + .../testdata/script/branch_split/basic.txt | 45 +++ .../testdata/script/branch_split/no_head.txt | 46 +++ .../independent_branches.txt | 27 ++ .../script/branch_tree_select/linear.txt | 43 +++ .../branch_tree_select/linear_filter.txt | 34 +++ .../branch_tree_select/unselectable_base.txt | 48 ++++ 27 files changed, 1164 insertions(+), 184 deletions(-) create mode 100644 internal/forge/github/flag_test.go create mode 100644 internal/forge/github/testdata/auth/pat.txt create mode 100644 internal/forge/github/testdata/auth/select/github_app.txt create mode 100644 internal/forge/github/testdata/auth/select/oauth.txt create mode 100644 internal/forge/github/testdata/auth/select/oauth_public.txt create mode 100644 internal/forge/gitlab/flag_test.go create mode 100644 internal/forge/gitlab/testdata/auth/pat.txt create mode 100644 internal/forge/gitlab/testdata/auth/select_oauth.txt create mode 100644 internal/ui/widget/branch_select_test.go create mode 100644 internal/ui/widget/branch_split_test.go create mode 100644 internal/ui/widget/flag_test.go create mode 100644 internal/ui/widget/testdata/script/branch_split/basic.txt create mode 100644 internal/ui/widget/testdata/script/branch_split/no_head.txt create mode 100644 internal/ui/widget/testdata/script/branch_tree_select/independent_branches.txt create mode 100644 internal/ui/widget/testdata/script/branch_tree_select/linear.txt create mode 100644 internal/ui/widget/testdata/script/branch_tree_select/linear_filter.txt create mode 100644 internal/ui/widget/testdata/script/branch_tree_select/unselectable_base.txt diff --git a/.golangci.yml b/.golangci.yml index 3f5e7216..bdae52b5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -84,3 +84,5 @@ issues: - linters: [revive] text: 'empty-block: this block is empty, you can remove it' + - linters: [musttag] + path: _test.go$ diff --git a/internal/forge/github/auth_test.go b/internal/forge/github/auth_test.go index 3cd967ca..d2d2112b 100644 --- a/internal/forge/github/auth_test.go +++ b/internal/forge/github/auth_test.go @@ -10,13 +10,14 @@ import ( "net/http/httptest" "net/url" "os/exec" + "reflect" + "strings" "testing" - "time" "github.com/charmbracelet/log" + "github.com/rogpeppe/go-internal/testscript" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/secret" "go.abhg.dev/gs/internal/ui" "go.abhg.dev/gs/internal/ui/uitest" @@ -188,90 +189,34 @@ func TestDeviceFlowAuthenticator(t *testing.T) { } func TestSelectAuthenticator(t *testing.T) { - view := uitest.NewEmulatorView(&uitest.EmulatorViewOptions{ - Rows: 40, - }) - - type result struct { - auth authenticator - err error - } - resultc := make(chan result, 1) - go func() { - defer close(resultc) + uitest.RunScripts(t, func(t testing.TB, ts *testscript.TestScript, view ui.InteractiveView) { + wantType := strings.TrimSpace(ts.ReadFile("want_type")) - got, err := selectAuthenticator(view, authenticatorOptions{ + auth, err := selectAuthenticator(view, authenticatorOptions{ Endpoint: oauth2.Endpoint{}, }) - resultc <- result{got, err} - }() - - // TODO: Generalize termtest and use that here - require.EventuallyWithT(t, func(t *assert.CollectT) { - assert.Contains(t, - view.Screen(), - "Select an authentication method", - ) - }, time.Second, 50*time.Millisecond) - - // Go through all options, roll back around to the first, and select it - for range _authenticationMethods { - require.NoError(t, view.FeedKeys("\x1b[B")) // Down arrow - } - require.NoError(t, view.FeedKeys("\r")) // Enter - - select { - case res, ok := <-resultc: - require.True(t, ok) - auth, err := res.auth, res.err require.NoError(t, err) - - _, ok = auth.(*DeviceFlowAuthenticator) - require.True(t, ok, "want *github.DeviceFlowAuthenticator, got %T", auth) - - case <-time.After(time.Second): - t.Fatal("timed out") - } + assert.Equal(t, wantType, reflect.TypeOf(auth).String()) + }, &uitest.RunScriptsOptions{ + Update: *UpdateFixtures, + Rows: 80, + }, "testdata/auth/select") } -func TestPATAuthenticator(t *testing.T) { - view := uitest.NewEmulatorView(nil) +func TestAuthenticationFlow_PAT(t *testing.T) { + uitest.RunScripts(t, func(t testing.TB, ts *testscript.TestScript, view ui.InteractiveView) { + wantToken := strings.TrimSpace(ts.ReadFile("want_token")) - type result struct { - tok forge.AuthenticationToken - err error - } - resultc := make(chan result, 1) - go func() { - defer close(resultc) - - got, err := (&PATAuthenticator{}).Authenticate(context.Background(), view) - resultc <- result{got, err} - }() - - // TODO: Generalize termtest and use that here - require.EventuallyWithT(t, func(t *assert.CollectT) { - assert.Contains(t, - view.Screen(), - "Enter Personal Access Token", - ) - }, time.Second, 50*time.Millisecond) - - require.NoError(t, view.FeedKeys("token\r")) - - select { - case res, ok := <-resultc: - require.True(t, ok) - tok, err := res.tok, res.err + got, err := new(Forge).AuthenticationFlow(context.Background(), view) require.NoError(t, err) - ght, ok := tok.(*AuthenticationToken) - require.True(t, ok, "want *github.AuthenticationToken, got %T", tok) - assert.Equal(t, "token", ght.AccessToken) - - case <-time.After(time.Second): - t.Fatal("timed out") - } + assert.Equal(t, &AuthenticationToken{ + AccessToken: wantToken, + }, got) + }, &uitest.RunScriptsOptions{ + Update: *UpdateFixtures, + Rows: 80, + }, "testdata/auth/pat.txt") } func TestAuthCLI(t *testing.T) { diff --git a/internal/forge/github/flag_test.go b/internal/forge/github/flag_test.go new file mode 100644 index 00000000..c414ccce --- /dev/null +++ b/internal/forge/github/flag_test.go @@ -0,0 +1,5 @@ +package github + +import "flag" + +var UpdateFixtures = flag.Bool("update", false, "update test fixtures") diff --git a/internal/forge/github/integration_test.go b/internal/forge/github/integration_test.go index b0f2cd12..5df4e3d7 100644 --- a/internal/forge/github/integration_test.go +++ b/internal/forge/github/integration_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/rand" - "flag" "io" "net/http" "os" @@ -33,7 +32,7 @@ import ( // using recorded fixtures. var ( - _update = flag.Bool("update", false, "update test fixtures") + _update = github.UpdateFixtures _fixtures = fixturetest.Config{Update: _update} ) diff --git a/internal/forge/github/testdata/auth/pat.txt b/internal/forge/github/testdata/auth/pat.txt new file mode 100644 index 00000000..fdc17405 --- /dev/null +++ b/internal/forge/github/testdata/auth/pat.txt @@ -0,0 +1,64 @@ +init + +await Select an authentication method +feed -r 3 +snapshot +await +snapshot +cmp stdout select +feed + +await +snapshot +cmp stdout prompt + +feed secret +await +snapshot +cmp stdout filled + +feed + +-- want_token -- +secret +-- select -- +Select an authentication method: + OAuth + Authorize git-spice to act on your behalf from this device only. + git-spice will get access to all repositories: public and private. + For private repositories, you will need to request installation from a + repository owner. + + OAuth: Public repositories only + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to public repositories. + + GitHub App + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to repositories where the git-spice GitHub + App is installed explicitly. + Use https://github.com/apps/git-spice to install the App on repositories. + For private repositories, you will need to request installation from a + repository owner. + +▶ Personal Access Token + Enter a classic or fine-grained Personal Access Token generated from + https://github.com/settings/tokens. + Classic tokens need at least one of the following scopes: repo or + public_repo. + Fine-grained tokens need read/write access to Repository Contents and Pull + requests. + You can use this method if you do not have the ability to install a GitHub + or OAuth App on your repositories. + + GitHub CLI + Re-use an existing GitHub CLI (https://cli.github.com) session. + You must be logged into gh with 'gh auth login' for this to work. + You can use this if you're just experimenting and don't want to set up a + token yet. +-- prompt -- +Select an authentication method: Personal Access Token +Enter Personal Access Token: +-- filled -- +Select an authentication method: Personal Access Token +Enter Personal Access Token: secret diff --git a/internal/forge/github/testdata/auth/select/github_app.txt b/internal/forge/github/testdata/auth/select/github_app.txt new file mode 100644 index 00000000..e69de29b diff --git a/internal/forge/github/testdata/auth/select/oauth.txt b/internal/forge/github/testdata/auth/select/oauth.txt new file mode 100644 index 00000000..a7efc79a --- /dev/null +++ b/internal/forge/github/testdata/auth/select/oauth.txt @@ -0,0 +1,91 @@ +init + +await Select an authentication method +snapshot +cmp stdout prompt + +# go through the list of options and roll back +feed -r 3 +await +snapshot +cmp stdout down + +feed -r 2 +await +snapshot +cmp stdout prompt + +feed + +-- want_type -- +*github.DeviceFlowAuthenticator +-- prompt -- +Select an authentication method: +▶ OAuth + Authorize git-spice to act on your behalf from this device only. + git-spice will get access to all repositories: public and private. + For private repositories, you will need to request installation from a + repository owner. + + OAuth: Public repositories only + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to public repositories. + + GitHub App + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to repositories where the git-spice GitHub + App is installed explicitly. + Use https://github.com/apps/git-spice to install the App on repositories. + For private repositories, you will need to request installation from a + repository owner. + + Personal Access Token + Enter a classic or fine-grained Personal Access Token generated from + https://github.com/settings/tokens. + Classic tokens need at least one of the following scopes: repo or + public_repo. + Fine-grained tokens need read/write access to Repository Contents and Pull + requests. + You can use this method if you do not have the ability to install a GitHub + or OAuth App on your repositories. + + GitHub CLI + Re-use an existing GitHub CLI (https://cli.github.com) session. + You must be logged into gh with 'gh auth login' for this to work. + You can use this if you're just experimenting and don't want to set up a + token yet. +-- down -- +Select an authentication method: + OAuth + Authorize git-spice to act on your behalf from this device only. + git-spice will get access to all repositories: public and private. + For private repositories, you will need to request installation from a + repository owner. + + OAuth: Public repositories only + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to public repositories. + + GitHub App + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to repositories where the git-spice GitHub + App is installed explicitly. + Use https://github.com/apps/git-spice to install the App on repositories. + For private repositories, you will need to request installation from a + repository owner. + +▶ Personal Access Token + Enter a classic or fine-grained Personal Access Token generated from + https://github.com/settings/tokens. + Classic tokens need at least one of the following scopes: repo or + public_repo. + Fine-grained tokens need read/write access to Repository Contents and Pull + requests. + You can use this method if you do not have the ability to install a GitHub + or OAuth App on your repositories. + + GitHub CLI + Re-use an existing GitHub CLI (https://cli.github.com) session. + You must be logged into gh with 'gh auth login' for this to work. + You can use this if you're just experimenting and don't want to set up a + token yet. diff --git a/internal/forge/github/testdata/auth/select/oauth_public.txt b/internal/forge/github/testdata/auth/select/oauth_public.txt new file mode 100644 index 00000000..25416a91 --- /dev/null +++ b/internal/forge/github/testdata/auth/select/oauth_public.txt @@ -0,0 +1,85 @@ +init + +await Select an authentication method +snapshot +cmp stdout prompt + +feed +await +snapshot +cmp stdout select + +feed + +-- want_type -- +*github.DeviceFlowAuthenticator +-- prompt -- +Select an authentication method: +▶ OAuth + Authorize git-spice to act on your behalf from this device only. + git-spice will get access to all repositories: public and private. + For private repositories, you will need to request installation from a + repository owner. + + OAuth: Public repositories only + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to public repositories. + + GitHub App + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to repositories where the git-spice GitHub + App is installed explicitly. + Use https://github.com/apps/git-spice to install the App on repositories. + For private repositories, you will need to request installation from a + repository owner. + + Personal Access Token + Enter a classic or fine-grained Personal Access Token generated from + https://github.com/settings/tokens. + Classic tokens need at least one of the following scopes: repo or + public_repo. + Fine-grained tokens need read/write access to Repository Contents and Pull + requests. + You can use this method if you do not have the ability to install a GitHub + or OAuth App on your repositories. + + GitHub CLI + Re-use an existing GitHub CLI (https://cli.github.com) session. + You must be logged into gh with 'gh auth login' for this to work. + You can use this if you're just experimenting and don't want to set up a + token yet. +-- select -- +Select an authentication method: + OAuth + Authorize git-spice to act on your behalf from this device only. + git-spice will get access to all repositories: public and private. + For private repositories, you will need to request installation from a + repository owner. + +▶ OAuth: Public repositories only + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to public repositories. + + GitHub App + Authorize git-spice to act on your behalf from this device only. + git-spice will only get access to repositories where the git-spice GitHub + App is installed explicitly. + Use https://github.com/apps/git-spice to install the App on repositories. + For private repositories, you will need to request installation from a + repository owner. + + Personal Access Token + Enter a classic or fine-grained Personal Access Token generated from + https://github.com/settings/tokens. + Classic tokens need at least one of the following scopes: repo or + public_repo. + Fine-grained tokens need read/write access to Repository Contents and Pull + requests. + You can use this method if you do not have the ability to install a GitHub + or OAuth App on your repositories. + + GitHub CLI + Re-use an existing GitHub CLI (https://cli.github.com) session. + You must be logged into gh with 'gh auth login' for this to work. + You can use this if you're just experimenting and don't want to set up a + token yet. diff --git a/internal/forge/gitlab/auth.go b/internal/forge/gitlab/auth.go index 3cfafddc..396dc68d 100644 --- a/internal/forge/gitlab/auth.go +++ b/internal/forge/gitlab/auth.go @@ -239,6 +239,8 @@ type authenticator interface { Authenticate(context.Context, ui.View) (*AuthenticationToken, error) } +var _execLookPath = exec.LookPath + var _authenticationMethods = []struct { Title string Description func(focused bool) string @@ -268,7 +270,7 @@ var _authenticationMethods = []struct { Build: func(a authenticatorOptions) authenticator { // Offer this option only if the user // has the GitLab CLI installed. - glExe, err := exec.LookPath("glab") + glExe, err := _execLookPath("glab") if err != nil { return nil } diff --git a/internal/forge/gitlab/auth_test.go b/internal/forge/gitlab/auth_test.go index f4e47edc..70288fce 100644 --- a/internal/forge/gitlab/auth_test.go +++ b/internal/forge/gitlab/auth_test.go @@ -9,16 +9,18 @@ import ( "net/http/httptest" "net/url" "os/exec" + "reflect" + "strings" "testing" - "time" "github.com/charmbracelet/log" + "github.com/rogpeppe/go-internal/testscript" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/secret" "go.abhg.dev/gs/internal/ui" "go.abhg.dev/gs/internal/ui/uitest" + "go.abhg.dev/testing/stub" "golang.org/x/oauth2" ) @@ -233,44 +235,40 @@ func TestAuthType(t *testing.T) { }) } -func TestPATAuthenticator(t *testing.T) { - view := uitest.NewEmulatorView(nil) +func TestSelectAuthenticator(t *testing.T) { + // Available authentication methods are affected by whether "glab" + // CLI is available. + stub.Func(&_execLookPath, "glab", nil) - type result struct { - tok forge.AuthenticationToken - err error - } - resultc := make(chan result, 1) - go func() { - defer close(resultc) - - got, err := (&PATAuthenticator{}).Authenticate(context.Background(), view) - resultc <- result{got, err} - }() - - // TODO: Generalize termtest and use that here - require.EventuallyWithT(t, func(t *assert.CollectT) { - assert.Contains(t, - view.Screen(), - "Enter Personal Access Token", - ) - }, time.Second, 50*time.Millisecond) - - require.NoError(t, view.FeedKeys("token\r")) - - select { - case res, ok := <-resultc: - require.True(t, ok) - tok, err := res.tok, res.err + uitest.RunScripts(t, func(t testing.TB, ts *testscript.TestScript, view ui.InteractiveView) { + wantType := strings.TrimSpace(ts.ReadFile("want_type")) + + auth, err := selectAuthenticator(view, authenticatorOptions{ + Endpoint: oauth2.Endpoint{}, + ClientID: _oauthAppID, + Hostname: "https://gitlab.com", + }) require.NoError(t, err) + assert.Equal(t, wantType, reflect.TypeOf(auth).String()) + }, &uitest.RunScriptsOptions{ + Update: *UpdateFixtures, + }, "testdata/auth/select_oauth.txt") +} - ght, ok := tok.(*AuthenticationToken) - require.True(t, ok, "want *gitlab.AuthenticationToken, got %T", tok) - assert.Equal(t, "token", ght.AccessToken) +func TestAuthenticationFlow_PAT(t *testing.T) { + uitest.RunScripts(t, func(t testing.TB, ts *testscript.TestScript, view ui.InteractiveView) { + wantToken := strings.TrimSpace(ts.ReadFile("want_token")) - case <-time.After(time.Second): - t.Fatal("timed out") - } + got, err := new(Forge).AuthenticationFlow(context.Background(), view) + require.NoError(t, err) + + assert.Equal(t, &AuthenticationToken{ + AuthType: AuthTypePAT, + AccessToken: wantToken, + }, got) + }, &uitest.RunScriptsOptions{ + Update: *UpdateFixtures, + }, "testdata/auth/pat.txt") } func TestGLabCLI(t *testing.T) { diff --git a/internal/forge/gitlab/flag_test.go b/internal/forge/gitlab/flag_test.go new file mode 100644 index 00000000..2a19ed20 --- /dev/null +++ b/internal/forge/gitlab/flag_test.go @@ -0,0 +1,5 @@ +package gitlab + +import "flag" + +var UpdateFixtures = flag.Bool("update", false, "update test fixtures") diff --git a/internal/forge/gitlab/integration_test.go b/internal/forge/gitlab/integration_test.go index 5ee3a5e9..3fc377f8 100644 --- a/internal/forge/gitlab/integration_test.go +++ b/internal/forge/gitlab/integration_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/rand" - "flag" "io" "net/http" "os" @@ -31,8 +30,8 @@ import ( // using recorded fixtures. var ( - _update = flag.Bool("update", false, "update test fixtures") - _fixtures = fixturetest.Config{Update: _update} + _update = gitlab.UpdateFixtures + _fixtures = fixturetest.Config{Update: gitlab.UpdateFixtures} ) // To avoid looking this up for every test that needs the repo ID, diff --git a/internal/forge/gitlab/testdata/auth/pat.txt b/internal/forge/gitlab/testdata/auth/pat.txt new file mode 100644 index 00000000..9a2a4ebb --- /dev/null +++ b/internal/forge/gitlab/testdata/auth/pat.txt @@ -0,0 +1,47 @@ +init + +await Select an authentication method +feed +snapshot +await +snapshot +cmp stdout select +feed + +await +snapshot +cmp stdout prompt + +feed secret +await +snapshot +cmp stdout filled + +feed + +-- want_token -- +secret +-- select -- +Select an authentication method: + OAuth + Authorize git-spice to act on your behalf from this device only. + git-spice will get access to all repositories: public and private. + For private repositories, you will need to request installation from a + repository owner. + +▶ Personal Access Token + Enter a Personal Access Token generated from https://gitlab.com/- + /user_settings/personal_access_tokens. + The Personal Access Token need the following scope: api. + + GitLab CLI + Re-use an existing GitLab CLI (https://gitlab.com/gitlab-org/cli) session. + You must be logged into glab with 'glab auth login' for this to work. + You can use this if you're just experimenting and don't want to set up a + token yet. +-- prompt -- +Select an authentication method: Personal Access Token +Enter Personal Access Token: +-- filled -- +Select an authentication method: Personal Access Token +Enter Personal Access Token: secret diff --git a/internal/forge/gitlab/testdata/auth/select_oauth.txt b/internal/forge/gitlab/testdata/auth/select_oauth.txt new file mode 100644 index 00000000..cb75e36e --- /dev/null +++ b/internal/forge/gitlab/testdata/auth/select_oauth.txt @@ -0,0 +1,57 @@ +init + +await Select an authentication method +snapshot +cmp stdout prompt + +# go through the list of options and roll back +feed +await +snapshot +cmp stdout down + +feed -r 2 +await +snapshot +cmp stdout prompt + +feed + +-- want_type -- +*gitlab.DeviceFlowAuthenticator +-- prompt -- +Select an authentication method: +▶ OAuth + Authorize git-spice to act on your behalf from this device only. + git-spice will get access to all repositories: public and private. + For private repositories, you will need to request installation from a + repository owner. + + Personal Access Token + Enter a Personal Access Token generated from https://gitlab.com/- + /user_settings/personal_access_tokens. + The Personal Access Token need the following scope: api. + + GitLab CLI + Re-use an existing GitLab CLI (https://gitlab.com/gitlab-org/cli) session. + You must be logged into glab with 'glab auth login' for this to work. + You can use this if you're just experimenting and don't want to set up a + token yet. +-- down -- +Select an authentication method: + OAuth + Authorize git-spice to act on your behalf from this device only. + git-spice will get access to all repositories: public and private. + For private repositories, you will need to request installation from a + repository owner. + +▶ Personal Access Token + Enter a Personal Access Token generated from https://gitlab.com/- + /user_settings/personal_access_tokens. + The Personal Access Token need the following scope: api. + + GitLab CLI + Re-use an existing GitLab CLI (https://gitlab.com/gitlab-org/cli) session. + You must be logged into glab with 'glab auth login' for this to work. + You can use this if you're just experimenting and don't want to set up a + token yet. diff --git a/internal/ui/form.go b/internal/ui/form.go index 4ae60339..9b6b5ffe 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -1,6 +1,7 @@ package ui import ( + "cmp" "errors" "fmt" "io" @@ -140,15 +141,47 @@ func NewForm(fields ...Field) *Form { } } +// FormRunOptions specifies options for [Form.Run]. +type FormRunOptions struct { + // Input is the input source. + // + // Defaults to os.Stdin. + Input io.Reader + + // Output is the destination to write to. + // + // Defaults to os.Stderr. + Output io.Writer + + // SendMsg specifies a message that should be posted + // to the program at startup. + SendMsg tea.Msg + + // WithoutSignals requests that the form not register signal handlers. + WithoutSignals bool +} + // Run runs the form and blocks until it's accepted or canceled. // It returns a combination of all errors returned by the fields. -func (f *Form) Run(view *TerminalView) error { - teaOpts := []tea.ProgramOption{ - tea.WithInput(view.R), - tea.WithOutput(view.W), +func (f *Form) Run(opts *FormRunOptions) error { + opts = cmp.Or(opts, &FormRunOptions{}) + + var teaOpts []tea.ProgramOption + if i := opts.Input; i != nil { + teaOpts = append(teaOpts, tea.WithInput(i)) + } + if o := opts.Output; o != nil { + teaOpts = append(teaOpts, tea.WithOutput(o)) + } + if opts.WithoutSignals { + teaOpts = append(teaOpts, tea.WithoutSignals()) } - if _, err := tea.NewProgram(f, teaOpts...).Run(); err != nil { + prog := tea.NewProgram(f, teaOpts...) + if msg := opts.SendMsg; msg != nil { + go prog.Send(msg) + } + if _, err := prog.Run(); err != nil { return err } diff --git a/internal/ui/uitest/emulator.go b/internal/ui/uitest/emulator.go index a9e0a8c3..f05a7b3c 100644 --- a/internal/ui/uitest/emulator.go +++ b/internal/ui/uitest/emulator.go @@ -2,10 +2,11 @@ package uitest import ( "cmp" + "errors" "io" - "strings" "sync" + tea "github.com/charmbracelet/bubbletea" "github.com/vito/midterm" "go.abhg.dev/gs/internal/ui" ) @@ -13,9 +14,14 @@ import ( // EmulatorView is a [ui.InteractiveView] that renders to an in-memory // terminal emulator, and allows interacting with it programmatically. type EmulatorView struct { - real *ui.TerminalView - stdin io.WriteCloser - term *lockedTerminal + logf func(string, ...any) + + // TODO: v2 + // renderer tea.Renderer + + mu sync.RWMutex + term *midterm.Terminal + stdinW io.Writer // nil if not running a prompt } var _ ui.InteractiveView = (*EmulatorView)(nil) @@ -29,6 +35,9 @@ type EmulatorViewOptions struct { // NoAutoResize disables automatic resizing of the terminal // as output is written to it. NoAutoResize bool + + // Log function to use, if any. + Logf func(string, ...any) } // NewEmulatorView creates a new [EmulatorView] with the given dimensions. @@ -37,77 +46,83 @@ type EmulatorViewOptions struct { func NewEmulatorView(opts *EmulatorViewOptions) *EmulatorView { opts = cmp.Or(opts, &EmulatorViewOptions{}) term := midterm.NewTerminal( - cmp.Or(opts.Rows, 24), + cmp.Or(opts.Rows, 40), cmp.Or(opts.Cols, 80), ) term.AutoResizeX = !opts.NoAutoResize term.AutoResizeY = !opts.NoAutoResize - lockedTerm := newLockedTerminal(term) - stdinR, stdinW := io.Pipe() + logf := opts.Logf + if logf == nil { + logf = func(string, ...any) {} + } return &EmulatorView{ - real: &ui.TerminalView{ - R: stdinR, - W: lockedTerm, - }, - term: lockedTerm, - stdin: stdinW, + logf: logf, + term: term, } } // Prompt prompts the user for input with the given interactive fields. func (e *EmulatorView) Prompt(fs ...ui.Field) error { - return e.real.Prompt(fs...) + stdinR, stdinW := io.Pipe() + defer func() { + _ = stdinR.Close() + e.mu.Lock() + e.stdinW = nil + e.mu.Unlock() + }() + + e.mu.Lock() + w, h := e.term.Width, e.term.Height + e.stdinW = stdinW + e.mu.Unlock() + + return ui.NewForm(fs...).Run(&ui.FormRunOptions{ + Input: stdinR, + Output: e, + // In-memory terminal emulator cannot be queried for size, + // so inject this manually. + SendMsg: tea.WindowSizeMsg{ + Width: w, + Height: h, + }, + WithoutSignals: true, + }) } // Write posts messages to the user. func (e *EmulatorView) Write(p []byte) (n int, err error) { - return e.real.Write(p) + e.mu.Lock() + defer e.mu.Unlock() + return e.term.Write(p) } // Close closes the EmulatorView and frees its resources. func (e *EmulatorView) Close() error { - return e.stdin.Close() -} - -// Rows returns a list of rows in the terminal emulator. -func (e *EmulatorView) Rows() []string { - return e.term.Rows() -} - -// Screen returns a string representation of the terminal emulator. -func (e *EmulatorView) Screen() string { - return e.term.Screen() + return nil // TODO: post EOT? } // FeedKeys feeds the given keys to the terminal emulator. func (e *EmulatorView) FeedKeys(keys string) error { - _, err := io.WriteString(e.stdin, keys) - return err -} - -type lockedTerminal struct { - mu sync.RWMutex - term *midterm.Terminal -} + e.mu.Lock() + defer e.mu.Unlock() -func newLockedTerminal(term *midterm.Terminal) *lockedTerminal { - return &lockedTerminal{term: term} -} + if e.stdinW == nil { + return errors.New("no prompt to fill") + } -func (l *lockedTerminal) Write(p []byte) (n int, err error) { - l.mu.Lock() - defer l.mu.Unlock() - return l.term.Write(p) + _, err := io.WriteString(e.stdinW, keys) + return err } -func (l *lockedTerminal) Rows() []string { - l.mu.RLock() - defer l.mu.RUnlock() +// Rows returns a list of rows in the terminal emulator. +func (e *EmulatorView) Rows() []string { + e.mu.RLock() + defer e.mu.RUnlock() var lines []string - for _, row := range l.term.Content { + for _, row := range e.term.Content { row = trimRightWS(row) lines = append(lines, string(row)) } @@ -123,20 +138,6 @@ func (l *lockedTerminal) Rows() []string { return lines } -func (l *lockedTerminal) Screen() string { - l.mu.RLock() - defer l.mu.RUnlock() - - var s strings.Builder - for _, row := range l.term.Content { - row = trimRightWS(row) - s.WriteString(string(row)) - s.WriteRune('\n') - } - - return strings.TrimRight(s.String(), "\n") -} - func trimRightWS(rs []rune) []rune { for i := len(rs) - 1; i >= 0; i-- { switch rs[i] { diff --git a/internal/ui/uitest/script.go b/internal/ui/uitest/script.go index e111eea3..16a34c98 100644 --- a/internal/ui/uitest/script.go +++ b/internal/ui/uitest/script.go @@ -5,14 +5,249 @@ import ( "bytes" "cmp" "errors" + "flag" "fmt" "io" + "maps" + "os" + "path/filepath" "slices" "strconv" "strings" + "testing" "time" + + "github.com/rogpeppe/go-internal/testscript" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/ui" ) +// RunScriptsOptions defines options for RunScripts. +type RunScriptsOptions struct { + // Size of the terminal. Defaults to 80x40. + Rows, Cols int + + // Update specifies whether 'cmp' commands in scripts + // should update the test file in case of mismatch. + Update bool + + // Cmds defines additional commands to provide to the test script. + Cmds map[string]func(*testscript.TestScript, bool, []string) +} + +// RunScripts runs scripts defined in the given file or directory. +// +// It provides an "init" command that runs the provided startView function +// inside a background goroutine with access to an in-memory terminal emulator. +// Other commands provided by [SetupScript] are also available in the script. +// +// Files is a list of test script files or directories. +// For any directory specified in files, its direct children matching the name +// '*.txt' are run as scripts. +func RunScripts( + t *testing.T, + startView func(testing.TB, *testscript.TestScript, ui.InteractiveView), + opts *RunScriptsOptions, + files ...string, +) { + require.NotEmpty(t, files, "no files provided") + + opts = cmp.Or(opts, &RunScriptsOptions{}) + + type tKey struct{} + params := testscript.Params{ + UpdateScripts: opts.Update, + Setup: func(env *testscript.Env) error { + env.Values[tKey{}] = env.T().(testing.TB) + return nil + }, + } + + for _, file := range files { + info, err := os.Stat(files[0]) + require.NoError(t, err) + if !info.IsDir() { + params.Files = append(params.Files, file) + continue + } + + ents, err := os.ReadDir(file) + require.NoError(t, err) + for _, ent := range ents { + if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".txt") { + continue // don't recurse + } + + params.Files = append(params.Files, filepath.Join(file, ent.Name())) + } + } + + // TODO: a means for waiting for the view to exit + // so that final form can also be seen. + setEmulator := SetupScript(¶ms) + params.Cmds["init"] = func(ts *testscript.TestScript, neg bool, args []string) { + t := ts.Value(tKey{}).(testing.TB) + + emu := NewEmulatorView(&EmulatorViewOptions{ + Rows: opts.Rows, + Cols: opts.Cols, + Logf: ts.Logf, + }) + done := make(chan struct{}) + go func() { + defer close(done) + + // TODO: set up a testing.T that will kill this goroutine + // and mark the test as failed without panic-exploding. + startView(t, ts, emu) + }() + ts.Defer(func() { + // If the test failed, send Ctrl+C to the emulator. + if t.Failed() { + _ = emu.FeedKeys("\x03") // Send Ctrl+C + } + + if err := emu.Close(); err != nil { + ts.Logf("closing emulator: %v", err) + } + + select { + case <-done: + // ok + + case <-time.After(3 * time.Second): + ts.Fatalf("view did not exit in time") + } + }) + + setEmulator(ts, emu) + } + + if opts.Cmds != nil { + maps.Copy(params.Cmds, opts.Cmds) + } + + testscript.Run(t, params) +} + +// SetupScript may be used from testscripts to control a fake terminal emulator. +// Install this in a testscript.Params, +// and use the returned setEmulator to provide an emulator to a test script +// with another command (e.g. "init"). +// +// The source of input for the emulator should run in the background. +// +// The following commands are added to scripts: +// +// clear +// Ignore current screen contents when awaiting text. +// await [txt] +// Wait for the given text to be visible on screen. +// If [txt] is absent, wait until the contents of the screen +// change compared to the last 'snapshot' call. +// snapshot +// Print a copy of the screen to the script's stdout. +// feed [-r N] txt +// Post the given text to the command's stdin. +// If -count is given, the input is repeated N times. +func SetupScript(params *testscript.Params) (setEmulator func(*testscript.TestScript, Emulator)) { + type stateKey struct{} + type stateValue struct{ V *scriptState } + + getState := func(ts *testscript.TestScript) *scriptState { + container := ts.Value(stateKey{}).(*stateValue) + if container.V == nil { + ts.Fatalf("setEmulator not called: no state found") + } + return container.V + } + + oldSetup := params.Setup + params.Setup = func(env *testscript.Env) error { + if oldSetup != nil { + if err := oldSetup(env); err != nil { + return err + } + } + + env.Values[stateKey{}] = new(stateValue) + return nil + } + + if params.Cmds == nil { + params.Cmds = make(map[string]func(ts *testscript.TestScript, neg bool, args []string)) + } + + // clear + params.Cmds["clear"] = func(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("usage: clear") + } + + getState(ts).Clear() + } + + // await [txt] + params.Cmds["await"] = func(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("usage: await [txt]") + } + + state := getState(ts) + want := strings.Join(args, " ") + if err := state.Await(want); err != nil { + ts.Fatalf("await %q: %v", want, err) + } + } + + // snapshot + params.Cmds["snapshot"] = func(ts *testscript.TestScript, neg bool, args []string) { + if neg || len(args) != 0 { + ts.Fatalf("usage: snapshot") + } + + state := getState(ts) + state.Snapshot("") + } + + // TODO: up/down/enter? + // feed [-r n] txt + params.Cmds["feed"] = func(ts *testscript.TestScript, neg bool, args []string) { + flag := flag.NewFlagSet("feed", flag.ContinueOnError) + flag.Usage = func() { + ts.Logf("usage: feed [-r n] txt") + } + if neg { + flag.Usage() + ts.Fatalf("incorrect usage") + } + + repeat := flag.Int("r", 1, "repetitions of the input") + if err := flag.Parse(args); err != nil { + ts.Fatalf("feed: %v", err) + } + + args = flag.Args() + if len(args) == 0 { + flag.Usage() + ts.Fatalf("incorrect usage") + } + + state := getState(ts) + keys := strings.Repeat(strings.Join(args, ""), *repeat) + if err := state.Feed(keys); err != nil { + ts.Fatalf("feed %q: %v", keys, err) + } + } + + return func(ts *testscript.TestScript, emu Emulator) { + ts.Value(stateKey{}).(*stateValue).V = newScriptState(emu, &scriptStateOptions{ + Logf: ts.Logf, + Output: ts.Stdout, + }) + } +} + // Emulator is a terminal emulator that receives input // and allows querying the terminal state. type Emulator interface { @@ -59,6 +294,8 @@ type ScriptOptions struct { // Examples: \r, \x1b[B // // Snapshots are written to [ScriptOptions.Output] if provided. +// +// New scripts should prefer using SetupScript for this purpose. func Script(emu Emulator, script []byte, opts *ScriptOptions) error { opts = cmp.Or(opts, &ScriptOptions{}) @@ -235,7 +472,31 @@ func (s *scriptState) Snapshot(title string) { } } +var _keyReplacements = map[string]string{ + "": "\x1b[B", + "": "\r", + "": " ", + "": "\t", + "": "\x1b[A", + "": "\x08", + "": "\x08", +} + +var _keyReplacer *strings.Replacer + +func init() { + var repl []string + for k, v := range _keyReplacements { + repl = append(repl, k, v) + repl = append(repl, strings.ToUpper(k), v) + repl = append(repl, strings.ToLower(k), v) + } + + _keyReplacer = strings.NewReplacer(repl...) +} + func (s *scriptState) Feed(keys string) error { + keys = _keyReplacer.Replace(keys) if err := s.emu.FeedKeys(keys); err != nil { return fmt.Errorf("feed keys %q: %w", keys, err) } diff --git a/internal/ui/view.go b/internal/ui/view.go index 1873679a..19e38276 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -66,5 +66,8 @@ func (tv *TerminalView) Write(p []byte) (int, error) { // Prompt prompts the user for input with the given interactive fields. func (tv *TerminalView) Prompt(fields ...Field) error { - return NewForm(fields...).Run(tv) + return NewForm(fields...).Run(&FormRunOptions{ + Input: tv.R, + Output: tv.W, + }) } diff --git a/internal/ui/widget/branch_select_test.go b/internal/ui/widget/branch_select_test.go new file mode 100644 index 00000000..1ba17519 --- /dev/null +++ b/internal/ui/widget/branch_select_test.go @@ -0,0 +1,59 @@ +package widget + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/rogpeppe/go-internal/testscript" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/ui" + "go.abhg.dev/gs/internal/ui/uitest" +) + +// Runs tests inside testdata/script/branch_select. +// The following files are expected: +// +// - want: name of the branch expected to be selected at the end +// - branches: branches available in the list. See below for format. +// - desc (optional): prompt description +// +// The branches file is a JSON-encoded file with the format: +// +// [ +// {branch: string, base: string?, disabled: bool?}, +// ... +// ] +func TestBranchTreeSelect_Script(t *testing.T) { + uitest.RunScripts(t, + func(t testing.TB, ts *testscript.TestScript, view ui.InteractiveView) { + wantBranch := strings.TrimSpace(ts.ReadFile("want")) + + var input []BranchTreeItem + require.NoError(t, + json.Unmarshal([]byte(ts.ReadFile("branches")), &input), + "read 'branches' file") + + var desc string + if _, err := os.Stat(ts.MkAbs("desc")); err == nil { + desc = strings.TrimSpace(ts.ReadFile("desc")) + } + + var gotBranch string + widget := NewBranchTreeSelect(). + WithTitle("Select a branch"). + WithItems(input...). + WithDescription(desc). + WithValue(&gotBranch) + + assert.NoError(t, ui.Run(view, widget)) + assert.Equal(t, wantBranch, gotBranch) + }, + &uitest.RunScriptsOptions{ + Update: *UpdateFixtures, + }, + "testdata/script/branch_tree_select", + ) +} diff --git a/internal/ui/widget/branch_split_test.go b/internal/ui/widget/branch_split_test.go new file mode 100644 index 00000000..19024373 --- /dev/null +++ b/internal/ui/widget/branch_split_test.go @@ -0,0 +1,76 @@ +package widget + +import ( + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/rogpeppe/go-internal/testscript" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/ui" + "go.abhg.dev/gs/internal/ui/uitest" + "go.abhg.dev/testing/stub" +) + +// Runs tests inside testdata/script/branch_split. +// The following files are expected: +// +// - commits: JSON describing input commit summaries +// - want: expected selected commits (list of git hashes as JSON) +// - head (optional): name of the HEAD commit +// - desc (optional): description for the prompt +func TestBranchSplit_Script(t *testing.T) { + stub.Func(&_timeNow, time.Date(2024, 12, 11, 10, 9, 8, 7, time.UTC)) + + uitest.RunScripts(t, + func(t testing.TB, ts *testscript.TestScript, view ui.InteractiveView) { + var input []CommitSummary + require.NoError(t, + json.Unmarshal([]byte(ts.ReadFile("commits")), &input), + "read 'commits' file") + + var want []git.Hash + require.NoError(t, + json.Unmarshal([]byte(ts.ReadFile("want")), &want), + "read 'want' file") + + var head string + if _, err := os.Stat(ts.MkAbs("head")); err == nil { + head = strings.TrimSpace(ts.ReadFile("head")) + } + + var desc string + if _, err := os.Stat(ts.MkAbs("desc")); err == nil { + desc = strings.TrimSpace(ts.ReadFile("desc")) + } + + commits := make([]CommitSummary, len(input)) + for i, c := range input { + commits[i] = CommitSummary(c) + } + + widget := NewBranchSplit(). + WithTitle("Select a commit"). + WithCommits(commits...). + WithDescription(desc). + WithHEAD(head) + + assert.NoError(t, ui.Run(view, widget)) + + var got []git.Hash + for _, idx := range widget.Selected() { + got = append(got, input[idx].ShortHash) + } + + assert.Equal(t, want, got) + }, + &uitest.RunScriptsOptions{ + Update: *UpdateFixtures, + }, + "testdata/script/branch_split", + ) +} diff --git a/internal/ui/widget/flag_test.go b/internal/ui/widget/flag_test.go new file mode 100644 index 00000000..d46a5ed2 --- /dev/null +++ b/internal/ui/widget/flag_test.go @@ -0,0 +1,5 @@ +package widget + +import "flag" + +var UpdateFixtures = flag.Bool("update", false, "update test fixtures") diff --git a/internal/ui/widget/testdata/script/branch_split/basic.txt b/internal/ui/widget/testdata/script/branch_split/basic.txt new file mode 100644 index 00000000..2d72f64d --- /dev/null +++ b/internal/ui/widget/testdata/script/branch_split/basic.txt @@ -0,0 +1,45 @@ +init + +await Select a commit + +snapshot +cmp stdout prompt + +feed +await +snapshot +cmp stdout selected + +feed + +-- commits -- +[ + { + "shortHash": "abcdef", + "subject": "feat: add feature", + "authorDate": "2024-12-10T22:26:23Z" + }, + { + "shortHash": "ghijkl", + "subject": "refac: unrelated change", + "authorDate": "2024-12-10T22:33:44Z" + } +] +-- head -- +main +-- desc -- +Select which commits to introduce splits at. +-- want -- +["abcdef"] +-- prompt -- +Select a commit: +▶ abcdef feat: add feature (11 hours ago) + ■ ghijkl refac: unrelated change (11 hours ago) [main] + Done +Select which commits to introduce splits at. +-- selected -- +Select a commit: + □ abcdef feat: add feature (11 hours ago) + ■ ghijkl refac: unrelated change (11 hours ago) [main] +▶ Done +Select which commits to introduce splits at. diff --git a/internal/ui/widget/testdata/script/branch_split/no_head.txt b/internal/ui/widget/testdata/script/branch_split/no_head.txt new file mode 100644 index 00000000..888ae970 --- /dev/null +++ b/internal/ui/widget/testdata/script/branch_split/no_head.txt @@ -0,0 +1,46 @@ +init + +await Select a commit + +snapshot +cmp stdout prompt + +feed +await +snapshot +cmp stdout selected + +feed + +-- commits -- +[ + { + "shortHash": "abc", + "subject": "feat: add feature", + "authorDate": "2024-12-10T22:26:23Z" + }, + { + "shortHash": "def", + "subject": "refac: unrelated change", + "authorDate": "2024-12-10T22:33:44Z" + }, + { + "shortHash": "ghi", + "subject": "feat: add another feature", + "authorDate": "2024-12-10T22:45:44Z" + } +] +-- prompt -- +Select a commit: +▶ abc feat: add feature (11 hours ago) + def refac: unrelated change (11 hours ago) + ■ ghi feat: add another feature (11 hours ago) + Done +-- selected -- +Select a commit: + □ abc feat: add feature (11 hours ago) + def refac: unrelated change (11 hours ago) + ■ ghi feat: add another feature (11 hours ago) +▶ Done +-- want -- +["abc"] diff --git a/internal/ui/widget/testdata/script/branch_tree_select/independent_branches.txt b/internal/ui/widget/testdata/script/branch_tree_select/independent_branches.txt new file mode 100644 index 00000000..118fd31c --- /dev/null +++ b/internal/ui/widget/testdata/script/branch_tree_select/independent_branches.txt @@ -0,0 +1,27 @@ +# several branches without a shared base + +init + +await Select a branch +snapshot +cmp stdout prompt + +feed qu + +-- branches -- +[ + {"branch": "main"}, + {"branch": "foo"}, + {"branch": "bar"}, + {"branch": "baz"}, + {"branch": "qux"} +] +-- want -- +qux +-- prompt -- +Select a branch: +main ◀ +foo +bar +baz +qux diff --git a/internal/ui/widget/testdata/script/branch_tree_select/linear.txt b/internal/ui/widget/testdata/script/branch_tree_select/linear.txt new file mode 100644 index 00000000..88b2a980 --- /dev/null +++ b/internal/ui/widget/testdata/script/branch_tree_select/linear.txt @@ -0,0 +1,43 @@ +init + +await Select a branch +snapshot +cmp stdout prompt + +feed -r 2 +await +snapshot +cmp stdout hover + +feed + +-- branches -- +[ + {"branch": "main"}, + {"branch": "foo", "base": "main"}, + {"branch": "bar", "base": "foo"}, + {"branch": "baz", "base": "bar"}, + {"branch": "qux", "base": "baz"} +] +-- desc -- +We have many branches to choose from. +-- want -- +bar +-- prompt -- +Select a branch: + ┏━■ qux ◀ + ┏━┻□ baz + ┏━┻□ bar +┏━┻□ foo +main + +We have many branches to choose from. +-- hover -- +Select a branch: + ┏━□ qux + ┏━┻□ baz + ┏━┻■ bar ◀ +┏━┻□ foo +main + +We have many branches to choose from. diff --git a/internal/ui/widget/testdata/script/branch_tree_select/linear_filter.txt b/internal/ui/widget/testdata/script/branch_tree_select/linear_filter.txt new file mode 100644 index 00000000..37e6a412 --- /dev/null +++ b/internal/ui/widget/testdata/script/branch_tree_select/linear_filter.txt @@ -0,0 +1,34 @@ +init + +await Select a branch +snapshot +cmp stdout prompt + +feed ba +await +snapshot +cmp stdout filter + +feed + +-- branches -- +[ + {"branch": "main"}, + {"branch": "foo", "base": "main"}, + {"branch": "bar", "base": "foo"}, + {"branch": "baz", "base": "bar"}, + {"branch": "qux", "base": "baz"} +] +-- want -- +baz +-- prompt -- +Select a branch: + ┏━■ qux ◀ + ┏━┻□ baz + ┏━┻□ bar +┏━┻□ foo +main +-- filter -- +Select a branch: +┏━□ baz +bar ◀ diff --git a/internal/ui/widget/testdata/script/branch_tree_select/unselectable_base.txt b/internal/ui/widget/testdata/script/branch_tree_select/unselectable_base.txt new file mode 100644 index 00000000..a69205c2 --- /dev/null +++ b/internal/ui/widget/testdata/script/branch_tree_select/unselectable_base.txt @@ -0,0 +1,48 @@ +# a base that isn't in the list of branches +# cannot be selected. + +init + +await Select a branch +snapshot +cmp stdout prompt + +# down 2 will roll back around +feed +await +snapshot +feed +await +snapshot +cmp stdout prompt + +# filter to 'foo' is useless +feed foo +await +snapshot +cmp stdout filter + +feed -r 3 +await +snapshot +cmp stdout prompt + +feed + +-- branches -- +[ + {"branch": "bar", "base": "foo"}, + {"branch": "baz", "base": "bar"} +] +-- want -- +bar +-- prompt -- +Select a branch: + ┏━■ baz ◀ +┏━┻□ bar +foo +-- filter -- +Select a branch: +foo + +no available matches: foo