Repository: rsteube/carapace
Pull request #569 (2022-09-25 06:53:55)

added sandbox test


Patch:
diff --git a/complete.go b/complete.go
index 373fd26..1334ab7 100644
--- a/complete.go
+++ b/complete.go
@@ -32,7 +32,7 @@ func complete(cmd *cobra.Command, args []string) (string, error) {
 		return ActionMessage(err.Error()).Invoke(Context{CallbackValue: current}).value(shell, current), nil
 	}
 
-	context := newContext(append(targetArgs, current))
+	context := NewContext(append(targetArgs, current))
 
 	// TODO needs more cleanup and tests
 	var targetAction Action
diff --git a/context.go b/context.go
index 9138430..221746e 100644
--- a/context.go
+++ b/context.go
@@ -1,11 +1,13 @@
 package carapace
 
 import (
+	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
 
+	"github.com/rsteube/carapace/internal/common"
 	"github.com/rsteube/carapace/internal/shell/zsh"
 	"github.com/rsteube/carapace/third_party/github.com/drone/envsubst"
 	"github.com/rsteube/carapace/third_party/golang.org/x/sys/execabs"
@@ -23,9 +25,11 @@ type Context struct {
 	Env []string
 	// Dir contains the working directory for current context
 	Dir string
+
+	mockedReplies map[string]string
 }
 
-func newContext(args []string) Context {
+func NewContext(args []string) Context {
 	context := Context{
 		CallbackValue: args[len(args)-1],
 		Args:          args[:len(args)-1],
@@ -35,6 +39,16 @@ func newContext(args []string) Context {
 	if wd, err := os.Getwd(); err == nil {
 		context.Dir = wd
 	}
+
+	// TODO needed for sandbox tests. is this a security hazard?
+	if value, exists := os.LookupEnv("CARAPACE_SANDBOX"); exists {
+		var m common.Mock
+		if err := json.Unmarshal([]byte(value), &m); err != nil {
+			panic(err.Error()) // TODO
+		}
+		context.Dir = m.Dir
+		context.mockedReplies = m.Replies
+	}
 	return context
 }
 
@@ -71,6 +85,14 @@ func (c Context) Envsubst(s string) (string, error) {
 // Env and Dir are set using the Context.
 // See exec.Command for most details.
 func (c Context) Command(name string, arg ...string) *execabs.Cmd {
+	if c.mockedReplies != nil {
+		if m, err := json.Marshal(append([]string{name}, arg...)); err == nil {
+			if reply, exists := c.mockedReplies[string(m)]; exists {
+				return execabs.Command("echo", reply)
+			}
+		}
+	}
+
 	cmd := execabs.Command(name, arg...)
 	cmd.Env = c.Env
 	cmd.Dir = c.Dir
diff --git a/example/cmd/multiparts.go b/example/cmd/multiparts.go
index 12d6d73..8e554ed 100644
--- a/example/cmd/multiparts.go
+++ b/example/cmd/multiparts.go
@@ -15,7 +15,7 @@ var multipartsCmd = &cobra.Command{
 
 func init() {
 	multipartsCmd.Flags().String("at", "", "multiparts with @ as divider")
-	multipartsCmd.Flags().String("colon", "", "multiparts with : as divider ")
+	multipartsCmd.Flags().String("colon", "", "multiparts with : as divider")
 	multipartsCmd.Flags().String("comma", "", "multiparts with , as divider")
 	multipartsCmd.Flags().String("dot", "", "multiparts with . as divider")
 	multipartsCmd.Flags().String("dotdotdot", "", "multiparts with ... as divider")
diff --git a/internal/common/mock.go b/internal/common/mock.go
new file mode 100644
index 0000000..8ef8dd9
--- /dev/null
+++ b/internal/common/mock.go
@@ -0,0 +1,6 @@
+package common
+
+type Mock struct {
+	Dir     string
+	Replies map[string]string
+}
diff --git a/internal/common/value.go b/internal/common/value.go
index f8f9d90..55abdbe 100644
--- a/internal/common/value.go
+++ b/internal/common/value.go
@@ -8,6 +8,10 @@ import (
 	"github.com/rsteube/carapace/pkg/style"
 )
 
+// FromInvokedAction provides access to RawValues within an InvokedAction.
+// It is intended for testing purposes in Sandbox (circumventing dependency issues).
+var FromInvokedAction func(action interface{}) RawValues
+
 // RawValue represents a completion candidate.
 type RawValue struct {
 	Value       string
diff --git a/invokedAction.go b/invokedAction.go
index 224d1b3..91e4d7d 100644
--- a/invokedAction.go
+++ b/invokedAction.go
@@ -150,3 +150,12 @@ func (a InvokedAction) ToMultiPartsA(dividers ...string) Action {
 func (a InvokedAction) value(shell string, callbackValue string) string {
 	return _shell.Value(shell, callbackValue, a.meta, a.rawValues)
 }
+
+func init() {
+	common.FromInvokedAction = func(i interface{}) common.RawValues {
+		if a, ok := i.(InvokedAction); ok {
+			return a.rawValues
+		}
+		return nil
+	}
+}
diff --git a/pkg/sandbox/sandbox.go b/pkg/sandbox/sandbox.go
new file mode 100644
index 0000000..997e24d
--- /dev/null
+++ b/pkg/sandbox/sandbox.go
@@ -0,0 +1,173 @@
+package sandbox
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"testing"
+
+	"github.com/rsteube/carapace"
+	"github.com/rsteube/carapace/internal/assert"
+	"github.com/rsteube/carapace/internal/common"
+	"github.com/spf13/cobra"
+)
+
+type Sandbox struct {
+	t    *testing.T
+	f    func() *cobra.Command
+	keep bool
+	mock *common.Mock
+}
+
+func newSandbox(t *testing.T, f func() *cobra.Command) Sandbox {
+	tempDir, err := os.MkdirTemp(os.TempDir(), "carapace-sandbox_"+t.Name()+"_")
+	if err != nil {
+		t.Fatal("failed to create sandbox dir: " + err.Error())
+	}
+	return Sandbox{
+		t: t,
+		f: f,
+		mock: &common.Mock{
+			Dir:     tempDir,
+			Replies: make(map[string]string),
+		},
+	}
+}
+
+func (s *Sandbox) Keep() {
+	s.keep = true
+}
+
+func (s *Sandbox) remove() {
+	if !s.keep && strings.HasPrefix(s.mock.Dir, os.TempDir()) {
+		os.RemoveAll(s.mock.Dir)
+	}
+}
+
+func (s *Sandbox) Files(args ...string) {
+	if len(args)%2 != 0 {
+		s.t.Errorf("invalid amount of arguments: %v", len(args))
+	}
+
+	if !strings.HasPrefix(s.mock.Dir, os.TempDir()) {
+		s.t.Errorf("sandbox dir not in os.TempDir: %v", s.mock.Dir)
+	}
+
+	for i := 0; i < len(args); i += 2 {
+		file := args[i]
+		content := args[i+1]
+
+		if strings.HasPrefix(file, "../") {
+			s.t.Fatalf("invalid filename: %v", file)
+		}
+
+		path := fmt.Sprintf("%v/%v", s.mock.Dir, file)
+
+		if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil && !os.IsExist(err) {
+			s.t.Fatal(err.Error())
+		}
+
+		if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+			s.t.Fatal(err.Error())
+		}
+	}
+
+}
+
+// Reply mocks a command for given arguments (Only works for `(Context).Command`).
+func (s *Sandbox) Reply(args ...string) reply {
+	m, _ := json.Marshal(args)
+	return reply{s, string(m)}
+}
+
+type reply struct {
+	*Sandbox
+	call string
+}
+
+// With sets the output for the mocked command.
+func (r reply) With(s string) {
+	r.mock.Replies[r.call] = s
+}
+
+// Run invokes `go run` on given package for sandbox tests.
+func Run(t *testing.T, pkg string) (f func(func(s *Sandbox))) {
+	cmdF := func() *cobra.Command {
+		cmd := &cobra.Command{
+			Use:                "integration",
+			Run:                func(cmd *cobra.Command, args []string) {},
+			DisableFlagParsing: true,
+			CompletionOptions: cobra.CompletionOptions{
+				DisableDefaultCmd: true,
+			},
+		}
+
+		carapace.Gen(cmd).PositionalAnyCompletion(
+			carapace.ActionCallback(func(c carapace.Context) carapace.Action {
+				args := []string{"run", pkg, "_carapace", "export", ""}
+				args = append(args, c.Args...)
+				args = append(args, c.CallbackValue)
+				return carapace.ActionExecCommand("go", args...)(func(output []byte) carapace.Action {
+					return carapace.ActionImport(output)
+				})
+			}),
+		)
+		return cmd
+	}
+
+	return func(f func(s *Sandbox)) {
+		s := newSandbox(t, cmdF)
+		defer s.remove()
+		f(&s)
+	}
+}
+
+// Run executes the sandbox with given arguments.
+func (s *Sandbox) Run(args ...string) run {
+	c := carapace.NewContext(args)
+	// TODO actually invoke it here instead of Expect
+	m, _ := json.Marshal(args)
+
+	return run{
+		s.t,
+		string(m),
+		s.mock.Dir,
+		c,
+		carapace.ActionCallback(func(c carapace.Context) carapace.Action {
+			b, _ := json.Marshal(s.mock)
+			c.Setenv("CARAPACE_SANDBOX", string(b))
+			return carapace.ActionExecute(s.f()).Invoke(c).ToA()
+		}),
+	}
+}
+
+type run struct {
+	t       *testing.T
+	id      string
+	dir     string
+	context carapace.Context
+	actual  carapace.Action
+}
+
+func (r run) invoke(a carapace.Action) string {
+	invoked := a.Invoke(r.context)
+	rawValues := common.FromInvokedAction(invoked) // TODO nil check
+	rawValues = rawValues.FilterPrefix(r.context.CallbackValue)
+	sort.Sort(common.ByValue(rawValues))
+	m, err := json.MarshalIndent(rawValues, "", "  ")
+	if err != nil {
+		r.t.Fatal(err.Error())
+	}
+	return string(m)
+}
+
+// Expects validates output of Run with given Action.
+func (r run) Expect(expected carapace.Action) {
+	r.t.Run(r.id, func(t *testing.T) {
+		// t.Parallel() TODO prevent concurrent map write for this (storage.go)
+		assert.Equal(r.t, r.invoke(expected.Chdir(r.dir)), r.invoke(r.actual))
+	})
+}
diff --git a/pkg/style/keyword.go b/pkg/style/keyword.go
index ecd9deb..837815c 100644
--- a/pkg/style/keyword.go
+++ b/pkg/style/keyword.go
@@ -61,7 +61,7 @@ var keywordReplacer = strings.NewReplacer(
 )
 
 // ForKeyword returns the style for given keyword.
-func ForKeyword(s string) string {
+func ForKeyword(s string, _ Context) string {
 	if _style, ok := keywords[keywordReplacer.Replace(strings.ToLower(s))]; ok {
 		return *_style
 	}
diff --git a/pkg/style/loglevel.go b/pkg/style/loglevel.go
index d2e3a27..8d7745e 100644
--- a/pkg/style/loglevel.go
+++ b/pkg/style/loglevel.go
@@ -5,7 +5,7 @@ import (
 )
 
 // ForLogLevel returns the style for given log level.
-func ForLogLevel(s string) string {
+func ForLogLevel(s string, _ Context) string {
 	return map[string]string{
 		"trace":    Carapace.LogLevelTrace,
 		"debug":    Carapace.LogLevelDebug,


Test patch:
diff --git a/example/cmd/action_test.go b/example/cmd/action_test.go
new file mode 100644
index 0000000..cfade44
--- /dev/null
+++ b/example/cmd/action_test.go
@@ -0,0 +1,48 @@
+package cmd
+
+import (
+	"testing"
+
+	"github.com/rsteube/carapace"
+	"github.com/rsteube/carapace/pkg/sandbox"
+	"github.com/rsteube/carapace/pkg/style"
+)
+
+func TestAction(t *testing.T) {
+	sandbox.Run(t, "github.com/rsteube/carapace/example")(func(s *sandbox.Sandbox) {
+		s.Files(
+			"dirA/file1.txt", "",
+			"dirA/file2.png", "",
+			"dirB/dirC/file3.go", "",
+			"dirB/file4.md", "",
+			"file5.go", "",
+		)
+
+		s.Reply("git", "remote").With("origin\nfork")
+
+		s.Run("action", "--callback", "").Expect(carapace.ActionMessage("values flag is not set"))
+		s.Run("action", "--values", "first", "--callback", "").Expect(carapace.ActionMessage("values flag is set to: 'first'"))
+		s.Run("action", "--directories", "").Expect(carapace.ActionValues("dirA/", "dirB/").StyleF(style.ForPath))
+		s.Run("action", "--directories", "dirB/").Expect(carapace.ActionValues("dirC/").Prefix("dirB/").StyleF(style.ForPath))
+		s.Run("action", "--exec-command", "").Expect(carapace.ActionValues("origin", "fork"))
+		s.Run("action", "--files", "").Expect(carapace.ActionValues("dirA/", "dirB/", "file5.go").StyleF(style.ForPath))
+		s.Run("action", "--files-filtered", "").Expect(carapace.ActionValues("dirA/", "dirB/").StyleF(style.ForPath))
+		s.Run("action", "--files-filtered", "dirB/").Expect(carapace.ActionValues("dirC/", "file4.md").Prefix("dirB/").StyleF(style.ForPath))
+		s.Run("action", "--import", "").Expect(carapace.ActionValues("first", "second", "third"))
+		s.Run("action", "--import", "s").Expect(carapace.ActionValues("second"))
+		s.Run("action", "--message", "").Expect(carapace.ActionMessage("example message"))
+		s.Run("action", "--message-multiple", "t").Expect(carapace.Batch(carapace.ActionMessage("first message"), carapace.ActionMessage("second message"), carapace.ActionMessage("third message"), carapace.ActionValues("one", "two", "three")).ToA())
+		s.Run("action", "--multiparts", "").Expect(carapace.ActionValues("userA", "userB").Suffix(":"))
+		s.Run("action", "--multiparts", "userA:").Expect(carapace.ActionValues("groupA", "groupB").Prefix("userA:"))
+		s.Run("action", "--multiparts-nested", "").Expect(carapace.ActionValues("DIRECTORY", "FILE", "VALUE").Suffix("="))
+		s.Run("action", "--multiparts-nested", "VALUE=").Expect(carapace.ActionValues("one", "two", "three").Prefix("VALUE="))
+		s.Run("action", "--multiparts-nested", "VALUE=two,").Expect(carapace.ActionValues("DIRECTORY", "FILE").Prefix("VALUE=two,").Suffix("="))
+		s.Run("action", "--multiparts-nested", "VALUE=two,DIRECTORY=").Expect(carapace.ActionValues("dirA/", "dirB/").StyleF(style.ForPath).Prefix("VALUE=two,DIRECTORY="))
+		s.Run("action", "--styled-values", "s").Expect(carapace.ActionStyledValues("second", style.Blue))
+		s.Run("action", "--styled-values-described", "t").Expect(carapace.ActionStyledValuesDescribed("third", "description of third", style.Of("#112233", style.Italic), "thirdalias", "description of third", style.BgBrightMagenta))
+		s.Run("action", "--values", "sec").Expect(carapace.ActionValues("second"))
+		s.Run("action", "--values-described", "third").Expect(carapace.ActionValuesDescribed("third", "description of third"))
+		s.Run("action", "pos").Expect(carapace.ActionValues("positional1", "positional1 with space"))
+		s.Run("action", "p1", "positional2 ").Expect(carapace.ActionValues("positional2 with space"))
+	})
+}
diff --git a/example/cmd/multiparts_test.go b/example/cmd/multiparts_test.go
new file mode 100644
index 0000000..6f971c2
--- /dev/null
+++ b/example/cmd/multiparts_test.go
@@ -0,0 +1,31 @@
+package cmd
+
+import (
+	"testing"
+
+	"github.com/rsteube/carapace"
+	"github.com/rsteube/carapace/pkg/sandbox"
+	"github.com/rsteube/carapace/pkg/style"
+)
+
+func TestMultiparts(t *testing.T) {
+	sandbox.Run(t, "github.com/rsteube/carapace/example")(func(s *sandbox.Sandbox) {
+		s.Files(
+			"dirA/file1.txt", "",
+			"dirA/file2.png", "",
+			"dirB/dirC/file3.go", "",
+			"dirB/file4.md", "",
+			"file5.go", "",
+		)
+		s.Run("multiparts", "").Expect(carapace.ActionValues("DIRECTORY", "FILE", "VALUE").Suffix("="))
+		s.Run("multiparts", "D").Expect(carapace.ActionValues("DIRECTORY").Suffix("="))
+		s.Run("multiparts", "DIRECTORY").Expect(carapace.ActionValues("DIRECTORY").Suffix("="))
+		s.Run("multiparts", "DIRECTORY=").Expect(carapace.ActionValues("dirA/", "dirB/").StyleF(style.ForPath).Prefix("DIRECTORY="))
+		s.Run("multiparts", "VALUE=").Expect(carapace.ActionValues("one", "two", "three").Prefix("VALUE="))
+		s.Run("multiparts", "VALUE=o").Expect(carapace.ActionValues("one").Prefix("VALUE="))
+		s.Run("multiparts", "VALUE=one,").Expect(carapace.ActionValues("DIRECTORY", "FILE").Prefix("VALUE=one,").Suffix("="))
+		s.Run("multiparts", "VALUE=one,F").Expect(carapace.ActionValues("FILE").Prefix("VALUE=one,").Suffix("="))
+		s.Run("multiparts", "VALUE=one,FILE=").Expect(carapace.ActionValues("dirA/", "dirB/", "file5.go").StyleF(style.ForPath).Prefix("VALUE=one,FILE="))
+		s.Run("multiparts", "VALUE=one,FILE=dirB/").Expect(carapace.ActionValues("dirC/", "file4.md").Prefix("dirB/").StyleF(style.ForPath).Prefix("VALUE=one,FILE="))
+	})
+}
diff --git a/example/cmd/root_test.go b/example/cmd/root_test.go
index 06f63b1..79719a3 100644
--- a/example/cmd/root_test.go
+++ b/example/cmd/root_test.go
@@ -6,6 +6,8 @@ import (
 
 	"github.com/rsteube/carapace"
 	"github.com/rsteube/carapace/internal/assert"
+	"github.com/rsteube/carapace/pkg/sandbox"
+	"github.com/rsteube/carapace/pkg/style"
 )
 
 func testScript(t *testing.T, shell string, file string) {
@@ -53,3 +55,67 @@ func TestXonsh(t *testing.T) {
 func TestZsh(t *testing.T) {
 	testScript(t, "zsh", "./_test/zsh.sh")
 }
+
+func TestRoot(t *testing.T) {
+	sandbox.Run(t, "github.com/rsteube/carapace/example")(func(s *sandbox.Sandbox) {
+		s.Run("").Expect(carapace.Batch(
+			carapace.ActionValuesDescribed(
+				"action", "action example",
+				"alias", "action example",
+				"flag", "flag example",
+			).Style(style.Blue).Tag("main commands"),
+			carapace.ActionValuesDescribed(
+				"modifier", "modifier example",
+			).Style(style.Yellow).Tag("modifier commands"),
+			carapace.ActionValuesDescribed(
+				"injection", "just trying to break things",
+			).Style(style.Magenta).Tag("test commands"),
+			carapace.ActionValuesDescribed(
+				"completion", "Generate the autocompletion script for the specified shell",
+				"help", "Help about any command",
+				"multiparts", "multiparts example",
+				"special", "",
+			).Tag("other commands"),
+		).ToA())
+
+		s.Run("a").Expect(carapace.ActionStyledValuesDescribed(
+			"action", "action example", style.Blue,
+			"alias", "action example", style.Blue,
+		).Tag("main commands"))
+
+		s.Run("action").Expect(carapace.ActionStyledValuesDescribed(
+			"action", "action example", style.Blue,
+		).Tag("main commands"))
+
+		s.Run("-").Expect(carapace.ActionValuesDescribed(
+			"--array", "multiflag",
+			"-a", "multiflag",
+			"--persistentFlag", "Help message for persistentFlag",
+			"-p", "Help message for persistentFlag",
+			"--toggle", "Help message for toggle",
+			"-t", "Help message for toggle",
+		).Tag("flags"))
+
+		s.Run("--").Expect(carapace.ActionValuesDescribed(
+			"--array", "multiflag",
+			"--persistentFlag", "Help message for persistentFlag",
+			"--toggle", "Help message for toggle",
+		).Tag("flags"))
+
+		s.Run("--a").Expect(carapace.ActionValuesDescribed(
+			"--array", "multiflag",
+		).Tag("flags"))
+
+		s.Run("--array").Expect(carapace.ActionValuesDescribed(
+			"--array", "multiflag",
+		).Tag("flags"))
+
+		s.Run("--array", "", "--a").Expect(carapace.ActionValuesDescribed(
+			"--array", "multiflag",
+		).Tag("flags"))
+
+		s.Run("-a", "", "--a").Expect(carapace.ActionValuesDescribed(
+			"--array", "multiflag",
+		).Tag("flags"))
+	})
+}