package browse import ( "fmt" "io" "net/http" "os" "path/filepath" "testing" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) func TestNewCmdBrowse(t *testing.T) { tests := []struct { name string cli string factory func(*cmdutil.Factory) *cmdutil.Factory wants BrowseOptions wantsErr bool }{ { name: "no arguments", cli: "", wantsErr: false, }, { name: "settings flag", cli: "--settings", wants: BrowseOptions{ SettingsFlag: true, }, wantsErr: false, }, { name: "projects flag", cli: "--projects", wants: BrowseOptions{ ProjectsFlag: true, }, wantsErr: false, }, { name: "releases flag", cli: "--releases", wants: BrowseOptions{ ReleasesFlag: true, }, wantsErr: false, }, { name: "wiki flag", cli: "--wiki", wants: BrowseOptions{ WikiFlag: true, }, wantsErr: false, }, { name: "actions flag", cli: "--actions", wants: BrowseOptions{ ActionsFlag: true, }, wantsErr: false, }, { name: "no browser flag", cli: "--no-browser", wants: BrowseOptions{ NoBrowserFlag: true, }, wantsErr: false, }, { name: "branch flag", cli: "--branch main", wants: BrowseOptions{ Branch: "main", }, wantsErr: false, }, { name: "branch flag without a branch name", cli: "--branch", wantsErr: true, }, { name: "combination: settings projects", cli: "--settings --projects", wants: BrowseOptions{ SettingsFlag: true, ProjectsFlag: true, }, wantsErr: true, }, { name: "combination: projects wiki", cli: "--projects --wiki", wants: BrowseOptions{ ProjectsFlag: true, WikiFlag: true, }, wantsErr: true, }, { name: "combination: actions wiki", cli: "--actions --wiki", wants: BrowseOptions{ ActionsFlag: true, WikiFlag: true, }, wantsErr: true, }, { name: "passed argument", cli: "main.go", wants: BrowseOptions{ SelectorArg: "main.go", }, wantsErr: false, }, { name: "passed two arguments", cli: "main.go main.go", wantsErr: true, }, { name: "passed argument and projects flag", cli: "main.go --projects", wantsErr: true, }, { name: "passed argument and releases flag", cli: "main.go --releases", wantsErr: true, }, { name: "passed argument and settings flag", cli: "main.go --settings", wantsErr: true, }, { name: "passed argument and wiki flag", cli: "main.go --wiki", wantsErr: true, }, { name: "passed argument and actions flag", cli: "main.go --actions", wantsErr: true, }, { name: "empty commit flag", cli: "--commit", wants: BrowseOptions{ Commit: emptyCommitFlag, }, wantsErr: false, }, { name: "commit flag with a hash", cli: "--commit=12a4", wants: BrowseOptions{ Commit: "12a4", }, wantsErr: false, }, { name: "commit flag with a hash and a file selector", cli: "main.go --commit=12a4", wants: BrowseOptions{ Commit: "12a4", SelectorArg: "main.go", }, wantsErr: false, }, { name: "passed both branch and commit flags", cli: "main.go --branch main --commit=12a4", wantsErr: true, }, { name: "passed both number arg and branch flag", cli: "1 --branch trunk", wantsErr: true, }, { name: "passed both number arg and commit flag", cli: "1 --commit=12a4", wantsErr: true, }, { name: "passed both commit SHA arg and branch flag", cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --branch trunk", wantsErr: true, }, { name: "passed both commit SHA arg and commit flag", cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --commit=12a4", wantsErr: true, }, { name: "blame flag", cli: "main.go --blame", wants: BrowseOptions{ BlameFlag: true, SelectorArg: "main.go", }, wantsErr: false, }, { name: "blame flag without file argument", cli: "--blame", wantsErr: true, }, { name: "blame flag with line number", cli: "main.go:312 --blame", wants: BrowseOptions{ BlameFlag: true, SelectorArg: "main.go:312", }, wantsErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := cmdutil.Factory{} var opts *BrowseOptions cmd := NewCmdBrowse(&f, func(o *BrowseOptions) error { opts = o return nil }) argv, err := shlex.Split(tt.cli) assert.NoError(t, err) cmd.SetArgs(argv) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) _, err = cmd.ExecuteC() if tt.wantsErr { assert.Error(t, err) return } else { assert.NoError(t, err) } assert.Equal(t, tt.wants.Branch, opts.Branch) assert.Equal(t, tt.wants.SelectorArg, opts.SelectorArg) assert.Equal(t, tt.wants.ProjectsFlag, opts.ProjectsFlag) assert.Equal(t, tt.wants.ReleasesFlag, opts.ReleasesFlag) assert.Equal(t, tt.wants.WikiFlag, opts.WikiFlag) assert.Equal(t, tt.wants.NoBrowserFlag, opts.NoBrowserFlag) assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag) assert.Equal(t, tt.wants.ActionsFlag, opts.ActionsFlag) assert.Equal(t, tt.wants.Commit, opts.Commit) assert.Equal(t, tt.wants.BlameFlag, opts.BlameFlag) }) } } type testGitClient struct{} func (gc *testGitClient) LastCommit() (*git.Commit, error) { return &git.Commit{Sha: "6f1a2405cace1633d89a79c74c65f22fe78f9659"}, nil } func Test_runBrowse(t *testing.T) { s := string(os.PathSeparator) t.Setenv("GIT_DIR", "../../../git/fixtures/simple.git") tests := []struct { name string opts BrowseOptions httpStub func(*httpmock.Registry) baseRepo ghrepo.Interface defaultBranch string expectedURL string wantsErr bool }{ { name: "no arguments", opts: BrowseOptions{ SelectorArg: "", }, baseRepo: ghrepo.New("jlsestak", "cli"), expectedURL: "https://github.com/jlsestak/cli", }, { name: "settings flag", opts: BrowseOptions{ SettingsFlag: true, }, baseRepo: ghrepo.New("bchadwic", "ObscuredByClouds"), expectedURL: "https://github.com/bchadwic/ObscuredByClouds/settings", }, { name: "projects flag", opts: BrowseOptions{ ProjectsFlag: true, }, baseRepo: ghrepo.New("ttran112", "7ate9"), expectedURL: "https://github.com/ttran112/7ate9/projects", }, { name: "releases flag", opts: BrowseOptions{ ReleasesFlag: true, }, baseRepo: ghrepo.New("ttran112", "7ate9"), expectedURL: "https://github.com/ttran112/7ate9/releases", }, { name: "wiki flag", opts: BrowseOptions{ WikiFlag: true, }, baseRepo: ghrepo.New("ravocean", "ThreatLevelMidnight"), expectedURL: "https://github.com/ravocean/ThreatLevelMidnight/wiki", }, { name: "actions flag", opts: BrowseOptions{ ActionsFlag: true, }, baseRepo: ghrepo.New("ravocean", "ThreatLevelMidnight"), expectedURL: "https://github.com/ravocean/ThreatLevelMidnight/actions", }, { name: "file argument", opts: BrowseOptions{SelectorArg: "path/to/file.txt"}, baseRepo: ghrepo.New("ken", "mrprofessor"), defaultBranch: "main", expectedURL: "https://github.com/ken/mrprofessor/tree/main/path/to/file.txt", }, { name: "issue argument", opts: BrowseOptions{ SelectorArg: "217", }, baseRepo: ghrepo.New("kevin", "MinTy"), expectedURL: "https://github.com/kevin/MinTy/issues/217", }, { name: "issue with hashtag argument", opts: BrowseOptions{ SelectorArg: "#217", }, baseRepo: ghrepo.New("kevin", "MinTy"), expectedURL: "https://github.com/kevin/MinTy/issues/217", }, { name: "branch flag", opts: BrowseOptions{ Branch: "trunk", }, baseRepo: ghrepo.New("jlsestak", "CouldNotThinkOfARepoName"), expectedURL: "https://github.com/jlsestak/CouldNotThinkOfARepoName/tree/trunk", }, { name: "branch flag with file", opts: BrowseOptions{ Branch: "trunk", SelectorArg: "main.go", }, baseRepo: ghrepo.New("bchadwic", "LedZeppelinIV"), expectedURL: "https://github.com/bchadwic/LedZeppelinIV/tree/trunk/main.go", }, { name: "branch flag within dir", opts: BrowseOptions{ Branch: "feature-123", PathFromRepoRoot: func() string { return "pkg/dir" }, }, baseRepo: ghrepo.New("bstnc", "yeepers"), expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123", }, { name: "branch flag within dir with .", opts: BrowseOptions{ Branch: "feature-123", SelectorArg: ".", PathFromRepoRoot: func() string { return "pkg/dir" }, }, baseRepo: ghrepo.New("bstnc", "yeepers"), expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123/pkg/dir", }, { name: "branch flag within dir with dir", opts: BrowseOptions{ Branch: "feature-123", SelectorArg: "inner/more", PathFromRepoRoot: func() string { return "pkg/dir" }, }, baseRepo: ghrepo.New("bstnc", "yeepers"), expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123/pkg/dir/inner/more", }, { name: "file with line number", opts: BrowseOptions{ SelectorArg: "path/to/file.txt:32", }, baseRepo: ghrepo.New("ravocean", "angur"), defaultBranch: "trunk", expectedURL: "https://github.com/ravocean/angur/blob/trunk/path/to/file.txt?plain=1#L32", }, { name: "file with line range", opts: BrowseOptions{ SelectorArg: "path/to/file.txt:32-40", }, baseRepo: ghrepo.New("ravocean", "angur"), defaultBranch: "trunk", expectedURL: "https://github.com/ravocean/angur/blob/trunk/path/to/file.txt?plain=1#L32-L40", }, { name: "invalid default branch", opts: BrowseOptions{ SelectorArg: "chocolate-pecan-pie.txt", }, baseRepo: ghrepo.New("andrewhsu", "recipes"), defaultBranch: "", wantsErr: true, }, { name: "file with invalid line number after colon", opts: BrowseOptions{ SelectorArg: "laptime-notes.txt:w-9", }, baseRepo: ghrepo.New("andrewhsu", "sonoma-raceway"), wantsErr: true, }, { name: "file with invalid file format", opts: BrowseOptions{ SelectorArg: "path/to/file.txt:32:32", }, baseRepo: ghrepo.New("ttran112", "ttrain211"), wantsErr: true, }, { name: "file with invalid line number", opts: BrowseOptions{ SelectorArg: "path/to/file.txt:32a", }, baseRepo: ghrepo.New("ttran112", "ttrain211"), wantsErr: true, }, { name: "file with invalid line range", opts: BrowseOptions{ SelectorArg: "path/to/file.txt:32-abc", }, baseRepo: ghrepo.New("ttran112", "ttrain211"), wantsErr: true, }, { name: "branch with issue number", opts: BrowseOptions{ SelectorArg: "217", Branch: "trunk", }, baseRepo: ghrepo.New("ken", "grc"), wantsErr: false, expectedURL: "https://github.com/ken/grc/tree/trunk/217", }, { name: "opening branch file with line number", opts: BrowseOptions{ Branch: "first-browse-pull", SelectorArg: "browse.go:32", }, baseRepo: ghrepo.New("github", "ThankYouGitHub"), wantsErr: false, expectedURL: "https://github.com/github/ThankYouGitHub/blob/first-browse-pull/browse.go?plain=1#L32", }, { name: "no browser with branch file and line number", opts: BrowseOptions{ Branch: "3-0-stable", SelectorArg: "init.rb:6", NoBrowserFlag: true, }, httpStub: func(r *httpmock.Registry) { r.Register( httpmock.REST("HEAD", "repos/mislav/will_paginate"), httpmock.StringResponse("{}"), ) }, baseRepo: ghrepo.New("mislav", "will_paginate"), wantsErr: false, expectedURL: "https://github.com/mislav/will_paginate/blob/3-0-stable/init.rb?plain=1#L6", }, { name: "open last commit", opts: BrowseOptions{ Commit: emptyCommitFlag, GitClient: &testGitClient{}, }, baseRepo: ghrepo.New("vilmibm", "gh-user-status"), wantsErr: false, expectedURL: "https://github.com/vilmibm/gh-user-status/tree/6f1a2405cace1633d89a79c74c65f22fe78f9659", }, { name: "open last commit with a file", opts: BrowseOptions{ Commit: emptyCommitFlag, SelectorArg: "main.go", GitClient: &testGitClient{}, }, baseRepo: ghrepo.New("vilmibm", "gh-user-status"), wantsErr: false, expectedURL: "https://github.com/vilmibm/gh-user-status/tree/6f1a2405cace1633d89a79c74c65f22fe78f9659/main.go", }, { name: "open number only commit hash", opts: BrowseOptions{ Commit: "1234567890", GitClient: &testGitClient{}, }, baseRepo: ghrepo.New("yanskun", "ILoveGitHub"), wantsErr: false, expectedURL: "https://github.com/yanskun/ILoveGitHub/tree/1234567890", }, { name: "open file with the repository state at a commit hash", opts: BrowseOptions{ Commit: "12a4", SelectorArg: "main.go", GitClient: &testGitClient{}, }, baseRepo: ghrepo.New("yanskun", "ILoveGitHub"), wantsErr: false, expectedURL: "https://github.com/yanskun/ILoveGitHub/tree/12a4/main.go", }, { name: "relative path from browse_test.go", opts: BrowseOptions{ SelectorArg: filepath.Join(".", "browse_test.go"), PathFromRepoRoot: func() string { return "pkg/cmd/browse/" }, }, baseRepo: ghrepo.New("bchadwic", "gh-graph"), defaultBranch: "trunk", expectedURL: "https://github.com/bchadwic/gh-graph/tree/trunk/pkg/cmd/browse/browse_test.go", wantsErr: false, }, { name: "relative path to file in parent folder from browse_test.go", opts: BrowseOptions{ SelectorArg: ".." + s + "pr", PathFromRepoRoot: func() string { return "pkg/cmd/browse/" }, }, baseRepo: ghrepo.New("bchadwic", "gh-graph"), defaultBranch: "trunk", expectedURL: "https://github.com/bchadwic/gh-graph/tree/trunk/pkg/cmd/pr", wantsErr: false, }, { name: "does not use relative path when has repo override", opts: BrowseOptions{ SelectorArg: "README.md", HasRepoOverride: true, PathFromRepoRoot: func() string { return "pkg/cmd/browse/" }, }, baseRepo: ghrepo.New("bchadwic", "gh-graph"), defaultBranch: "trunk", expectedURL: "https://github.com/bchadwic/gh-graph/tree/trunk/README.md", wantsErr: false, }, { name: "use special characters in selector arg", opts: BrowseOptions{ SelectorArg: "?=hello world/ *:23-44", Branch: "branch/with spaces?", }, baseRepo: ghrepo.New("bchadwic", "test"), expectedURL: "https://github.com/bchadwic/test/blob/branch/with%20spaces%3F/%3F=hello%20world/%20%2A?plain=1#L23-L44", wantsErr: false, }, { name: "commit hash in selector arg", opts: BrowseOptions{ SelectorArg: "77507cd94ccafcf568f8560cfecde965fcfa63e7", }, baseRepo: ghrepo.New("bchadwic", "test"), expectedURL: "https://github.com/bchadwic/test/commit/77507cd94ccafcf568f8560cfecde965fcfa63e7", wantsErr: false, }, { name: "short commit hash in selector arg", opts: BrowseOptions{ SelectorArg: "6e3689d5", }, baseRepo: ghrepo.New("bchadwic", "test"), expectedURL: "https://github.com/bchadwic/test/commit/6e3689d5", wantsErr: false, }, { name: "commit hash with extension", opts: BrowseOptions{ SelectorArg: "77507cd94ccafcf568f8560cfecde965fcfa63e7.txt", Branch: "trunk", }, baseRepo: ghrepo.New("bchadwic", "test"), expectedURL: "https://github.com/bchadwic/test/tree/trunk/77507cd94ccafcf568f8560cfecde965fcfa63e7.txt", wantsErr: false, }, { name: "file with blame flag", opts: BrowseOptions{ SelectorArg: "path/to/file.txt", BlameFlag: true, }, baseRepo: ghrepo.New("owner", "repo"), defaultBranch: "main", expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt", wantsErr: false, }, { name: "file with blame flag and line number", opts: BrowseOptions{ SelectorArg: "path/to/file.txt:42", BlameFlag: true, }, baseRepo: ghrepo.New("owner", "repo"), defaultBranch: "main", expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L42", wantsErr: false, }, { name: "file with blame flag and line range", opts: BrowseOptions{ SelectorArg: "path/to/file.txt:10-20", BlameFlag: true, }, baseRepo: ghrepo.New("owner", "repo"), defaultBranch: "main", expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L10-L20", wantsErr: false, }, { name: "file with blame flag and branch", opts: BrowseOptions{ SelectorArg: "main.go:100", BlameFlag: true, Branch: "feature-branch", }, baseRepo: ghrepo.New("owner", "repo"), expectedURL: "https://github.com/owner/repo/blame/feature-branch/main.go#L100", wantsErr: false, }, { name: "file with blame flag and commit", opts: BrowseOptions{ SelectorArg: "src/app.js:50", BlameFlag: true, Commit: "abc123", }, baseRepo: ghrepo.New("owner", "repo"), expectedURL: "https://github.com/owner/repo/blame/abc123/src/app.js#L50", wantsErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() browser := browser.Stub{} reg := httpmock.Registry{} defer reg.Verify(t) if tt.defaultBranch != "" { reg.StubRepoInfoResponse(tt.baseRepo.RepoOwner(), tt.baseRepo.RepoName(), tt.defaultBranch) } if tt.httpStub != nil { tt.httpStub(®) } opts := tt.opts opts.IO = ios opts.BaseRepo = func() (ghrepo.Interface, error) { return tt.baseRepo, nil } opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: ®}, nil } opts.Browser = &browser if opts.PathFromRepoRoot == nil { opts.PathFromRepoRoot = func() string { return "" } } err := runBrowse(&opts) if tt.wantsErr { assert.Error(t, err) } else { assert.NoError(t, err) } if opts.NoBrowserFlag { assert.Equal(t, fmt.Sprintf("%s\n", tt.expectedURL), stdout.String()) assert.Equal(t, "", stderr.String()) browser.Verify(t, "") } else { assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) browser.Verify(t, tt.expectedURL) } }) } } func Test_parsePathFromFileArg(t *testing.T) { tests := []struct { name string currentDir string fileArg string expectedPath string }{ { name: "empty paths", currentDir: "", fileArg: "", expectedPath: "", }, { name: "root directory", currentDir: "", fileArg: ".", expectedPath: "", }, { name: "relative path", currentDir: "", fileArg: filepath.FromSlash("foo/bar.py"), expectedPath: "foo/bar.py", }, { name: "go to parent folder", currentDir: "pkg/cmd/browse/", fileArg: filepath.FromSlash("../"), expectedPath: "pkg/cmd", }, { name: "current folder", currentDir: "pkg/cmd/browse/", fileArg: ".", expectedPath: "pkg/cmd/browse", }, { name: "current folder (alternative)", currentDir: "pkg/cmd/browse/", fileArg: filepath.FromSlash("./"), expectedPath: "pkg/cmd/browse", }, { name: "file that starts with '.'", currentDir: "pkg/cmd/browse/", fileArg: ".gitignore", expectedPath: "pkg/cmd/browse/.gitignore", }, { name: "file in current folder", currentDir: "pkg/cmd/browse/", fileArg: filepath.Join(".", "browse.go"), expectedPath: "pkg/cmd/browse/browse.go", }, { name: "file within parent folder", currentDir: "pkg/cmd/browse/", fileArg: filepath.Join("..", "browse.go"), expectedPath: "pkg/cmd/browse.go", }, { name: "file within parent folder uncleaned", currentDir: "pkg/cmd/browse/", fileArg: filepath.FromSlash(".././//browse.go"), expectedPath: "pkg/cmd/browse.go", }, { name: "different path from root directory", currentDir: "pkg/cmd/browse/", fileArg: filepath.Join("..", "..", "..", "internal/build/build.go"), expectedPath: "internal/build/build.go", }, { name: "go out of repository", currentDir: "pkg/cmd/browse/", fileArg: filepath.FromSlash("../../../../../../"), expectedPath: "", }, { name: "go to root of repository", currentDir: "pkg/cmd/browse/", fileArg: filepath.FromSlash("../../../"), expectedPath: "", }, { name: "empty fileArg", fileArg: "", expectedPath: "", }, } for _, tt := range tests { path, _, _, _ := parseFile(BrowseOptions{ PathFromRepoRoot: func() string { return tt.currentDir }}, tt.fileArg) assert.Equal(t, tt.expectedPath, path, tt.name) } }