From 2f378f457c6499fb3dfd8a6cb5b6f9741de7fce0 Mon Sep 17 00:00:00 2001 From: cotishq Date: Tue, 12 May 2026 00:34:05 +0530 Subject: [PATCH 1/2] feat(test): add global output formatter support (text/json/yaml) Signed-off-by: cotishq --- .gitignore | 2 +- cmd/test.go | 61 +++++++-- documentation/cmd/test.md | 1 + pkg/connectors/microcks_client.go | 97 +++++++++---- pkg/output/formatter.go | 33 +++++ pkg/output/json_formatter.go | 30 ++++ pkg/output/output_test.go | 221 ++++++++++++++++++++++++++++++ pkg/output/test_result_json.go | 69 ++++++++++ pkg/output/text_formatter.go | 38 +++++ pkg/output/yaml_formatter.go | 29 ++++ 10 files changed, 537 insertions(+), 44 deletions(-) create mode 100644 pkg/output/formatter.go create mode 100644 pkg/output/json_formatter.go create mode 100644 pkg/output/output_test.go create mode 100644 pkg/output/test_result_json.go create mode 100644 pkg/output/text_formatter.go create mode 100644 pkg/output/yaml_formatter.go diff --git a/.gitignore b/.gitignore index c7ab30c..ced4a89 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ build/dist .DS_Store # test data files -**/testdata/** \ No newline at end of file +**/testdata/**.gocache/ diff --git a/cmd/test.go b/cmd/test.go index b9a6989..07f703e 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -25,6 +25,7 @@ import ( "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" "github.com/spf13/cobra" ) @@ -39,6 +40,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { filteredOperations string operationsHeaders string oAuth2Context string + outputFormat string ) var testCmd = &cobra.Command{ @@ -46,7 +48,6 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { Short: "Run tests on Microcks", Long: `Run tests on Microcks`, Run: func(cmd *cobra.Command, args []string) { - // Parse subcommand args first. if len(os.Args) < 4 { fmt.Println("test command require args") os.Exit(1) @@ -56,7 +57,6 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { testEndpoint := args[1] runnerType := args[2] - // Validate presence and values of args. if len(serviceRef) == 0 || strings.HasPrefix(serviceRef, "-") { fmt.Println("test command require args") os.Exit(1) @@ -74,18 +74,26 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { os.Exit(1) } - // Validate presence and values of flags. + validOutputFormats := map[string]bool{ + string(output.OutputFormatText): true, + string(output.OutputFormatJSON): true, + string(output.OutputFormatYAML): true, + } + if !validOutputFormats[outputFormat] { + fmt.Println("--output format is wrong. Accepted values are: text, json, yaml") + os.Exit(1) + } + isTextOutput := outputFormat == string(output.OutputFormatText) + if !strings.HasSuffix(waitFor, "milli") && !strings.HasSuffix(waitFor, "sec") && !strings.HasSuffix(waitFor, "min") { fmt.Println("--waitFor format is wrong. Accepted units are: milli, sec, min (e.g. 500milli, 30sec, 5min)") os.Exit(1) } - // Collect optional HTTPS transport flags. config.InsecureTLS = globalClientOpts.InsecureTLS config.CaCertPaths = globalClientOpts.CaCertPaths config.Verbose = globalClientOpts.Verbose - // Compute time to wait in milliseconds. var waitForMilliseconds int64 if strings.HasSuffix(waitFor, "milli") { n, err := strconv.ParseInt(waitFor[:len(waitFor)-5], 0, 64) @@ -115,7 +123,6 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { - // create client with server address serverAddr = globalClientOpts.ServerAddr mc = connectors.NewMicrocksClient(serverAddr) @@ -127,7 +134,6 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { var oauthToken string = "unauthenticated-token" if keycloakURL != "null" { - // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. kc := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) oauthToken, err = kc.ConnectAndGetToken() @@ -135,10 +141,8 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { fmt.Printf("Got error when invoking Keycloak client: %s", err) os.Exit(1) } - //fmt.Printf("Retrieve OAuthToken: %s", oauthToken) } - // Then - launch the test on Microcks Server. mc.SetOAuthToken(oauthToken) } else { @@ -175,12 +179,9 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { fmt.Printf("Got error when invoking Microcks client creating Test: %s", err) os.Exit(1) } - //fmt.Printf("Retrieve TestResult ID: %s", testResultID) - // Finally - wait before checking and loop for some time time.Sleep(1 * time.Second) - // Add 10.000ms to wait time as it's now representing the server timeout. now := nowInMilliseconds() future := now + waitForMilliseconds + 10000 @@ -193,17 +194,46 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { } success = testResultSummary.Success inProgress := testResultSummary.InProgress - fmt.Printf("MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)) + if isTextOutput { + fmt.Printf("MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)) + } else { + fmt.Fprintf(os.Stderr, "MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)) + } if !inProgress { break } - fmt.Println("MicrocksTester waiting for 2 seconds before checking again or exiting.") + if isTextOutput { + fmt.Println("MicrocksTester waiting for 2 seconds before checking again or exiting.") + } else { + fmt.Fprintln(os.Stderr, "MicrocksTester waiting for 2 seconds before checking again or exiting.") + } time.Sleep(2 * time.Second) } - fmt.Printf("Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) + if isTextOutput { + fmt.Printf("Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) + } else { + fmt.Fprintf(os.Stderr, "Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) + } + + if !isTextOutput { + fullResult, err := mc.GetFullTestResult(testResultID) + if err != nil { + fmt.Printf("Got error when retrieving full test result: %s", err) + os.Exit(1) + } + formatter, err := output.NewFormatter(output.OutputFormat(outputFormat)) + if err != nil { + fmt.Printf("Got error when selecting output formatter: %s", err) + os.Exit(1) + } + outputStr := formatter.FormatTestResult(fullResult) + if outputStr != "" { + fmt.Println(outputStr) + } + } if !success { os.Exit(1) @@ -216,6 +246,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { testCmd.Flags().StringVar(&filteredOperations, "filteredOperations", "", "List of operations to launch a test for") testCmd.Flags().StringVar(&operationsHeaders, "operationsHeaders", "", "Override of operations headers as JSON string") testCmd.Flags().StringVar(&oAuth2Context, "oAuth2Context", "", "Spec of an OAuth2 client context as JSON string") + testCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text, json, or yaml") return testCmd } diff --git a/documentation/cmd/test.md b/documentation/cmd/test.md index 9fb0c79..4c604fc 100644 --- a/documentation/cmd/test.md +++ b/documentation/cmd/test.md @@ -34,6 +34,7 @@ One of: | `--filteredOperations` | Comma-separated list of operations to test | | `--operationsHeaders` | Custom headers for operations as JSON string | | `--oAuth2Context` | OAuth2 client context as JSON string | +| `--output` | Output format: `text` (default), `json`, or `yaml` | ### Options Inherited from Parent Commands diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index f06b513..631285c 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -50,6 +50,7 @@ type MicrocksClient interface { SetOAuthToken(oauthToken string) CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error) GetTestResult(testResultID string) (*TestResultSummary, error) + GetFullTestResult(testResultID string) (*TestResult, error) UploadArtifact(specificationFilePath string, mainArtifact bool) (string, error) DownloadArtifact(artifactURL string, mainArtifact bool, secret string) (string, error) } @@ -67,6 +68,38 @@ type TestResultSummary struct { InProgress bool `json:"inProgress"` } +// TestResult represents the full Microcks TestResult with nested test case/step details +type TestResult struct { + ID string `json:"id"` + Version int32 `json:"version"` + TestNumber int32 `json:"testNumber"` + TestDate int64 `json:"testDate"` + TestedEndpoint string `json:"testedEndpoint"` + ServiceID string `json:"serviceId"` + ElapsedTime int64 `json:"elapsedTime"` + Success bool `json:"success"` + InProgress bool `json:"inProgress"` + RunnerType string `json:"runnerType"` + TestCases []TestCaseResult `json:"testCaseResults"` +} + +// TestCaseResult represents results for a single operation/action within a test +type TestCaseResult struct { + Success bool `json:"success"` + ElapsedTime int64 `json:"elapsedTime"` + OperationName string `json:"operationName"` + TestStepResults []TestStepResult `json:"testStepResults"` +} + +// TestStepResult represents results for a single request/message within a test case +type TestStepResult struct { + Success bool `json:"success"` + ElapsedTime int64 `json:"elapsedTime"` + RequestName string `json:"requestName"` + Message string `json:"message"` + EventMessageName string `json:"eventMessageName"` +} + // HeaderDTO represents an operation header passed for Test type HeaderDTO struct { Name string `json:"name"` @@ -203,7 +236,6 @@ func (c *microcksClient) HttpClient() *http.Client { } func (c *microcksClient) GetKeycloakURL() (string, error) { - // Ensure we have a correct URL for retrieving Keycloal configuration. rel := &url.URL{Path: "keycloak/config"} u := c.APIURL.ResolveReference(rel) @@ -214,7 +246,6 @@ func (c *microcksClient) GetKeycloakURL() (string, error) { req.Header.Set("Accept", "application/json") - // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for getting Keycloak config", req, true) resp, err := c.httpClient.Do(req) @@ -223,7 +254,6 @@ func (c *microcksClient) GetKeycloakURL() (string, error) { } defer resp.Body.Close() - // Dump request if verbose required. config.DumpResponseIfRequired("Microcks for getting Keycloak config", resp, true) body, err := io.ReadAll(resp.Body) @@ -236,12 +266,10 @@ func (c *microcksClient) GetKeycloakURL() (string, error) { panic(err) } - // Retrieve auth server url and realm name. enabled := configResp["enabled"].(bool) authServerURL := configResp["auth-server-url"].(string) realmName := configResp["realm"].(string) - // Return a proper URL or 'null' if Keycloak is disables. if enabled { return authServerURL + "/realms/" + realmName + "/", nil } @@ -250,7 +278,6 @@ func (c *microcksClient) GetKeycloakURL() (string, error) { func (c *microcksClient) refreshAuthToken(localCfg *config.LocalConfig, ctxName, configPath string) error { if c.RefreshToken == "" { - // If we have no refresh token, there's no point in doing anything return nil } configCtx, err := localCfg.ResolveContext(ctxName) @@ -264,7 +291,6 @@ func (c *microcksClient) refreshAuthToken(localCfg *config.LocalConfig, ctxName, return err } if claims.Valid() == nil { - // token is still valid return nil } @@ -319,11 +345,9 @@ func (c *microcksClient) SetOAuthToken(oauthToken string) { } func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error) { - // Ensure we have a correct URL. rel := &url.URL{Path: "tests"} u := c.APIURL.ResolveReference(rel) - // Prepare an input string as body. var input = "{" input += ("\"serviceId\": \"" + serviceID + "\", ") input += ("\"testEndpoint\": \"" + testEndpoint + "\", ") @@ -353,7 +377,6 @@ func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+c.AuthToken) - // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for creating test", req, true) resp, err := c.httpClient.Do(req) @@ -362,7 +385,6 @@ func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, } defer resp.Body.Close() - // Dump response if verbose required. config.DumpResponseIfRequired("Microcks for creating test", resp, true) body, err := io.ReadAll(resp.Body) @@ -380,7 +402,6 @@ func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, } func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, error) { - // Ensure we have a correct URL. rel := &url.URL{Path: "tests/" + testResultID} u := c.APIURL.ResolveReference(rel) @@ -392,7 +413,6 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+c.AuthToken) - // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for getting status", req, false) resp, err := c.httpClient.Do(req) @@ -401,7 +421,6 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, } defer resp.Body.Close() - // Dump response if verbose required. config.DumpResponseIfRequired("Microcks for getting status test", resp, true) body, err := io.ReadAll(resp.Body) @@ -417,8 +436,42 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, return &result, nil } +func (c *microcksClient) GetFullTestResult(testResultID string) (*TestResult, error) { + rel := &url.URL{Path: "tests/" + testResultID} + u := c.APIURL.ResolveReference(rel) + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AuthToken) + + config.DumpRequestIfRequired("Microcks for getting full test result", req, false) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + config.DumpResponseIfRequired("Microcks for getting full test result", resp, true) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + result := TestResult{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse full test result response: %w", err) + } + + return &result, nil +} + func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifact bool) (string, error) { - // Ensure file exists on fs. file, err := os.Open(specificationFilePath) if err != nil { return "", err @@ -455,7 +508,6 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa errCh <- writer.Close() }() - // Ensure we have a correct URL. rel := &url.URL{Path: "artifact/upload"} u := c.APIURL.ResolveReference(rel) @@ -466,7 +518,6 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+c.AuthToken) - // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for uploading artifact", req, true) resp, err := c.httpClient.Do(req) @@ -488,7 +539,6 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa return "", fmt.Errorf("failed to read upload response: %w", err) } - // Raise exception if not created. if resp.StatusCode != 201 { return "", errs.New(string(respBody)) } @@ -497,12 +547,9 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa } func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, secret string) (string, error) { - - // create Multipart Form to add fields body := &bytes.Buffer{} writer := multipart.NewWriter(body) - // Add all the form fields writer.WriteField("url", artifactURL) writer.WriteField("mainArtifact", strconv.FormatBool(mainArtifact)) if secret != "" { @@ -514,7 +561,6 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, return "", err } - // Ensure we have a correct URL. rel := &url.URL{Path: "artifact/download"} u := c.APIURL.ResolveReference(rel) @@ -525,7 +571,6 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+c.AuthToken) - // Dump request if verbose required. config.DumpRequestIfRequired("Microcks for uploading artifact", req, true) resp, err := c.httpClient.Do(req) @@ -534,7 +579,6 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, } defer resp.Body.Close() - // Dump response if verbose required. config.DumpResponseIfRequired("Microcks for uploading artifact", resp, true) respBody, err := io.ReadAll(resp.Body) @@ -542,7 +586,6 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, panic(err.Error()) } - // Raise exception if not created. if resp.StatusCode != 201 { return "", errs.New(string(respBody)) } @@ -551,7 +594,6 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, } func ensureValidOperationsList(filteredOperations string) bool { - // Unmarshal using a generic interface var list = []string{} err := json.Unmarshal([]byte(filteredOperations), &list) if err != nil { @@ -562,7 +604,6 @@ func ensureValidOperationsList(filteredOperations string) bool { } func ensureValidOperationsHeaders(operationsHeaders string) bool { - // Unmarshal using a generic interface var headers = map[string][]HeaderDTO{} err := json.Unmarshal([]byte(operationsHeaders), &headers) if err != nil { @@ -584,4 +625,4 @@ func ensureValidOAuth2Context(oAuth2Context string) bool { return false } return true -} +} \ No newline at end of file diff --git a/pkg/output/formatter.go b/pkg/output/formatter.go new file mode 100644 index 0000000..4481e16 --- /dev/null +++ b/pkg/output/formatter.go @@ -0,0 +1,33 @@ +package output + +import ( + "fmt" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +type Formatter interface { + FormatTestResult(result *connectors.TestResult) string + FormatTestCaseResult(testCase *connectors.TestCaseResult) string +} + +type OutputFormat string + +const ( + OutputFormatText OutputFormat = "text" + OutputFormatJSON OutputFormat = "json" + OutputFormatYAML OutputFormat = "yaml" +) + +func NewFormatter(format OutputFormat) (Formatter, error) { + switch format { + case OutputFormatText: + return NewTextFormatter(), nil + case OutputFormatJSON: + return NewJSONFormatter(), nil + case OutputFormatYAML: + return NewYAMLFormatter(), nil + default: + return nil, fmt.Errorf("unsupported output format %q", format) + } +} diff --git a/pkg/output/json_formatter.go b/pkg/output/json_formatter.go new file mode 100644 index 0000000..8bc989f --- /dev/null +++ b/pkg/output/json_formatter.go @@ -0,0 +1,30 @@ +package output + +import ( + "encoding/json" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +type JSONFormatter struct{} + +func NewJSONFormatter() *JSONFormatter { + return &JSONFormatter{} +} + +func (f *JSONFormatter) FormatTestResult(result *connectors.TestResult) string { + formattedResult := NewTestResultJSON(result) + bytes, err := json.MarshalIndent(formattedResult, "", " ") + if err != nil { + return "{}" + } + return string(bytes) +} + +func (f *JSONFormatter) FormatTestCaseResult(testCase *connectors.TestCaseResult) string { + bytes, err := json.MarshalIndent(testCase, "", " ") + if err != nil { + return "{}" + } + return string(bytes) +} diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go new file mode 100644 index 0000000..04293de --- /dev/null +++ b/pkg/output/output_test.go @@ -0,0 +1,221 @@ +package output + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/microcks/microcks-cli/pkg/connectors" + "gopkg.in/yaml.v2" +) + +func TestTextFormatter_FormatTestResult(t *testing.T) { + formatter := NewTextFormatter() + + result := &connectors.TestResult{ + ID: "test-123", + Success: true, + TestCases: []connectors.TestCaseResult{ + { + Success: true, + OperationName: "GET /api/users", + TestStepResults: []connectors.TestStepResult{ + { + Success: true, + RequestName: "Test Case 1", + Message: "", + }, + }, + }, + }, + } + + output := formatter.FormatTestResult(result) + if output != "" && !strings.Contains(output, "success: true") { + t.Errorf("TextFormatter should include success status, got: %s", output) + } +} + +func TestTextFormatter_FormatTestResult_WithFailures(t *testing.T) { + formatter := NewTextFormatter() + + result := &connectors.TestResult{ + ID: "test-456", + Success: false, + TestCases: []connectors.TestCaseResult{ + { + Success: false, + OperationName: "POST /api/users", + TestStepResults: []connectors.TestStepResult{ + { + Success: false, + RequestName: "Invalid Request", + Message: "expected status 201, got 400", + }, + }, + }, + }, + } + + output := formatter.FormatTestResult(result) + if !strings.Contains(output, "POST /api/users") { + t.Errorf("TextFormatter should include operation name, got: %s", output) + } + if !strings.Contains(output, "expected status 201, got 400") { + t.Errorf("TextFormatter should include failure message, got: %s", output) + } +} + +func TestJSONFormatter_FormatTestResult(t *testing.T) { + formatter := NewJSONFormatter() + + result := &connectors.TestResult{ + ID: "test-789", + Version: 1, + TestNumber: 1, + Success: true, + TestCases: []connectors.TestCaseResult{ + { + Success: true, + OperationName: "GET /api/health", + TestStepResults: []connectors.TestStepResult{ + { + Success: true, + RequestName: "Health Check", + ElapsedTime: 50, + }, + }, + }, + }, + } + + output := formatter.FormatTestResult(result) + + var parsed TestResultJSON + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + t.Errorf("JSONFormatter output should be valid JSON: %v", err) + } + + if parsed.ID != "test-789" { + t.Errorf("JSONFormatter should preserve ID field, got: %s", parsed.ID) + } + if len(parsed.TestCases) != 1 { + t.Errorf("JSONFormatter should include testCases, got: %d", len(parsed.TestCases)) + } +} + +func TestYAMLFormatter_FormatTestResult(t *testing.T) { + formatter := NewYAMLFormatter() + + result := &connectors.TestResult{ + ID: "test-yaml-001", + Version: 1, + TestNumber: 11, + TestDate: 1700000000, + TestedEndpoint: "https://example.org/api", + ServiceID: "svc-123", + ElapsedTime: 99, + Success: true, + InProgress: false, + RunnerType: "HTTP", + TestCases: []connectors.TestCaseResult{ + { + Success: true, + OperationName: "GET /health", + TestStepResults: []connectors.TestStepResult{ + { + Success: true, + RequestName: "check", + Message: "ok", + }, + }, + }, + }, + } + + output := formatter.FormatTestResult(result) + + var parsed TestResultJSON + if err := yaml.Unmarshal([]byte(output), &parsed); err != nil { + t.Errorf("YAMLFormatter output should be valid YAML: %v", err) + } + if parsed.ID != "test-yaml-001" { + t.Errorf("YAMLFormatter should preserve ID field, got: %s", parsed.ID) + } + if len(parsed.TestCases) != 1 { + t.Errorf("YAMLFormatter should include testCaseResults, got: %d", len(parsed.TestCases)) + } +} + +func TestJSONFormatter_FormatTestCaseResult(t *testing.T) { + formatter := NewJSONFormatter() + + testCase := &connectors.TestCaseResult{ + Success: false, + ElapsedTime: 100, + OperationName: "DELETE /api/users/1", + TestStepResults: []connectors.TestStepResult{ + { + Success: false, + Message: "connection refused", + }, + }, + } + + output := formatter.FormatTestCaseResult(testCase) + + var parsed connectors.TestCaseResult + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + t.Errorf("JSONFormatter output should be valid JSON: %v", err) + } + + if parsed.Success { + t.Errorf("JSONFormatter should preserve success=false") + } + if !strings.Contains(output, "DELETE /api/users/1") { + t.Errorf("JSONFormatter should include operationName, got: %s", output) + } +} + +func TestNewTextFormatter_ReturnsPointer(t *testing.T) { + formatter := NewTextFormatter() + if formatter == nil { + t.Error("NewTextFormatter should not return nil") + } +} + +func TestNewJSONFormatter_ReturnsPointer(t *testing.T) { + formatter := NewJSONFormatter() + if formatter == nil { + t.Error("NewJSONFormatter should not return nil") + } +} + +func TestNewYAMLFormatter_ReturnsPointer(t *testing.T) { + formatter := NewYAMLFormatter() + if formatter == nil { + t.Error("NewYAMLFormatter should not return nil") + } +} + +func TestNewFormatter(t *testing.T) { + formatter, err := NewFormatter(OutputFormatText) + if err != nil || formatter == nil { + t.Fatalf("NewFormatter text should return formatter, err=%v", err) + } + + formatter, err = NewFormatter(OutputFormatJSON) + if err != nil || formatter == nil { + t.Fatalf("NewFormatter json should return formatter, err=%v", err) + } + + formatter, err = NewFormatter(OutputFormatYAML) + if err != nil || formatter == nil { + t.Fatalf("NewFormatter yaml should return formatter, err=%v", err) + } + + formatter, err = NewFormatter(OutputFormat("csv")) + if err == nil || formatter != nil { + t.Fatalf("NewFormatter should fail on unknown format") + } +} diff --git a/pkg/output/test_result_json.go b/pkg/output/test_result_json.go new file mode 100644 index 0000000..c66a154 --- /dev/null +++ b/pkg/output/test_result_json.go @@ -0,0 +1,69 @@ +package output + +import "github.com/microcks/microcks-cli/pkg/connectors" + +// TestResultJSON is a serialized view of connectors.TestResult. +type TestResultJSON struct { + ID string `json:"id" yaml:"id"` + Version int32 `json:"version" yaml:"version"` + TestNumber int32 `json:"testNumber" yaml:"testNumber"` + TestDate int64 `json:"testDate" yaml:"testDate"` + TestedEndpoint string `json:"testedEndpoint" yaml:"testedEndpoint"` + ServiceID string `json:"serviceId" yaml:"serviceId"` + ElapsedTime int64 `json:"elapsedTime" yaml:"elapsedTime"` + Success bool `json:"success" yaml:"success"` + InProgress bool `json:"inProgress" yaml:"inProgress"` + RunnerType string `json:"runnerType" yaml:"runnerType"` + TestCases []TestCaseResultJSON `json:"testCaseResults" yaml:"testCaseResults"` +} + +type TestCaseResultJSON struct { + Success bool `json:"success" yaml:"success"` + ElapsedTime int64 `json:"elapsedTime" yaml:"elapsedTime"` + OperationName string `json:"operationName" yaml:"operationName"` + TestStepResults []TestStepResultJSON `json:"testStepResults" yaml:"testStepResults"` +} + +type TestStepResultJSON struct { + Success bool `json:"success" yaml:"success"` + ElapsedTime int64 `json:"elapsedTime" yaml:"elapsedTime"` + RequestName string `json:"requestName" yaml:"requestName"` + Message string `json:"message" yaml:"message"` + EventMessageName string `json:"eventMessageName" yaml:"eventMessageName"` +} + +func NewTestResultJSON(result *connectors.TestResult) TestResultJSON { + testCases := make([]TestCaseResultJSON, 0, len(result.TestCases)) + for _, tc := range result.TestCases { + steps := make([]TestStepResultJSON, 0, len(tc.TestStepResults)) + for _, step := range tc.TestStepResults { + steps = append(steps, TestStepResultJSON{ + Success: step.Success, + ElapsedTime: step.ElapsedTime, + RequestName: step.RequestName, + Message: step.Message, + EventMessageName: step.EventMessageName, + }) + } + testCases = append(testCases, TestCaseResultJSON{ + Success: tc.Success, + ElapsedTime: tc.ElapsedTime, + OperationName: tc.OperationName, + TestStepResults: steps, + }) + } + + return TestResultJSON{ + ID: result.ID, + Version: result.Version, + TestNumber: result.TestNumber, + TestDate: result.TestDate, + TestedEndpoint: result.TestedEndpoint, + ServiceID: result.ServiceID, + ElapsedTime: result.ElapsedTime, + Success: result.Success, + InProgress: result.InProgress, + RunnerType: result.RunnerType, + TestCases: testCases, + } +} diff --git a/pkg/output/text_formatter.go b/pkg/output/text_formatter.go new file mode 100644 index 0000000..bef3164 --- /dev/null +++ b/pkg/output/text_formatter.go @@ -0,0 +1,38 @@ +package output + +import ( + "fmt" + "strings" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +type TextFormatter struct{} + +func NewTextFormatter() *TextFormatter { + return &TextFormatter{} +} + +func (f *TextFormatter) FormatTestResult(result *connectors.TestResult) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("Test \"%s\" success: %v\n", result.ID, result.Success)) + + for _, tc := range result.TestCases { + sb.WriteString(f.FormatTestCaseResult(&tc)) + } + + return sb.String() +} + +func (f *TextFormatter) FormatTestCaseResult(testCase *connectors.TestCaseResult) string { + var sb strings.Builder + + for _, step := range testCase.TestStepResults { + if step.Message != "" { + sb.WriteString(fmt.Sprintf("[%s] %s: %s\n", testCase.OperationName, step.RequestName, step.Message)) + } + } + + return sb.String() +} \ No newline at end of file diff --git a/pkg/output/yaml_formatter.go b/pkg/output/yaml_formatter.go new file mode 100644 index 0000000..ebba2d5 --- /dev/null +++ b/pkg/output/yaml_formatter.go @@ -0,0 +1,29 @@ +package output + +import ( + "github.com/microcks/microcks-cli/pkg/connectors" + "gopkg.in/yaml.v2" +) + +type YAMLFormatter struct{} + +func NewYAMLFormatter() *YAMLFormatter { + return &YAMLFormatter{} +} + +func (f *YAMLFormatter) FormatTestResult(result *connectors.TestResult) string { + formattedResult := NewTestResultJSON(result) + bytes, err := yaml.Marshal(formattedResult) + if err != nil { + return "{}" + } + return string(bytes) +} + +func (f *YAMLFormatter) FormatTestCaseResult(testCase *connectors.TestCaseResult) string { + bytes, err := yaml.Marshal(testCase) + if err != nil { + return "{}" + } + return string(bytes) +} From 0acd8a6f8302327fa6bcc7dbccc85353988d3743 Mon Sep 17 00:00:00 2001 From: cotishq Date: Fri, 15 May 2026 01:08:13 +0530 Subject: [PATCH 2/2] refactor: centralize output stream routing and fix ignore patterns Signed-off-by: cotishq --- .gitignore | 3 ++- cmd/cmd.go | 1 + cmd/test.go | 27 +++++++-------------------- pkg/connectors/microcks_client.go | 17 +++++++++-------- pkg/output/formatter.go | 21 +++++++++++++++++++++ pkg/output/output_test.go | 31 +++++++++++++++++++++++++------ 6 files changed, 65 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index ced4a89..982b29a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ build/dist .DS_Store # test data files -**/testdata/**.gocache/ +**/testdata/** +.gocache/ diff --git a/cmd/cmd.go b/cmd/cmd.go index 16df6b9..5f1e243 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -59,6 +59,7 @@ func NewCommad() *cobra.Command { command.PersistentFlags().StringVar(&clientOpts.ClientId, "keycloakClientId", "", "Keycloak Realm Service Account ClientId") command.PersistentFlags().StringVar(&clientOpts.ClientSecret, "keycloakClientSecret", "", "Keycloak Realm Service Account ClientSecret") command.PersistentFlags().StringVar(&clientOpts.ServerAddr, "microcksURL", "", "Microcks API URL") + command.PersistentFlags().StringVar(&clientOpts.OutputFormat, "output", "text", "Output format: text, json, or yaml") command.MarkFlagsRequiredTogether("keycloakClientId", "keycloakClientSecret") return command diff --git a/cmd/test.go b/cmd/test.go index 07f703e..4201bf8 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -40,7 +40,6 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { filteredOperations string operationsHeaders string oAuth2Context string - outputFormat string ) var testCmd = &cobra.Command{ @@ -79,11 +78,12 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { string(output.OutputFormatJSON): true, string(output.OutputFormatYAML): true, } - if !validOutputFormats[outputFormat] { + if !validOutputFormats[globalClientOpts.OutputFormat] { fmt.Println("--output format is wrong. Accepted values are: text, json, yaml") os.Exit(1) } - isTextOutput := outputFormat == string(output.OutputFormatText) + isTextOutput := globalClientOpts.OutputFormat == string(output.OutputFormatText) + outputWriter := output.NewWriter(output.OutputFormat(globalClientOpts.OutputFormat)) if !strings.HasSuffix(waitFor, "milli") && !strings.HasSuffix(waitFor, "sec") && !strings.HasSuffix(waitFor, "min") { fmt.Println("--waitFor format is wrong. Accepted units are: milli, sec, min (e.g. 500milli, 30sec, 5min)") @@ -194,29 +194,17 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { } success = testResultSummary.Success inProgress := testResultSummary.InProgress - if isTextOutput { - fmt.Printf("MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)) - } else { - fmt.Fprintf(os.Stderr, "MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)) - } + outputWriter.Progressf("MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)) if !inProgress { break } - if isTextOutput { - fmt.Println("MicrocksTester waiting for 2 seconds before checking again or exiting.") - } else { - fmt.Fprintln(os.Stderr, "MicrocksTester waiting for 2 seconds before checking again or exiting.") - } + outputWriter.Progressf("MicrocksTester waiting for 2 seconds before checking again or exiting.\n") time.Sleep(2 * time.Second) } - if isTextOutput { - fmt.Printf("Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) - } else { - fmt.Fprintf(os.Stderr, "Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) - } + outputWriter.Infof("Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) if !isTextOutput { fullResult, err := mc.GetFullTestResult(testResultID) @@ -224,7 +212,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { fmt.Printf("Got error when retrieving full test result: %s", err) os.Exit(1) } - formatter, err := output.NewFormatter(output.OutputFormat(outputFormat)) + formatter, err := output.NewFormatter(output.OutputFormat(globalClientOpts.OutputFormat)) if err != nil { fmt.Printf("Got error when selecting output formatter: %s", err) os.Exit(1) @@ -246,7 +234,6 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { testCmd.Flags().StringVar(&filteredOperations, "filteredOperations", "", "List of operations to launch a test for") testCmd.Flags().StringVar(&operationsHeaders, "operationsHeaders", "", "Override of operations headers as JSON string") testCmd.Flags().StringVar(&oAuth2Context, "oAuth2Context", "", "Spec of an OAuth2 client context as JSON string") - testCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text, json, or yaml") return testCmd } diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index 631285c..2a31f45 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -85,18 +85,18 @@ type TestResult struct { // TestCaseResult represents results for a single operation/action within a test type TestCaseResult struct { - Success bool `json:"success"` - ElapsedTime int64 `json:"elapsedTime"` - OperationName string `json:"operationName"` + Success bool `json:"success"` + ElapsedTime int64 `json:"elapsedTime"` + OperationName string `json:"operationName"` TestStepResults []TestStepResult `json:"testStepResults"` } // TestStepResult represents results for a single request/message within a test case type TestStepResult struct { - Success bool `json:"success"` - ElapsedTime int64 `json:"elapsedTime"` - RequestName string `json:"requestName"` - Message string `json:"message"` + Success bool `json:"success"` + ElapsedTime int64 `json:"elapsedTime"` + RequestName string `json:"requestName"` + Message string `json:"message"` EventMessageName string `json:"eventMessageName"` } @@ -123,6 +123,7 @@ type ClientOptions struct { Context string ConfigPath string AuthToken string + OutputFormat string InsecureTLS bool Verbose bool CaCertPaths string @@ -625,4 +626,4 @@ func ensureValidOAuth2Context(oAuth2Context string) bool { return false } return true -} \ No newline at end of file +} diff --git a/pkg/output/formatter.go b/pkg/output/formatter.go index 4481e16..5ac7b7e 100644 --- a/pkg/output/formatter.go +++ b/pkg/output/formatter.go @@ -2,6 +2,8 @@ package output import ( "fmt" + "io" + "os" "github.com/microcks/microcks-cli/pkg/connectors" ) @@ -31,3 +33,22 @@ func NewFormatter(format OutputFormat) (Formatter, error) { return nil, fmt.Errorf("unsupported output format %q", format) } } + +type Writer struct { + out io.Writer +} + +func NewWriter(format OutputFormat) *Writer { + if format == OutputFormatText { + return &Writer{out: os.Stdout} + } + return &Writer{out: os.Stderr} +} + +func (w *Writer) Infof(format string, args ...any) { + fmt.Fprintf(w.out, format, args...) +} + +func (w *Writer) Progressf(format string, args ...any) { + fmt.Fprintf(w.out, format, args...) +} diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go index 04293de..1d75a1c 100644 --- a/pkg/output/output_test.go +++ b/pkg/output/output_test.go @@ -1,6 +1,7 @@ package output import ( + "bytes" "encoding/json" "strings" "testing" @@ -13,8 +14,8 @@ func TestTextFormatter_FormatTestResult(t *testing.T) { formatter := NewTextFormatter() result := &connectors.TestResult{ - ID: "test-123", - Success: true, + ID: "test-123", + Success: true, TestCases: []connectors.TestCaseResult{ { Success: true, @@ -70,10 +71,10 @@ func TestJSONFormatter_FormatTestResult(t *testing.T) { formatter := NewJSONFormatter() result := &connectors.TestResult{ - ID: "test-789", - Version: 1, - TestNumber: 1, - Success: true, + ID: "test-789", + Version: 1, + TestNumber: 1, + Success: true, TestCases: []connectors.TestCaseResult{ { Success: true, @@ -219,3 +220,21 @@ func TestNewFormatter(t *testing.T) { t.Fatalf("NewFormatter should fail on unknown format") } } + +func TestWriterRouting(t *testing.T) { + textWriter := NewWriter(OutputFormatText) + var textBuf bytes.Buffer + textWriter.out = &textBuf + textWriter.Infof("hello %s", "text") + if textBuf.String() != "hello text" { + t.Fatalf("text writer should write to stdout buffer, got %q", textBuf.String()) + } + + jsonWriter := NewWriter(OutputFormatJSON) + var jsonBuf bytes.Buffer + jsonWriter.out = &jsonBuf + jsonWriter.Progressf("hello %s", "json") + if jsonBuf.String() != "hello json" { + t.Fatalf("json writer should write to stderr buffer, got %q", jsonBuf.String()) + } +}