diff --git a/go.mod b/go.mod index ff64a675821c8034bf9f089a8f2960232ba551c5..566e99fb4100d8110b596b47618e99840b61bd06 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/stretchr/testify v1.9.0 github.com/testcontainers/testcontainers-go v0.31.0 github.com/testcontainers/testcontainers-go/modules/mockserver v0.31.0 + gonum.org/v1/gonum v0.15.0 ) require ( @@ -59,7 +60,7 @@ require ( golang.org/x/crypto v0.22.0 // indirect golang.org/x/mod v0.16.0 // indirect golang.org/x/sys v0.19.0 // indirect - golang.org/x/tools v0.13.0 // indirect + golang.org/x/tools v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d // indirect google.golang.org/grpc v1.58.3 // indirect google.golang.org/protobuf v1.33.0 // indirect diff --git a/go.sum b/go.sum index ac34cc9267709ba357bb2915d2d656ceb0a05d95..3235f796ca93d3d205485acd5e6ebf5a4fa72b5f 100644 --- a/go.sum +++ b/go.sum @@ -147,6 +147,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= @@ -160,8 +162,8 @@ golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -186,12 +188,14 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= +golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ= +gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo= google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 h1:Z0hjGZePRE0ZBWotvtrwxFNrNE9CUAGtplaDK5NNI/g= google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 h1:FmF5cCW94Ij59cfpoLiwTgodWmm60eEV0CjlsVg2fuw= google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= diff --git a/snok.go b/snok.go index e49d2ddaaa8e82b5dc72457b21148287dcc70444..5400ba5aa4d7f5b2e60776b87d2775134ff14052 100644 --- a/snok.go +++ b/snok.go @@ -8,8 +8,6 @@ import ( "io" "os" "regexp" - "slices" - "strconv" "strings" "sync" "testing" @@ -19,110 +17,123 @@ import ( "github.com/spf13/pflag" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" + "gonum.org/v1/gonum/graph" + "gonum.org/v1/gonum/graph/simple" + "gonum.org/v1/gonum/graph/topo" ) -type test struct { - Name string `json:"name,omitempty"` - Args []string `json:"args,omitempty"` - Input *string `json:"input,omitempty"` - Output *string `json:"output,omitempty"` - ExpectError bool `json:"expectError,omitempty"` +type Tests struct { + t *testing.T + RootCmd *cobra.Command + Debug bool + Trace bool + graph *simple.DirectedGraph + unexpectedErr func(require.TestingT, error, ...interface{}) } -type container struct { - startFn func(*CommandTest, *testing.T) error - running bool -} - -type CommandTest struct { - t *testing.T - RootCmd *cobra.Command - Debug bool - Trace bool - containers map[string]container - handleUnexpectedError func(require.TestingT, error, ...interface{}) -} - -func NewCommandTest(cmd *cobra.Command) *CommandTest { - ct := &CommandTest{ - RootCmd: cmd, - Debug: false, - Trace: false, - containers: make(map[string]container), - handleUnexpectedError: require.NoError, +func NewTestSuite(cmd *cobra.Command) *Tests { + ct := &Tests{ + RootCmd: cmd, + Debug: false, + Trace: false, + graph: simple.NewDirectedGraph(), + unexpectedErr: require.NoError, } - fmt.Println("parse flags") flag.BoolVar(&ct.Debug, "debug", false, "debug") flag.BoolVar(&ct.Trace, "trace", false, "trace") flag.Parse() return ct } -func (ct *CommandTest) AddContainer(name string, startFn func(*CommandTest, *testing.T) error) { - ct.containers[name] = container{startFn: startFn} +func (ct *Tests) GetTest(name string) (*test, error) { + node, new := ct.graph.NodeWithID(hash(name)) + if new { + return nil, fmt.Errorf("Undefined test: %s", name) + } + if test, ok := node.(*test); ok { + return test, nil + } + return nil, fmt.Errorf("Node type is not test: %s", name) } -func (ct *CommandTest) Accept(log testcontainers.Log) { +func (ct *Tests) Accept(log testcontainers.Log) { ct.t.Helper() ct.t.Logf("%s", log.Content) } -func (ct *CommandTest) Run(t *testing.T) { - ct.t = t - ct.testCmd(t, ct.RootCmd, []string{}) +func (tests *Tests) AddHelper(name string, fn func(*Tests, *testing.T) error, needs ...string) { + tests.graph.AddNode(&helper{ + node: node{ + Name: name, + Needs: needs, + }, + fn: fn, + }) } -func (ct *CommandTest) testCmd(t *testing.T, cmd *cobra.Command, args []string) { - args = append(args, cmd.Name()) - containers, exists := cmd.Annotations["containers"] - if exists { - ct.startContainers(t, containers) - } +func (tests *Tests) addTests(t *testing.T, cmd *cobra.Command, cmds []string) { + cmds = append(cmds, cmd.Name()) annotation, exists := cmd.Annotations["tests"] if exists { - t.Run(cmd.Name(), func(t *testing.T) { - tests := []test{} - err := json.Unmarshal([]byte(annotation), &tests) - require.NoError(t, err, "Malformed annotation: %s", annotation) - for _, test := range tests { - ct.executeTest(t, test, args) - cmd.Flags().VisitAll(func(pf *pflag.Flag) { - if pf.Changed { - err := pf.Value.Set(pf.DefValue) - require.NoError(t, err, "Error setting %d to %d", pf.Name, pf.DefValue) - pf.Changed = false - } - }) - } - }) + nodes := []test{} + err := json.Unmarshal([]byte(annotation), &nodes) + require.NoError(t, err, "Malformed annotation: %s", annotation) + for _, test := range nodes { + test.cmd = cmd + test.Args = append(cmds[1:], test.Args...) + tests.graph.AddNode(&test) + } } if cmd.HasSubCommands() { - // Todo: Use a DAG to determine the order of the tests - slices.SortFunc(cmd.Commands(), func(a, b *cobra.Command) int { - return getOrder(a) - getOrder(b) - }) for _, cmd := range cmd.Commands() { - ct.testCmd(t, cmd, args) + tests.addTests(t, cmd, cmds) } } } -func (ct *CommandTest) startContainers(t *testing.T, containers string) { - for _, container := range strings.Split(containers, ",") { - key := strings.TrimFunc(container, unicode.IsSpace) - if c, ok := ct.containers[key]; ok { - if !c.running { - t.Run("[Start "+key+"]", func(_ *testing.T) { - err := c.startFn(ct, t) - require.NoError(t, err, "Failed to start container: %s", key) - }) - c.running = true +func (tests *Tests) calculateExecutionOrder(t *testing.T) []graph.Node { + for _, n := range graph.NodesOf(tests.graph.Nodes()) { + if node, ok := n.(Node); ok { + for _, id := range node.DependencyIDs() { + dependency, new := tests.graph.NodeWithID(id) + require.False(t, new, "Unknown dependency for %s", node) + tests.graph.SetEdge(simple.Edge{F: dependency, T: node}) } } } + cycles := topo.DirectedCyclesIn(tests.graph) + require.Empty(t, cycles, "Detected %d cycles", len(cycles)) + sorted, err := topo.Sort(tests.graph) + require.NoError(t, err) + return sorted } -func (ct *CommandTest) executeTest(t *testing.T, test test, args []string) { +func (tests *Tests) Run(t *testing.T) { + tests.t = t + tests.addTests(t, tests.RootCmd, []string{}) + nodes := tests.calculateExecutionOrder(t) + for _, node := range nodes { + if helper, ok := node.(*helper); ok { + err := helper.fn(tests, t) + require.NoError(t, err, "Failed to execute helper: %s", helper) + } + if test, ok := node.(*test); ok { + tests.executeTest(t, test) + } + } +} + +func resetFlags(t *testing.T, cmd *cobra.Command) { + cmd.Flags().VisitAll(func(pf *pflag.Flag) { + if pf.Changed { + err := pf.Value.Set(pf.DefValue) + require.NoError(t, err, "Error setting %d to %d", pf.Name, pf.DefValue) + pf.Changed = false + } + }) +} + +func (tests *Tests) executeTest(t *testing.T, test *test) { t.Run(test.Name, func(t *testing.T) { var input io.Reader if test.Input != nil { @@ -133,11 +144,12 @@ func (ct *CommandTest) executeTest(t *testing.T, test test, args []string) { } }() } - output, err := ct.executeCmd(append(args[1:], test.Args...), input) + output, err := tests.executeCmd(test.Args, input) + resetFlags(t, test.cmd) if test.ExpectError { require.Error(t, err, "Expected error") } else if err != nil { - ct.handleUnexpectedError(t, err, "Unexpected error") + tests.unexpectedErr(t, err, "Unexpected error") } else if test.Output != nil { output = strings.TrimRightFunc(output, unicode.IsSpace) if strings.HasPrefix(*test.Output, "/") && strings.HasSuffix(*test.Output, "/") { @@ -151,7 +163,7 @@ func (ct *CommandTest) executeTest(t *testing.T, test test, args []string) { }) } -func (ct *CommandTest) executeCmd(args []string, input io.Reader) (string, error) { +func (tests *Tests) executeCmd(args []string, input io.Reader) (string, error) { osStdout := os.Stdout // keep backup of the real stdout defer func() { os.Stdout = osStdout }() // restore the real stdout @@ -164,14 +176,14 @@ func (ct *CommandTest) executeCmd(args []string, input io.Reader) (string, error wg.Add(1) go func() { _, err := io.Copy(output, r) - require.NoError(ct.t, err, "IO Error") + require.NoError(tests.t, err, "IO Error") wg.Done() }() - ct.RootCmd.SetIn(input) - ct.RootCmd.SetOutput(w) - ct.RootCmd.SetArgs(args) - err := ct.RootCmd.Execute() + tests.RootCmd.SetIn(input) + tests.RootCmd.SetOutput(w) + tests.RootCmd.SetArgs(args) + err := tests.RootCmd.Execute() w.Close() wg.Wait() @@ -179,14 +191,3 @@ func (ct *CommandTest) executeCmd(args []string, input io.Reader) (string, error return output.String(), err } - -func getOrder(cmd *cobra.Command) int { - orderStr, exists := cmd.Annotations["order"] - if exists { - order, err := strconv.Atoi(orderStr) - if err == nil { - return order - } - } - return 0 -} diff --git a/snok_test.go b/snok_test.go index 273b8251ba242fc789eb5f8a024f893f2399605d..cccc774f587a384bc90c4710acb7a8244dac9bd1 100644 --- a/snok_test.go +++ b/snok_test.go @@ -16,7 +16,7 @@ import ( "github.com/testcontainers/testcontainers-go/modules/mockserver" ) -var ct *CommandTest +var tests *Tests func mockUnexpectedError(t require.TestingT, err error, msgAndArgs ...interface{}) { if err.Error() != "expect the unexpected" { @@ -28,22 +28,23 @@ func TestMain(m *testing.M) { rootCmd.AddCommand(envCmd, echoCmd, errorCmd) rootCmd.PersistentFlags().BoolP("debug", "d", false, "debug flag") echoCmd.Flags().BoolP("reverse", "r", false, "reverse the output") - ct = NewCommandTest(rootCmd) - ct.handleUnexpectedError = mockUnexpectedError - ct.AddContainer("Test", mockContainer) + tests = NewTestSuite(rootCmd) + tests.unexpectedErr = mockUnexpectedError + tests.AddHelper("TestContainer", mockContainer) + tests.AddHelper("PatchURL", patchTestInput, "TestContainer") os.Exit(m.Run()) } func TestCmds(t *testing.T) { - ct.Run(t) + tests.Run(t) } -func mockContainer(ct *CommandTest, t *testing.T) (err error) { +func mockContainer(tests *Tests, t *testing.T) error { if os.Getenv("MOCKSERVER_URL") == "" { ctx := context.Background() opts := []testcontainers.ContainerCustomizer{ testcontainers.WithImage("mockserver/mockserver:5.15.0"), - testcontainers.WithLogConsumers(ct), + testcontainers.WithLogConsumers(tests), } mockserverContainer, err := mockserver.RunContainer(ctx, opts...) if err != nil && strings.Contains(err.Error(), "Cannot connect to the Docker daemon") { @@ -56,9 +57,21 @@ func mockContainer(ct *CommandTest, t *testing.T) (err error) { url, err := mockserverContainer.URL(ctx) require.NoError(t, err) t.Setenv("MOCKSERVER_URL", url) - echoCmd.Annotations["tests"] = strings.ReplaceAll(echoCmd.Annotations["tests"], "http://localhost", url) } - return + return nil +} + +func patchTestInput(tests *Tests, t *testing.T) error { + _, err := tests.GetTest("TestContainer") + require.Error(t, err) + _, err = tests.GetTest("Does not exist") + require.Error(t, err) + test, err := tests.GetTest("Echo Input (http)") + dashboardUrl := os.Getenv("MOCKSERVER_URL") + "/mockserver/dashboard" + t.Logf("Modifying %s", test.String()) + test.Input = &dashboardUrl + require.NoError(t, err) + return nil } var rootCmd = &cobra.Command{ @@ -91,6 +104,7 @@ var envCmd = &cobra.Command{ "tests": `[ { "name": "Check Container URL", + "needs": ["TestContainer"], "args": ["MOCKSERVER_URL"], "output": "/^http:\\/\\/\\S+:\\d+$/" } @@ -141,12 +155,14 @@ var echoCmd = &cobra.Command{ }, { "name": "Echo Input (http)", + "needs": ["PatchURL"], "args": ["test"], "input": "http://localhost/mockserver/dashboard", "output": "/^test <!doctype html><html lang=\"en\">.*<\\/html>/" }, { - "name": "Echo Input (ftp)", + "name": "Echo Input (string)", + "needs": ["Expected Error"], "args": ["test"], "input": "Hello, World!", "output": "test Hello, World!" @@ -180,6 +196,7 @@ var errorCmd = &cobra.Command{ "tests": `[ { "name": "Expected Error", + "needs": ["Echo Input (http)","Version"], "args": ["hello","world"], "expectError": true }, diff --git a/types.go b/types.go new file mode 100644 index 0000000000000000000000000000000000000000..c64efe60f256d1ddb15c5ae880c691d65bd417a0 --- /dev/null +++ b/types.go @@ -0,0 +1,67 @@ +package snok + +import ( + "hash/fnv" + "testing" + + "github.com/spf13/cobra" + "gonum.org/v1/gonum/graph" +) + +// hash calculates the hash value of a string using the FNV-1a algorithm. +func hash(s string) int64 { + hash := fnv.New64a() + hash.Write([]byte(s)) + return int64(hash.Sum64()) +} + +// Node is a graph node. It returns a graph-unique integer ID and a list of +// integer IDs of nodes that it depends on. +type Node interface { + graph.Node + // DependencyIDs returns a list of graph-unique integer ID for each dependency. + DependencyIDs() []int64 +} + +// node in the graph. helper and test inherit from this. +type node struct { + id *int64 + Name string `json:"name,omitempty"` + Needs []string `json:"needs,omitempty"` +} + +func (n *node) ID() int64 { + if n.id == nil { + n.id = new(int64) + *n.id = hash(n.Name) + } + return *n.id +} + +func (n *node) DependencyIDs() []int64 { + needs := make([]int64, len(n.Needs)) + for i, need := range n.Needs { + needs[i] = hash(need) + } + return needs +} + +func (n *node) String() string { + return n.Name +} + +// helper node in the graph. +type helper struct { + node + fn func(*Tests, *testing.T) error +} + +// test node in the graph. +type test struct { + node + cmd *cobra.Command + Args []string `json:"args,omitempty"` + Input *string `json:"input,omitempty"` + Output *string `json:"output,omitempty"` + ExpectError bool `json:"expectError,omitempty"` +}