From 0be16298d7c3b200794eeb5853c8189f00fe9d45 Mon Sep 17 00:00:00 2001 From: Cody Baker Date: Mon, 5 Feb 2024 03:42:33 +0000 Subject: [PATCH] integration test + cleanup outut --- cmd/root.go | 7 +- cmd/shelly.go | 3 +- go.mod | 3 +- go.sum | 6 ++ integration_test/helpers_test.go | 57 +++++++++++++++ integration_test/main_test.go | 117 +++++++++++++++++++++++++++++++ integration_test/shelly_test.go | 72 +++++++++++++++++++ pkg/discovery/device.go | 2 +- pkg/gencobra/gencobra.go | 4 +- pkg/outputter/out.go | 64 ++++++++++++++--- 10 files changed, 319 insertions(+), 16 deletions(-) create mode 100644 integration_test/helpers_test.go create mode 100644 integration_test/main_test.go create mode 100644 integration_test/shelly_test.go diff --git a/cmd/root.go b/cmd/root.go index 1609b83..fc4acee 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -33,7 +34,7 @@ func init() { rootCmd.Help() } rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "warn", "threshold for outputing logs: trace, debug, info, warn, error, fatal, panic") - rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "text", "desired output format: json, text, log") + rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "o", "text", "desired output format: json, min-json, yaml, text, log") rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) @@ -81,6 +82,6 @@ func Execute() { } } -func Output(ctx context.Context, msg, field string, f any) error { - return activeOutputter(ctx, msg, field, f) +func Output(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { + return activeOutputter(ctx, msg, field, f, raw) } diff --git a/cmd/shelly.go b/cmd/shelly.go index 1964900..183796b 100644 --- a/cmd/shelly.go +++ b/cmd/shelly.go @@ -60,7 +60,7 @@ func init() { } ll.Info().Any("request_body", req).Str("method", req.Method()).Msg("sending request") resp := req.NewResponse() - _, err = shelly.Do(ctx, conn, d.AuthCallback(ctx), req, resp) + raw, err := shelly.Do(ctx, conn, d.AuthCallback(ctx), req, resp) if err != nil { return fmt.Errorf("executing %s: %w", req.Method(), err) } @@ -69,6 +69,7 @@ func init() { fmt.Sprintf("Response to %s command for %s", req.Method(), d.BestName()), "response", resp, + raw.Response, ) } return nil diff --git a/go.mod b/go.mod index f417d1a..544fe6b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.21.3 require ( github.com/go-logr/zerologr v1.2.3 github.com/hashicorp/mdns v1.0.5 - github.com/jcodybaker/go-shelly v0.0.0-20240129013438-f78ef39340b5 + github.com/jcodybaker/go-shelly v0.0.0-20240205025506-cf7de7b6cbf3 github.com/mongoose-os/mos v0.0.0-20230313140341-b44964e63a92 github.com/prometheus/client_golang v1.18.0 github.com/rs/zerolog v1.31.0 @@ -68,6 +68,7 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tinygo-org/cbgo v0.0.4 // indirect + github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect diff --git a/go.sum b/go.sum index fd2f974..a439cce 100644 --- a/go.sum +++ b/go.sum @@ -530,6 +530,10 @@ github.com/jcodybaker/go-shelly v0.0.0-20240129011241-4d585546b813 h1:kpVAdNxk4H github.com/jcodybaker/go-shelly v0.0.0-20240129011241-4d585546b813/go.mod h1:EfKnkqHSomR+wV7AoVgv6wU+kz1Xm4RSaEKaWMKWgWg= github.com/jcodybaker/go-shelly v0.0.0-20240129013438-f78ef39340b5 h1:iL7n3gCzkNlClPBPuKd/51gLELI7jwEC6d0+BUqw20k= github.com/jcodybaker/go-shelly v0.0.0-20240129013438-f78ef39340b5/go.mod h1:EfKnkqHSomR+wV7AoVgv6wU+kz1Xm4RSaEKaWMKWgWg= +github.com/jcodybaker/go-shelly v0.0.0-20240205023821-52dd211a5e6f h1:/eO2ZIjuibDRCqP0u0vK2DB7PYfnInhgsV4H7ZalXmY= +github.com/jcodybaker/go-shelly v0.0.0-20240205023821-52dd211a5e6f/go.mod h1:EfKnkqHSomR+wV7AoVgv6wU+kz1Xm4RSaEKaWMKWgWg= +github.com/jcodybaker/go-shelly v0.0.0-20240205025506-cf7de7b6cbf3 h1:TuMjUPXvwQIw8T/s9cyUMspK7U9usPf7pfb7hM0D4hc= +github.com/jcodybaker/go-shelly v0.0.0-20240205025506-cf7de7b6cbf3/go.mod h1:EfKnkqHSomR+wV7AoVgv6wU+kz1Xm4RSaEKaWMKWgWg= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -848,6 +852,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a/go.mod h1:ul22v+Nro/R083muKhosV54bj5niojjWZvU8xrevuH4= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/integration_test/helpers_test.go b/integration_test/helpers_test.go new file mode 100644 index 0000000..ff5b16d --- /dev/null +++ b/integration_test/helpers_test.go @@ -0,0 +1,57 @@ +package integrationtest + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yalp/jsonpath" +) + +func run(ctx context.Context, t *testing.T, wDevice bool, args ...string) (out, logs string, exitCode int) { + if wDevice { + // echo-min-json roundtrips the response through the parser so we can also verify the parsing. + args = append([]string{"--host", iTest.uri, "-o", "echo-min-json"}, args...) + } + t.Logf("Running: %s %s", iTest.binPath, strings.Join(args, " ")) + cmd := exec.CommandContext(iTest.ctx, iTest.binPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + var eErr *exec.ExitError + if errors.As(err, &eErr) { + return stdout.String(), stderr.String(), eErr.ExitCode() + } + t.Fatalf("cmd %s %s err: %v", iTest.binPath, strings.Join(args, " "), err) + } + return stdout.String(), stderr.String(), 0 +} + +func jsonGet(t *testing.T, actual, path string) any { + t.Helper() + var data any + err := json.Unmarshal([]byte(actual), &data) + require.NoError(t, err) + v, err := jsonpath.Read(data, path) + require.NoError(t, err) + return v +} + +func jsonAssertEqual(t *testing.T, actual, path string, expect any, msg ...any) { + t.Helper() + v := jsonGet(t, actual, path) + assert.Equal(t, expect, v, msg...) +} + +func jsonAssertExists(t *testing.T, actual, path string, msg ...any) { + t.Helper() + v := jsonGet(t, actual, path) + assert.NotNil(t, v, msg...) +} diff --git a/integration_test/main_test.go b/integration_test/main_test.go new file mode 100644 index 0000000..95cf56e --- /dev/null +++ b/integration_test/main_test.go @@ -0,0 +1,117 @@ +package integrationtest + +import ( + "context" + "flag" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/rs/zerolog/log" + + "github.com/jcodybaker/go-shelly" + "github.com/mongoose-os/mos/common/mgrpc" +) + +func init() { + flag.StringVar(&iTest.uri, "device-uri", "", "device for test") + iTest.ctx = context.Background() +} + +var iTest = struct { + ctx context.Context + uri string + srcPath string + binDir string + binPath string + deviceInfo *shelly.ShellyGetDeviceInfoResponse + spec shelly.DeviceSpecs +}{} + +func TestMain(m *testing.M) { + if !flag.Parsed() { + flag.Parse() + } + defer cleanup() + build() + cleanupURI() + getDeviceInfo() + os.Exit(m.Run()) +} + +func cleanupURI() { + if iTest.uri == "" { + log.Fatal().Msg("the -device-uri parameter is required") + } + var u *url.URL + if !strings.Contains(iTest.uri, "://") { + iTest.uri = "http://" + iTest.uri + } + u, err := url.Parse(iTest.uri) + if err != nil { + log.Fatal().Err(err).Msg("parsing device-uri parameter") + } + log.Info().Str("uri.path", u.Path).Msg("using URI path") + if u.Path == "" { + u.Path = "/rpc" + } + iTest.uri = u.String() + log.Info().Str("uri", iTest.uri).Msg("using URI") +} + +func getDeviceInfo() { + ctx := context.Background() + c, err := mgrpc.New(ctx, iTest.uri, mgrpc.UseHTTPPost()) + if err != nil { + log.Fatal().Err(err).Msg("establishing connection to test device") + } + defer c.Disconnect(ctx) + + iTest.deviceInfo, _, err = (&shelly.ShellyGetDeviceInfoRequest{}).Do(ctx, c, nil) + if err != nil { + log.Fatal().Err(err).Msg("requesting device info") + } + + iTest.spec, err = shelly.AppToDeviceSpecs(iTest.deviceInfo.App, iTest.deviceInfo.Profile) + if err != nil { + log.Fatal().Err(err).Msg("resolving device specs") + } +} + +func build() { + _, file, _, ok := runtime.Caller(0) + if !ok { + log.Fatal().Msg("finding path of src for test") + } + dir := filepath.Dir(file) + splitPath := strings.Split(dir, string(filepath.Separator)) + if len(splitPath) < 2 { + log.Fatal().Strs("path", splitPath).Msg("src directory was improbably short") + } + last := splitPath[len(splitPath)-1] + if last != "integration_test" { + log.Fatal().Msg("src directory has wrong format") + } + iTest.srcPath = string(filepath.Separator) + filepath.Join(splitPath[0:len(splitPath)-1]...) + var err error + iTest.binDir, err = os.MkdirTemp("", "shellyctl") + if err != nil { + log.Fatal().Err(err).Msg("making temp dir for binary") + } + iTest.binPath = filepath.Join(iTest.binDir, "shellyctl") + cmd := exec.CommandContext(iTest.ctx, "go", "build", "-o", iTest.binPath, iTest.srcPath) + out, err := cmd.CombinedOutput() + if err != nil { + log.Fatal().Err(err).Str("out", string(out)).Msg("making temp dir for binary") + } +} + +func cleanup() { + if iTest.binDir != "" { + os.RemoveAll(iTest.binDir) + } +} diff --git a/integration_test/shelly_test.go b/integration_test/shelly_test.go new file mode 100644 index 0000000..f8dcb6e --- /dev/null +++ b/integration_test/shelly_test.go @@ -0,0 +1,72 @@ +package integrationtest + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShellyGetDeviceInfo(t *testing.T) { + out, logs, exit := run(iTest.ctx, t, true, "shelly", "get-device-info") + require.Equal(t, 0, exit) + t.Log(out) + t.Log(logs) + t.Logf("cmd exited: %d", exit) + jsonAssertEqual(t, out, "$.auth_en", false) +} + +func TestShellyGetStatus(t *testing.T) { + out, logs, exit := run(iTest.ctx, t, true, "shelly", "get-status") + require.Equal(t, 0, exit) + t.Log(out) + t.Log(logs) + t.Logf("cmd exited: %d", exit) + jsonAssertExists(t, out, "$.sys") + jsonAssertExists(t, out, "$.cloud") + if iTest.spec.Ethernet { + jsonAssertExists(t, out, "$.eth") + } + jsonAssertExists(t, out, "$.wifi") + jsonAssertExists(t, out, "$.ble") + jsonAssertExists(t, out, "$.mqtt") + if iTest.spec.Switches > 0 { + jsonAssertExists(t, out, "$.switches") + } + if iTest.spec.Covers > 0 { + jsonAssertExists(t, out, "$.covers") + } + if iTest.spec.Inputs > 0 { + jsonAssertExists(t, out, "$.inputs") + } + if iTest.spec.Lights > 0 { + jsonAssertExists(t, out, "$.lights") + } +} + +func TestShellyGetConfig(t *testing.T) { + out, logs, exit := run(iTest.ctx, t, true, "shelly", "get-config") + require.Equal(t, 0, exit) + t.Log(out) + t.Log(logs) + t.Logf("cmd exited: %d", exit) + jsonAssertExists(t, out, "$.sys") + jsonAssertExists(t, out, "$.cloud") + if iTest.spec.Ethernet { + jsonAssertExists(t, out, "$.eth") + } + jsonAssertExists(t, out, "$.wifi") + jsonAssertExists(t, out, "$.ble") + jsonAssertExists(t, out, "$.mqtt") + if iTest.spec.Switches > 0 { + jsonAssertExists(t, out, "$.switches") + } + if iTest.spec.Covers > 0 { + jsonAssertExists(t, out, "$.covers") + } + if iTest.spec.Inputs > 0 { + jsonAssertExists(t, out, "$.inputs") + } + if iTest.spec.Lights > 0 { + jsonAssertExists(t, out, "$.lights") + } +} diff --git a/pkg/discovery/device.go b/pkg/discovery/device.go index 38be8bd..f1aded9 100644 --- a/pkg/discovery/device.go +++ b/pkg/discovery/device.go @@ -63,7 +63,7 @@ func (d *Device) resolveSpecs(ctx context.Context) error { if err != nil { return fmt.Errorf("requesting device info for spec resolve: %w", err) } - d.Specs, err = shelly.MDNSAppToDeviceSpecs(resp.App, resp.Profile) + d.Specs, err = shelly.AppToDeviceSpecs(resp.App, resp.Profile) if err != nil { return fmt.Errorf("resolving device info to spec: %w", err) } diff --git a/pkg/gencobra/gencobra.go b/pkg/gencobra/gencobra.go index e05632b..f684b19 100644 --- a/pkg/gencobra/gencobra.go +++ b/pkg/gencobra/gencobra.go @@ -66,15 +66,17 @@ func RequestToCmd(req shelly.RPCRequestBody, baggage *Baggage) (*cobra.Command, } }() resp := req.NewResponse() - _, err = shelly.Do(ctx, conn, d.AuthCallback(ctx), req, resp) + raw, err := shelly.Do(ctx, conn, d.AuthCallback(ctx), req, resp) if err != nil { return fmt.Errorf("executing %s: %w", req.Method(), err) } + ll.Debug().RawJSON("raw_response", raw.Response).Msg("got raw response") baggage.Output( ctx, fmt.Sprintf("Response to %s command for %s", req.Method(), d.BestName()), "response", resp, + raw.Response, ) } return nil diff --git a/pkg/outputter/out.go b/pkg/outputter/out.go index b0f093e..67d9df7 100644 --- a/pkg/outputter/out.go +++ b/pkg/outputter/out.go @@ -1,6 +1,7 @@ package outputter import ( + "bytes" "context" "encoding/json" "fmt" @@ -13,28 +14,54 @@ import ( "sigs.k8s.io/yaml" ) -type Outputter func(ctx context.Context, msg, field string, f any) error +type Outputter func(ctx context.Context, msg, field string, f any, raw json.RawMessage) error -// JSON encodes the data as JSON output to stdout. -func JSON(ctx context.Context, msg, field string, f any) error { +// EchoJSON encodes the data as JSON output to stdout. +func EchoJSON(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { e := json.NewEncoder(os.Stdout) e.SetIndent("", " ") return e.Encode(f) } -// MinJSON encodes the data as minimized JSON output to stdout. -func MinJSON(ctx context.Context, msg, field string, f any) error { +// JSON outputs raw JSON (if possible) after expanding it for readability. +func JSON(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { + if raw == nil { + return EchoJSON(ctx, msg, field, f, nil) + } + var b bytes.Buffer + if err := json.Indent(&b, raw, "", " "); err != nil { + return err + } + _, err := fmt.Fprintln(os.Stdout, b.String()) + return err +} + +// EchoMinJSON reencodes the data as minimized JSON output to stdout. +func EchoMinJSON(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { return json.NewEncoder(os.Stdout).Encode(f) } +// MinJSON outputs the data in its raw format. +func MinJSON(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { + if raw == nil { + return EchoMinJSON(ctx, msg, field, f, nil) + } + var b bytes.Buffer + if err := json.Indent(&b, raw, "", ""); err != nil { + return err + } + _, err := fmt.Fprintln(os.Stdout, b.String()) + return err +} + // Log encodes the data as a structured log. -func Log(ctx context.Context, msg, field string, f any) error { +func Log(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { log.Ctx(ctx).Info().Any(field, f).Msg(msg) return nil } // Text encodes the data as a structured log. -func Text(ctx context.Context, msg, field string, f any) error { +func Text(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { fmt.Printf("%s:", msg) v := reflect.ValueOf(f) if (v.Kind() == reflect.Struct && v.NumField() == 0) || @@ -124,8 +151,21 @@ func text(v reflect.Value, firstIndent, indent string) error { return nil } -// YAML encodes the data as yaml output to stdout. -func YAML(ctx context.Context, msg, field string, f any) error { +// YAML encodes the raw data as yaml output to stdout. +func YAML(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { + if raw == nil { + return EchoYAML(ctx, msg, field, f, nil) + } + yamlBytes, err := yaml.JSONToYAML(raw) + if err != nil { + return err + } + _, err = os.Stdout.Write(yamlBytes) + return err +} + +// EchoYAML reencodes the data as yaml output to stdout. +func EchoYAML(ctx context.Context, msg, field string, f any, raw json.RawMessage) error { jsonBytes, err := json.Marshal(f) if err != nil { return err @@ -151,6 +191,12 @@ func ByName(name string) (Outputter, error) { return Text, nil case "log": return Log, nil + case "echo-json": + return EchoJSON, nil + case "echo-min-json": + return EchoMinJSON, nil + case "echo-yaml": + return EchoYAML, nil default: return nil, fmt.Errorf("unknown output formatter: %q", name) }