Repository: kubernetes-sigs/krew
Pull request #488 (2020-02-01 12:53:42)

Resolve todo and add unit test
Related issue: #270

k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/krew/pull/488#" title="Author self-approved">corneliusweig</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fkrew).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/krew/blob/master/OWNERS)~~ [corneliusweig]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
codecov-io: # [Codecov](https://codecov.io/gh/kubernetes-sigs/krew/pull/488?src=pr&el=h1) Report
> Merging [#488](https://codecov.io/gh/kubernetes-sigs/krew/pull/488?src=pr&el=desc) into [master](https://codecov.io/gh/kubernetes-sigs/krew/commit/7842e57ab9dc4cf676e55856d06564ecf29d72ef?src=pr&el=desc) will **increase** coverage by `0.97%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/kubernetes-sigs/krew/pull/488/graphs/tree.svg?width=650&token=J3nFtARI2m&height=150&src=pr)](https://codecov.io/gh/kubernetes-sigs/krew/pull/488?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #488      +/-   ##
==========================================
+ Coverage   56.47%   57.45%   +0.97%     
==========================================
  Files          19       19              
  Lines         926      926              
==========================================
+ Hits          523      532       +9     
+ Misses        350      339      -11     
- Partials       53       55       +2
```


| [Impacted Files](https://codecov.io/gh/kubernetes-sigs/krew/pull/488?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [internal/installation/util.go](https://codecov.io/gh/kubernetes-sigs/krew/pull/488/diff?src=pr&el=tree#diff-aW50ZXJuYWwvaW5zdGFsbGF0aW9uL3V0aWwuZ28=) | `69.23% <ø> (+69.23%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/kubernetes-sigs/krew/pull/488?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/kubernetes-sigs/krew/pull/488?src=pr&el=footer). Last update [7842e57...cb4e726](https://codecov.io/gh/kubernetes-sigs/krew/pull/488?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1c25c7f..aff62d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,12 +1,12 @@
 name: Kubernetes-sigs/krew CI
-on: 
+on:
   push:
-    branches: 
+    branches:
       - '*'
-    tags: 
+    tags:
       - 'v*.*.*'
   pull_request:
-    branches: 
+    branches:
       - '*'
 
 jobs:
@@ -23,52 +23,47 @@ jobs:
 
     - name: Check out code into the Go module directory
       uses: actions/checkout@v2
-    
+
     - name: Add GOPATH/bin to PATH
-      # temporary fix
-      # see https://github.com/actions/setup-go/issues/14
-      run: |
-        set -euo pipefail
-        echo "##[set-env name=GOPATH;]$(dirname $GITHUB_WORKSPACE)"
-        echo "##[add-path]$(dirname $GITHUB_WORKSPACE)/bin"
-    
+      run: echo "::add-path::$(go env GOPATH)/bin"
+
     - name: Install dependencies
       run: go mod download
-    
+
     - name: Install gox
       run: hack/install-gox.sh
-      
-    - name: Verify code quality
+
+    - name: Ensure go.mod is already tidied
       run: go mod tidy && git diff --no-patch --exit-code
-        
+
     - name: Verify code patterns
       run: hack/verify-code-patterns.sh
-      
+
     - name: Verify boilerplate
       run: hack/verify-boilerplate.sh
-    
+
     - name: Run code lint
       run: hack/run-lint.sh
-        
+
     - name: Run unit tests
       run: go test -short -coverprofile=coverage.txt -covermode=atomic ./...
-        
+
     - name: Make binaries && verify krew installation
       run: hack/make-all.sh
-        
+
     - name: Ensure kubectl installed
       run: hack/ensure-kubectl-installed.sh
-        
+
     - name: Verify installation
       run: hack/verify-installation.sh
-    
+
     - name: Run integration tests
       run: hack/run-integration-tests.sh
-    
-    - name: Receipts upgrade migrations
+
+    - name: Verify receipt migration from v0.2.x to v0.3.x
       run: hack/verify-receipts-upgrade-migration.sh
 
-    - name: Create release
+    - name: Create a new release
       if: contains(github.ref, 'tags')
       id: create_release
       uses: actions/create-release@v1.0.0
@@ -80,7 +75,7 @@ jobs:
         draft: false
         prerelease: false
 
-    - name: Upload binaries to release
+    - name: Upload binaries to the new release
       if: contains(github.ref, 'tags')
       uses: svenstaro/upload-release-action@v1-release
       with:
@@ -89,6 +84,3 @@ jobs:
         tag: ${{ github.ref }}
         overwrite: true
         file_glob: true
-
-      
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5e48090..6fb30f5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,8 +22,10 @@ If your repo has certain guidelines for contribution, put them here ahead of the
 
 ## Contact Information
 
-To discuss about this project, you can use the following channels, or attend
-[SIG CLI meetings](https://github.com/kubernetes/community/tree/master/sig-cli).
+To discuss about this project, you can use the following channels. You can find
+Krew maintainers at:
 
-- [SIG CLI Slack channel](https://kubernetes.slack.com/messages/sig-cli)
+- Kubernetes Slack channel:
+  [**#krew**](https://kubernetes.slack.com/messages/krew) or
+  [#sig-cli](https://kubernetes.slack.com/messages/sig-cli)
 - [SIG CLI Mailing list](https://groups.google.com/forum/#!forum/kubernetes-sig-cli)
diff --git a/README.md b/README.md
index 445135f..be8538f 100644
--- a/README.md
+++ b/README.md
@@ -157,14 +157,15 @@ new features and changes.
 
 ### Communication channels
 
-* [Slack](https://kubernetes.slack.com/messages/sig-cli) #sig-cli
+* Slack: [#krew](https://kubernetes.slack.com/messages/krew) or
+  [#sig-cli](https://kubernetes.slack.com/messages/sig-cli)
 * [Mailing List](https://groups.google.com/forum/#!forum/kubernetes-sig-cli)
 * [Kubernetes Community site](http://kubernetes.io/community/)
 
 ### Contributing
 
 Interested in contributing to Krew? Please refer to our
-[Contributing Guidelines](./docs/CONTRIBUTOR_GUIDE.md) for more details
+[Contributing Guidelines](./docs/CONTRIBUTOR_GUIDE.md) for more details.
 
 ### Code of Conduct
 
diff --git a/cmd/krew/cmd/internal/setup_check.go b/cmd/krew/cmd/internal/setup_check.go
new file mode 100644
index 0000000..575ca19
--- /dev/null
+++ b/cmd/krew/cmd/internal/setup_check.go
@@ -0,0 +1,85 @@
+// Copyright 2020 The Kubernetes Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package internal
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"k8s.io/klog"
+
+	"sigs.k8s.io/krew/internal/environment"
+	"sigs.k8s.io/krew/internal/installation"
+)
+
+const (
+	instructionWindows = `To be able to run kubectl plugins, you need to add the
+"%USERPROFILE%\.krew\bin" directory to your PATH environment variable
+and restart your shell.`
+	instructionUnixTemplate = `To be able to run kubectl plugins, you need to add
+the following to your %s
+
+and restart your shell.`
+	instructionZsh = `~/.zshrc:
+
+    export PATH="${PATH}:${HOME}/.krew/bin"`
+	instructionBash = `~/.bash_profile or ~/.bashrc:
+
+    export PATH="${PATH}:${HOME}/.krew/bin"`
+	instructionFish = `config.fish:
+
+    set -gx PATH $PATH $HOME/.krew/bin`
+	instructionGeneric = `~/.bash_profile, ~/.bashrc, or ~/.zshrc:
+
+    export PATH="${PATH}:${HOME}/.krew/bin"`
+)
+
+func IsBinDirInPATH(paths environment.Paths) bool {
+	// For the first run we don't want to show a warning.
+	_, err := os.Stat(paths.BasePath())
+	if err != nil {
+		klog.V(4).Info("Assuming this is the first run")
+		return os.IsNotExist(err)
+	}
+
+	binPath := paths.BinPath()
+	for _, dirInPATH := range filepath.SplitList(os.Getenv("PATH")) {
+		if dirInPATH == binPath {
+			return true
+		}
+	}
+	return false
+}
+
+func SetupInstructions() string {
+	if installation.IsWindows() {
+		return instructionWindows
+	}
+
+	var instruction string
+	switch shell := os.Getenv("SHELL"); {
+	case strings.HasSuffix(shell, "/zsh"):
+		instruction = instructionZsh
+	case strings.HasSuffix(shell, "/bash"):
+		instruction = instructionBash
+	case strings.HasSuffix(shell, "/fish"):
+		instruction = instructionFish
+	default:
+		instruction = instructionGeneric
+	}
+	return fmt.Sprintf(instructionUnixTemplate, instruction)
+}
diff --git a/cmd/krew/cmd/root.go b/cmd/krew/cmd/root.go
index a588889..c614769 100644
--- a/cmd/krew/cmd/root.go
+++ b/cmd/krew/cmd/root.go
@@ -19,12 +19,14 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/fatih/color"
 	"github.com/mattn/go-isatty"
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 	"k8s.io/klog"
 
+	"sigs.k8s.io/krew/cmd/krew/cmd/internal"
 	"sigs.k8s.io/krew/internal/environment"
 	"sigs.k8s.io/krew/internal/gitutil"
 	"sigs.k8s.io/krew/internal/installation"
@@ -77,15 +79,22 @@ func init() {
 	}
 
 	paths = environment.MustGetKrewPaths()
+}
+
+func preRun(cmd *cobra.Command, _ []string) error {
+	// check must be done before ensureDirs, to detect krew's self-installation
+	if !internal.IsBinDirInPATH(paths) {
+		boldRed := color.New(color.FgRed, color.Bold).SprintfFunc()
+		fmt.Fprintf(os.Stderr, "%s: %s\n\n", boldRed("WARNING"), internal.SetupInstructions())
+	}
+
 	if err := ensureDirs(paths.BasePath(),
 		paths.InstallPath(),
 		paths.BinPath(),
 		paths.InstallReceiptsPath()); err != nil {
 		klog.Fatal(err)
 	}
-}
 
-func preRun(cmd *cobra.Command, _ []string) error {
 	// detect if receipts migration (v0.2.x->v0.3.x) is complete
 	isMigrated, err := receiptsmigration.Done(paths)
 	if err != nil {
diff --git a/cmd/krew/cmd/version.go b/cmd/krew/cmd/version.go
index 7bc2618..9097278 100644
--- a/cmd/krew/cmd/version.go
+++ b/cmd/krew/cmd/version.go
@@ -19,6 +19,7 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"sigs.k8s.io/krew/internal/installation"
 	"sigs.k8s.io/krew/internal/version"
 	"sigs.k8s.io/krew/pkg/constants"
 )
@@ -46,6 +47,7 @@ Remarks:
 			{"IndexPath", paths.IndexPath()},
 			{"InstallPath", paths.InstallPath()},
 			{"BinPath", paths.BinPath()},
+			{"DetectedPlatform", installation.OSArch().String()},
 		}
 		return printTable(os.Stdout, []string{"OPTION", "VALUE"}, conf)
 	},
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 0891d6f..b0326b0 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -1,4 +1,4 @@
-# Developer Guide
+# Developer Guide 
 
 This guide is intended for plugin developers. If you are not developing kubectl
 plugins, read the [User Guide](./USER_GUIDE.md) to learn how to use krew.
@@ -22,6 +22,10 @@ available on the krew index.
 
 <!-- /TOC -->
 
+Find Krew maintainers on [#krew](https://kubernetes.slack.com/messages/krew)
+room on Kubernetes Slack channel to ask questions, or simply open an issue if
+something is missing.
+
 ## Developing a Plugin
 
 Before creating a plugin, read the [Kubernetes Plugins documentation][plugins].
@@ -336,8 +340,8 @@ they have installed.
 
 ### Automate releasing new versions on krew-index
 
-You can use Github Actions to publish new release of your Krew plugin. 
-`krew-release-bot` is a Github Action to automatically bump the version in 
+You can use Github Actions to publish new release of your Krew plugin.
+`krew-release-bot` is a Github Action to automatically bump the version in
 `krew-index` repo. 
 Refer to the [krew-release-bot](https://github.com/rajatjindal/krew-release-bot) documentation for details.
 
diff --git a/hack/make-binaries.sh b/hack/make-binaries.sh
index 8f97d0d..8a0e43b 100755
--- a/hack/make-binaries.sh
+++ b/hack/make-binaries.sh
@@ -26,7 +26,7 @@ if ! command -v "gox" &>/dev/null; then
 fi
 
 supported_platforms="darwin/amd64 windows/amd64 linux/amd64 linux/arm"
-version_pkg="sigs.k8s.io/krew/pkg/version"
+version_pkg="sigs.k8s.io/krew/internal/version"
 
 cd "${SCRIPTDIR}/.."
 rm -rf -- "out/"
diff --git a/internal/installation/util.go b/internal/installation/util.go
index 3a82104..41b1786 100644
--- a/internal/installation/util.go
+++ b/internal/installation/util.go
@@ -27,10 +27,6 @@ import (
 // ListInstalledPlugins returns a list of all install plugins in a
 // name:version format based on the install receipts at the specified dir.
 func ListInstalledPlugins(receiptsDir string) (map[string]string, error) {
-	// TODO(ahmetb): Write unit tests for this method. Currently blocked by
-	// lack of an in-memory recipt object (issue#270) that we can use to save
-	// receipts to a tempdir that can be read from unit tests.
-
 	matches, err := filepath.Glob(filepath.Join(receiptsDir, "*"+constants.ManifestExtension))
 	if err != nil {
 		return nil, errors.Wrapf(err, "failed to grab receipts directory (%s) for manifests", receiptsDir)


Test patch:
diff --git a/cmd/krew/cmd/internal/setup_check_test.go b/cmd/krew/cmd/internal/setup_check_test.go
new file mode 100644
index 0000000..f0001cb
--- /dev/null
+++ b/cmd/krew/cmd/internal/setup_check_test.go
@@ -0,0 +1,101 @@
+// Copyright 2020 The Kubernetes Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package internal
+
+import (
+	"os"
+	"strings"
+	"testing"
+
+	"sigs.k8s.io/krew/internal/environment"
+	"sigs.k8s.io/krew/internal/testutil"
+)
+
+func TestIsBinDirInPATH_firstRun(t *testing.T) {
+	tempDir, cleanup := testutil.NewTempDir(t)
+	defer cleanup()
+
+	paths := environment.NewPaths(tempDir.Path("does-not-exist"))
+	res := IsBinDirInPATH(paths)
+	if res == false {
+		t.Errorf("expected positive result on first run")
+	}
+}
+
+func TestIsBinDirInPATH_secondRun(t *testing.T) {
+	tempDir, cleanup := testutil.NewTempDir(t)
+	defer cleanup()
+	paths := environment.NewPaths(tempDir.Root())
+	res := IsBinDirInPATH(paths)
+	if res == true {
+		t.Errorf("expected negative result on second run")
+	}
+}
+
+func TestSetupInstructions_windows(t *testing.T) {
+	const instructionsContain = "add the\n\"%USERPROFILE%\\.krew\\bin\" directory to your PATH environment variable"
+	os.Setenv("KREW_OS", "windows")
+	defer func() { os.Unsetenv("KREW_OS") }()
+	instructions := SetupInstructions()
+	if !strings.Contains(instructions, instructionsContain) {
+		t.Errorf("expected %q\nto contain %q", instructions, instructionsContain)
+	}
+}
+
+func TestSetupInstructions_unix(t *testing.T) {
+	tests := []struct {
+		name                string
+		shell               string
+		instructionsContain string
+	}{
+		{
+			name:                "When the shell is zsh",
+			shell:               "/bin/zsh",
+			instructionsContain: "~/.zshrc",
+		},
+		{
+			name:                "When the shell is bash",
+			shell:               "/bin/bash",
+			instructionsContain: "~/.bash_profile or ~/.bashrc",
+		},
+		{
+			name:                "When the shell is fish",
+			shell:               "/bin/fish",
+			instructionsContain: "config.fish",
+		},
+		{
+			name:                "When the shell is unknown",
+			shell:               "other",
+			instructionsContain: "~/.bash_profile, ~/.bashrc, or ~/.zshrc",
+		},
+	}
+
+	// always set KREW_OS, so that tests succeed on windows
+	os.Setenv("KREW_OS", "linux")
+	defer func(origShell string) {
+		os.Unsetenv("KREW_OS")
+		os.Setenv("SHELL", origShell)
+	}(os.Getenv("SHELL"))
+
+	for _, test := range tests {
+		t.Run(test.name, func(tt *testing.T) {
+			os.Setenv("SHELL", test.shell)
+			instructions := SetupInstructions()
+			if !strings.Contains(instructions, test.instructionsContain) {
+				tt.Errorf("expected %q\nto contain %q", instructions, test.instructionsContain)
+			}
+		})
+	}
+}
diff --git a/integration_test/testutil_test.go b/integration_test/testutil_test.go
index 692f4d1..4ed29b1 100644
--- a/integration_test/testutil_test.go
+++ b/integration_test/testutil_test.go
@@ -68,6 +68,8 @@ func NewTest(t *testing.T) (*ITest, func()) {
 		env: []string{
 			fmt.Sprintf("KREW_ROOT=%s", tempDir.Root()),
 			fmt.Sprintf("PATH=%s", augmentPATH(t, binDir)),
+			"KREW_OS=linux",
+			"KREW_ARCH=amd64",
 		},
 		tempDir: tempDir,
 	}, cleanup
diff --git a/integration_test/version_test.go b/integration_test/version_test.go
index 0da795a..dbe1775 100644
--- a/integration_test/version_test.go
+++ b/integration_test/version_test.go
@@ -15,8 +15,9 @@
 package integrationtest
 
 import (
-	"fmt"
+	"path"
 	"regexp"
+	"strings"
 	"testing"
 )
 
@@ -26,21 +27,39 @@ func TestKrewVersion(t *testing.T) {
 	test, cleanup := NewTest(t)
 	defer cleanup()
 
-	output := test.Krew("version").RunOrFailOutput()
+	stdOut := string(test.Krew("version").RunOrFailOutput())
 
-	requiredSubstrings := []string{
-		fmt.Sprintf(`BasePath\s+%s`, test.Root()),
-		"GitTag",
-		"GitCommit",
-		`IndexURI\s+https://github.com/kubernetes-sigs/krew-index.git`,
-		"IndexPath",
-		"InstallPath",
-		"BinPath",
+	lineSplit := regexp.MustCompile(`\s+`)
+	actual := make(map[string]string)
+	for _, line := range strings.Split(stdOut, "\n") {
+		if line == "" {
+			continue
+		}
+		optionValue := lineSplit.Split(line, 2)
+		if len(optionValue) < 2 {
+			t.Errorf("Expect `krew version` to output `OPTION VALUE` pair separated by spaces, got: %v", optionValue)
+		}
+		actual[optionValue[0]] = optionValue[1]
+	}
+
+	requiredSubstrings := map[string]string{
+		"OPTION":           "VALUE",
+		"BasePath":         test.Root(),
+		"GitTag":           "",
+		"GitCommit":        "",
+		"IndexURI":         "https://github.com/kubernetes-sigs/krew-index.git",
+		"IndexPath":        path.Join(test.Root(), "index"),
+		"InstallPath":      path.Join(test.Root(), "store"),
+		"BinPath":          path.Join(test.Root(), "bin"),
+		"DetectedPlatform": "/",
 	}
 
-	for _, p := range requiredSubstrings {
-		if regexp.MustCompile(p).FindSubmatchIndex(output) == nil {
-			t.Errorf("Expected to find %q in output of `krew version`", p)
+	for k, v := range requiredSubstrings {
+		got, ok := actual[k]
+		if !ok {
+			t.Errorf("`krew version` output doesn't contain field %q", k)
+		} else if !strings.Contains(got, v) {
+			t.Errorf("`krew version` %q field doesn't contain string %q (got: %q)", k, v, got)
 		}
 	}
 }
diff --git a/internal/installation/util_test.go b/internal/installation/util_test.go
index cc749e4..4b3d490 100644
--- a/internal/installation/util_test.go
+++ b/internal/installation/util_test.go
@@ -1,4 +1,4 @@
-// Copyright 2019 The Kubernetes Authors.
+// Copyright 2020 The Kubernetes Authors.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -17,6 +17,12 @@ package installation
 import (
 	"path/filepath"
 	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	"sigs.k8s.io/krew/internal/testutil"
+	"sigs.k8s.io/krew/pkg/constants"
+	"sigs.k8s.io/krew/pkg/index"
 )
 
 func testdataPath(t *testing.T) string {
@@ -26,3 +32,49 @@ func testdataPath(t *testing.T) string {
 	}
 	return filepath.Join(pwd, "testdata")
 }
+
+func TestListInstalledPlugins(t *testing.T) {
+	tests := []struct {
+		name     string
+		plugins  []index.Plugin
+		expected map[string]string
+	}{
+		{
+			name:     "single plugin",
+			plugins:  []index.Plugin{testutil.NewPlugin().WithName("test").WithVersion("v0.0.1").V()},
+			expected: map[string]string{"test": "v0.0.1"},
+		},
+		{
+			name: "multiple plugins",
+			plugins: []index.Plugin{
+				testutil.NewPlugin().WithName("plugin-a").WithVersion("v0.0.1").V(),
+				testutil.NewPlugin().WithName("plugin-b").WithVersion("v0.1.0").V(),
+				testutil.NewPlugin().WithName("plugin-c").WithVersion("v1.0.0").V(),
+			},
+			expected: map[string]string{
+				"plugin-a": "v0.0.1",
+				"plugin-b": "v0.1.0",
+				"plugin-c": "v1.0.0",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			tempDir, cleanup := testutil.NewTempDir(t)
+			defer cleanup()
+
+			for _, plugin := range test.plugins {
+				tempDir.WritePlugin(plugin.Name+constants.ManifestExtension, plugin)
+			}
+
+			actual, err := ListInstalledPlugins(tempDir.Root())
+			if err != nil {
+				t.Fatal(err)
+			}
+			if diff := cmp.Diff(test.expected, actual); diff != "" {
+				t.Error(diff)
+			}
+		})
+	}
+}
diff --git a/internal/testutil/tempdir.go b/internal/testutil/tempdir.go
index aa8b270..92ef417 100644
--- a/internal/testutil/tempdir.go
+++ b/internal/testutil/tempdir.go
@@ -21,6 +21,10 @@ import (
 	"path/filepath"
 	"strings"
 	"testing"
+
+	"sigs.k8s.io/yaml"
+
+	"sigs.k8s.io/krew/pkg/index"
 )
 
 type TempDir struct {
@@ -72,3 +76,12 @@ func (td *TempDir) Write(file string, content []byte) *TempDir {
 	}
 	return td
 }
+
+func (td *TempDir) WritePlugin(file string, plugin index.Plugin) *TempDir {
+	td.t.Helper()
+	content, err := yaml.Marshal(plugin)
+	if err != nil {
+		td.t.Fatalf("cannot marshal plugin: %s", err)
+	}
+	return td.Write(file, content)
+}