package view import ( "bytes" "context" "errors" "io" "testing" "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewCmdList(t *testing.T) { tests := []struct { name string tty bool args string wantOpts ViewOptions wantBaseRepo ghrepo.Interface wantErr string }{ { name: "no arg tty", tty: true, args: "", wantOpts: ViewOptions{}, }, { name: "session ID arg tty", tty: true, args: "00000000-0000-0000-0000-000000000000", wantOpts: ViewOptions{ SelectorArg: "00000000-0000-0000-0000-000000000000", SessionID: "00000000-0000-0000-0000-000000000000", }, }, { name: "non-session ID arg tty", tty: true, args: "some-arg", wantOpts: ViewOptions{ SelectorArg: "some-arg", }, }, { name: "session ID required if non-tty", tty: false, args: "some-arg", wantErr: "session ID is required when not running interactively", }, { name: "repo override", tty: true, args: "some-arg -R OWNER/REPO", wantBaseRepo: ghrepo.New("OWNER", "REPO"), wantOpts: ViewOptions{ SelectorArg: "some-arg", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() ios.SetStdinTTY(tt.tty) ios.SetStdoutTTY(tt.tty) ios.SetStderrTTY(tt.tty) f := &cmdutil.Factory{ IOStreams: ios, } var gotOpts *ViewOptions cmd := NewCmdView(f, func(opts *ViewOptions) error { gotOpts = opts; return nil }) argv, err := shlex.Split(tt.args) require.NoError(t, err) cmd.SetArgs(argv) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) _, err = cmd.ExecuteC() if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) return } require.NoError(t, err) assert.Equal(t, tt.wantOpts.SelectorArg, gotOpts.SelectorArg) assert.Equal(t, tt.wantOpts.SessionID, gotOpts.SessionID) if tt.wantBaseRepo != nil { baseRepo, err := gotOpts.BaseRepo() require.NoError(t, err) assert.True(t, ghrepo.IsSame(tt.wantBaseRepo, baseRepo)) } }) } } func Test_viewRun(t *testing.T) { sampleDate := time.Now().Add(-6 * time.Hour) // 6h ago tests := []struct { name string tty bool opts ViewOptions promptStubs func(*testing.T, *prompter.MockPrompter) capiStubs func(*testing.T, *capi.CapiClientMock) wantOut string wantErr error wantStderr string }{ { name: "with session id, not found (tty)", tty: true, opts: ViewOptions{ SelectorArg: "some-session-id", SessionID: "some-session-id", }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetSessionFunc = func(_ context.Context, _ string) (*capi.Session, error) { return nil, capi.ErrSessionNotFound } }, wantStderr: "session not found\n", wantErr: cmdutil.SilentError, }, { name: "with session id, api error (tty)", tty: true, opts: ViewOptions{ SelectorArg: "some-session-id", SessionID: "some-session-id", }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetSessionFunc = func(_ context.Context, _ string) (*capi.Session, error) { return nil, errors.New("some error") } }, wantErr: errors.New("some error"), }, { name: "with session id, success, with pr and user data (tty)", tty: true, opts: ViewOptions{ SelectorArg: "some-session-id", SessionID: "some-session-id", }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ ID: "some-session-id", State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, URL: "https://github.com/OWNER/REPO/pull/101", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, }, User: &api.GitHubUser{ Login: "octocat", }, }, nil } }, wantOut: heredoc.Doc(` Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, { // The user data should always be there, but we need to cover the code path. name: "with session id, success, without user data (tty)", tty: true, opts: ViewOptions{ SelectorArg: "some-session-id", SessionID: "some-session-id", }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ ID: "some-session-id", State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, URL: "https://github.com/OWNER/REPO/pull/101", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, }, }, nil } }, wantOut: heredoc.Doc(` Completed • fix something • OWNER/REPO#101 Started about 6 hours ago View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, { // This can happen when the session is just created and a PR is not yet available for it. name: "with session id, success, without pr data (tty)", tty: true, opts: ViewOptions{ SelectorArg: "some-session-id", SessionID: "some-session-id", }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ ID: "some-session-id", State: "completed", CreatedAt: sampleDate, User: &api.GitHubUser{ Login: "octocat", }, }, nil } }, wantOut: heredoc.Doc(` Completed Started on behalf of octocat about 6 hours ago `), }, { // The user data should always be there, but we need to cover the code path. name: "with session id, success, without pr nor user data (tty)", tty: true, opts: ViewOptions{ SelectorArg: "some-session-id", SessionID: "some-session-id", }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ ID: "some-session-id", State: "completed", CreatedAt: sampleDate, }, nil } }, wantOut: heredoc.Doc(` Completed Started about 6 hours ago `), }, { name: "with pr number, api error (tty)", tty: true, opts: ViewOptions{ SelectorArg: "pr-number", Finder: prShared.NewMockFinder( "pr-number", &api.PullRequest{FullDatabaseID: "999999"}, ghrepo.New("OWNER", "REPO"), ), }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.ListSessionsByResourceIDFunc = func(_ context.Context, _ string, _ int64, _ int) ([]*capi.Session, error) { return nil, errors.New("some error") } }, wantErr: errors.New("failed to list sessions for pull request: some error"), }, { name: "with pr reference, unsupported hostname (tty)", tty: true, opts: ViewOptions{ SelectorArg: "OWNER/REPO#999", BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.NewWithHost("OWNER", "REPO", "foo.com"), nil }, }, wantErr: errors.New("agent tasks are not supported on this host: foo.com"), }, { name: "with pr reference, api error when fetching pr database ID (tty)", tty: true, opts: ViewOptions{ SelectorArg: "OWNER/REPO#999", BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetPullRequestDatabaseIDFunc = func(_ context.Context, _ string, _ string, _ string, _ int) (int64, error) { return 0, errors.New("some error") } }, wantErr: errors.New("failed to fetch pull request: some error"), }, { name: "with pr reference, api error when fetching session (tty)", tty: true, opts: ViewOptions{ SelectorArg: "OWNER/REPO#999", BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetPullRequestDatabaseIDFunc = func(_ context.Context, _ string, _ string, _ string, _ int) (int64, error) { return 999999, nil } m.ListSessionsByResourceIDFunc = func(_ context.Context, _ string, _ int64, _ int) ([]*capi.Session, error) { return nil, errors.New("some error") } }, wantErr: errors.New("failed to list sessions for pull request: some error"), }, { name: "with pr number, success, single session with pr and user data (tty)", tty: true, opts: ViewOptions{ SelectorArg: "pr-number", Finder: prShared.NewMockFinder( "pr-number", &api.PullRequest{FullDatabaseID: "999999"}, ghrepo.New("OWNER", "REPO"), ), }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { assert.Equal(t, "pull", resourceType) assert.Equal(t, int64(999999), resourceID) assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { ID: "some-session-id", State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, URL: "https://github.com/OWNER/REPO/pull/101", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, }, User: &api.GitHubUser{ Login: "octocat", }, }, }, nil } }, wantOut: heredoc.Doc(` Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, { name: "with pr number, success, multiple sessions with pr and user data (tty)", tty: true, opts: ViewOptions{ SelectorArg: "pr-number", Finder: prShared.NewMockFinder( "pr-number", &api.PullRequest{FullDatabaseID: "999999"}, ghrepo.New("OWNER", "REPO"), ), }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { assert.Equal(t, "pull", resourceType) assert.Equal(t, int64(999999), resourceID) assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { ID: "some-session-id", Name: "session one", State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, URL: "https://github.com/OWNER/REPO/pull/101", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, }, User: &api.GitHubUser{ Login: "octocat", }, }, { ID: "some-other-session-id", Name: "session two", State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ Title: "fix something else", Number: 102, URL: "https://github.com/OWNER/REPO/pull/102", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, }, User: &api.GitHubUser{ Login: "octocat", }, }, }, nil } }, promptStubs: func(t *testing.T, pm *prompter.MockPrompter) { pm.RegisterSelect( "Select a session", []string{ "✓ session one • about 6 hours ago", "✓ session two • about 6 hours ago", }, func(_, _ string, opts []string) (int, error) { return prompter.IndexFor(opts, "✓ session one • about 6 hours ago") }, ) }, wantOut: heredoc.Doc(` Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, { name: "with pr reference, success, multiple sessions with pr and user data (tty)", tty: true, opts: ViewOptions{ SelectorArg: "OWNER/REPO#999", BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.GetPullRequestDatabaseIDFunc = func(_ context.Context, hostname string, owner string, repo string, number int) (int64, error) { assert.Equal(t, "github.com", hostname) assert.Equal(t, "OWNER", owner) assert.Equal(t, "REPO", repo) assert.Equal(t, 999, number) return 999999, nil } m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { assert.Equal(t, "pull", resourceType) assert.Equal(t, int64(999999), resourceID) assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { ID: "some-session-id", Name: "session one", State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, URL: "https://github.com/OWNER/REPO/pull/101", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, }, User: &api.GitHubUser{ Login: "octocat", }, }, { ID: "some-other-session-id", Name: "session two", State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ Title: "fix something else", Number: 102, URL: "https://github.com/OWNER/REPO/pull/102", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, }, User: &api.GitHubUser{ Login: "octocat", }, }, }, nil } }, promptStubs: func(t *testing.T, pm *prompter.MockPrompter) { pm.RegisterSelect( "Select a session", []string{ "✓ session one • about 6 hours ago", "✓ session two • about 6 hours ago", }, func(_, _ string, opts []string) (int, error) { return prompter.IndexFor(opts, "✓ session one • about 6 hours ago") }, ) }, wantOut: heredoc.Doc(` Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { capiClientMock := &capi.CapiClientMock{} if tt.capiStubs != nil { tt.capiStubs(t, capiClientMock) } prompter := prompter.NewMockPrompter(t) if tt.promptStubs != nil { tt.promptStubs(t, prompter) } ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) opts := tt.opts opts.IO = ios opts.Prompter = prompter opts.CapiClient = func() (capi.CapiClient, error) { return capiClientMock, nil } err := viewRun(&opts) if tt.wantErr != nil { assert.Error(t, err) require.EqualError(t, err, tt.wantErr.Error()) } else { require.NoError(t, err) } got := stdout.String() require.Equal(t, tt.wantOut, got) require.Equal(t, tt.wantStderr, stderr.String()) }) } }