From 2969a4cdcdba3213cfd9edd72038f60211acab63 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Fri, 26 Jun 2026 11:42:28 +0100 Subject: [PATCH 1/6] feat(connectors): add GetFullTestResult with per-operation test detail Signed-off-by: caesarsage --- pkg/connectors/microcks_client.go | 69 +++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index 6021a3c..dd77c22 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,37 @@ type TestResultSummary struct { InProgress bool `json:"inProgress"` } +// TestResult represents a full Microcks TestResult including per-operation detail. +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 int32 `json:"elapsedTime"` + Success bool `json:"success"` + InProgress bool `json:"inProgress"` + TestCaseResults []TestCaseResult `json:"testCaseResults"` +} + +// TestCaseResult is the result for a single operation within a TestResult. +type TestCaseResult struct { + Success bool `json:"success"` + ElapsedTime int32 `json:"elapsedTime"` + OperationName string `json:"operationName"` + TestStepResults []TestStepResult `json:"testStepResults"` +} + +// TestStepResult is the result for a single request/message within a TestCaseResult. +type TestStepResult struct { + Success bool `json:"success"` + ElapsedTime int32 `json:"elapsedTime"` + RequestName string `json:"requestName"` + EventMessageName string `json:"eventMessageName"` + Message string `json:"message"` +} + // HeaderDTO represents an operation header passed for Test type HeaderDTO struct { Name string `json:"name"` @@ -436,6 +468,43 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, return &result, nil } +// GetFullTestResult fetches the complete TestResult including per-operation +// (testCaseResults) detail, used by the richer --output formatters. +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) From 6bd82fcfd86df58843ccd151218eee631bf0c7b6 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Fri, 26 Jun 2026 11:42:28 +0100 Subject: [PATCH 2/6] feat(output): add output formatter framework (text/json/yaml/github-actions) Signed-off-by: caesarsage --- pkg/output/formatter.go | 68 +++++++++++ pkg/output/github_actions_formatter.go | 128 ++++++++++++++++++++ pkg/output/json_formatter.go | 33 ++++++ pkg/output/output_test.go | 155 +++++++++++++++++++++++++ pkg/output/text_formatter.go | 51 ++++++++ pkg/output/yaml_formatter.go | 43 +++++++ 6 files changed, 478 insertions(+) create mode 100644 pkg/output/formatter.go create mode 100644 pkg/output/github_actions_formatter.go create mode 100644 pkg/output/json_formatter.go create mode 100644 pkg/output/output_test.go create mode 100644 pkg/output/text_formatter.go create mode 100644 pkg/output/yaml_formatter.go diff --git a/pkg/output/formatter.go b/pkg/output/formatter.go new file mode 100644 index 0000000..2f3dfcd --- /dev/null +++ b/pkg/output/formatter.go @@ -0,0 +1,68 @@ +/* + * Copyright The Microcks 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 output renders a completed Microcks TestResult in a selectable format +// (text, json, yaml, github-actions) for the `microcks test --output` flag. +package output + +import ( + "fmt" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +// OutputFormat is a supported value of the --output flag. +type OutputFormat string + +const ( + FormatText OutputFormat = "text" + FormatJSON OutputFormat = "json" + FormatYAML OutputFormat = "yaml" + FormatGitHubActions OutputFormat = "github-actions" +) + +// Formatter renders a completed TestResult for a chosen output target. The +// returned string is written to stdout by the caller; formatters that also have +// side effects (e.g. github-actions writing the job step summary) perform them +// during Format. +type Formatter interface { + Format(result *connectors.TestResult) (string, error) +} + +// NewFormatter returns the Formatter for the given format. +func NewFormatter(format OutputFormat) (Formatter, error) { + switch format { + case FormatText: + return &TextFormatter{}, nil + case FormatJSON: + return &JSONFormatter{}, nil + case FormatYAML: + return &YAMLFormatter{}, nil + case FormatGitHubActions: + return &GitHubActionsFormatter{}, nil + default: + return nil, fmt.Errorf("unsupported output format %q (use: text, json, yaml, github-actions)", format) + } +} + +// IsValid reports whether s is a supported output format. +func IsValid(s string) bool { + switch OutputFormat(s) { + case FormatText, FormatJSON, FormatYAML, FormatGitHubActions: + return true + } + return false +} diff --git a/pkg/output/github_actions_formatter.go b/pkg/output/github_actions_formatter.go new file mode 100644 index 0000000..107c0e6 --- /dev/null +++ b/pkg/output/github_actions_formatter.go @@ -0,0 +1,128 @@ +/* + * Copyright The Microcks 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 output + +import ( + "fmt" + "os" + "strings" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +// GitHubActionsFormatter renders the result as GitHub Actions workflow commands: +// a collapsible ::group:: per operation, ::error:: annotations for failures +// (and ::notice:: for passes when MICROCKS_ACTIONS_VERBOSE is set), plus a +// markdown table appended to $GITHUB_STEP_SUMMARY. +type GitHubActionsFormatter struct{} + +func (f *GitHubActionsFormatter) Format(r *connectors.TestResult) (string, error) { + verbose := os.Getenv("MICROCKS_ACTIONS_VERBOSE") != "" + + var b strings.Builder + for _, tc := range r.TestCaseResults { + icon := "✅" + if !tc.Success { + icon = "❌" + } + fmt.Fprintf(&b, "::group::%s %s\n", icon, tc.OperationName) + for _, s := range tc.TestStepResults { + switch { + case !s.Success: + fmt.Fprintf(&b, "::error title=%s::%s\n", + escapeProperty(tc.OperationName), escapeData(stepMessage(s))) + case verbose: + fmt.Fprintf(&b, "::notice title=%s::%s passed\n", + escapeProperty(tc.OperationName), escapeData(s.RequestName)) + } + } + fmt.Fprintf(&b, "::endgroup::\n") + } + + if r.Success { + fmt.Fprintf(&b, "::notice title=Microcks contract test::All %d operation(s) conform to the contract\n", + len(r.TestCaseResults)) + } else { + fmt.Fprintf(&b, "::error title=Microcks contract test::Contract test failed - see annotations above\n") + } + + if err := writeStepSummary(r); err != nil { + // The step summary is best-effort; never fail the run over it. + fmt.Fprintf(&b, "::warning::could not write GITHUB_STEP_SUMMARY: %s\n", escapeData(err.Error())) + } + + return b.String(), nil +} + +// stepMessage returns the failure message, or a sensible default when empty. +func stepMessage(s connectors.TestStepResult) string { + if strings.TrimSpace(s.Message) != "" { + return s.Message + } + if s.RequestName != "" { + return s.RequestName + " did not conform to the contract" + } + return "did not conform to the contract" +} + +// writeStepSummary appends a per-operation markdown table to the GitHub job +// summary file, if GITHUB_STEP_SUMMARY is set. +func writeStepSummary(r *connectors.TestResult) error { + path := os.Getenv("GITHUB_STEP_SUMMARY") + if path == "" { + return nil + } + + var b strings.Builder + b.WriteString("## Microcks contract test\n\n") + b.WriteString(fmt.Sprintf("**Overall:** %s\n\n", passFail(r.Success))) + b.WriteString("| Operation | Result |\n| --- | --- |\n") + for _, tc := range r.TestCaseResults { + b.WriteString(fmt.Sprintf("| %s | %s |\n", tc.OperationName, passFail(tc.Success))) + } + b.WriteString("\n") + + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644) + if err != nil { + return err + } + defer file.Close() + _, err = file.WriteString(b.String()) + return err +} + +func passFail(ok bool) string { + if ok { + return "✅ pass" + } + return "❌ fail" +} + +// escapeData escapes a GitHub Actions command message per the workflow-command spec. +func escapeData(s string) string { + s = strings.ReplaceAll(s, "%", "%25") + s = strings.ReplaceAll(s, "\r", "%0D") + s = strings.ReplaceAll(s, "\n", "%0A") + return s +} + +// escapeProperty escapes a GitHub Actions command property value. +func escapeProperty(s string) string { + s = escapeData(s) + s = strings.ReplaceAll(s, ":", "%3A") + s = strings.ReplaceAll(s, ",", "%2C") + return s +} diff --git a/pkg/output/json_formatter.go b/pkg/output/json_formatter.go new file mode 100644 index 0000000..0a35941 --- /dev/null +++ b/pkg/output/json_formatter.go @@ -0,0 +1,33 @@ +/* + * Copyright The Microcks 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 output + +import ( + "encoding/json" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +// JSONFormatter renders the test result as indented JSON. +type JSONFormatter struct{} + +func (f *JSONFormatter) Format(r *connectors.TestResult) (string, error) { + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go new file mode 100644 index 0000000..2bd7698 --- /dev/null +++ b/pkg/output/output_test.go @@ -0,0 +1,155 @@ +/* + * Copyright The Microcks 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 output + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +func sampleResult() *connectors.TestResult { + return &connectors.TestResult{ + ID: "abc123", + Success: false, + ElapsedTime: 1500, + TestCaseResults: []connectors.TestCaseResult{ + {Success: true, OperationName: "GET /products", TestStepResults: []connectors.TestStepResult{ + {Success: true, RequestName: "all"}, + }}, + {Success: false, OperationName: "POST /orders", TestStepResults: []connectors.TestStepResult{ + {Success: false, RequestName: "new", Message: "price: expected number\ngot string"}, + }}, + }, + } +} + +func TestNewFormatterAndIsValid(t *testing.T) { + for _, f := range []string{"text", "json", "yaml", "github-actions"} { + if !IsValid(f) { + t.Errorf("expected %q to be valid", f) + } + if _, err := NewFormatter(OutputFormat(f)); err != nil { + t.Errorf("NewFormatter(%q) errored: %v", f, err) + } + } + if IsValid("xml") { + t.Error("expected xml to be invalid") + } + if _, err := NewFormatter("xml"); err == nil { + t.Error("expected NewFormatter(xml) to error") + } +} + +func TestTextFormatter(t *testing.T) { + out, err := (&TextFormatter{}).Format(sampleResult()) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"FAILURE", "[PASS] GET /products", "[FAIL] POST /orders", "price: expected number"} { + if !strings.Contains(out, want) { + t.Errorf("text output missing %q\n%s", want, out) + } + } +} + +func TestJSONFormatter(t *testing.T) { + out, err := (&JSONFormatter{}).Format(sampleResult()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"id": "abc123"`) || !strings.Contains(out, `"operationName": "POST /orders"`) { + t.Errorf("json output unexpected:\n%s", out) + } +} + +func TestYAMLFormatter(t *testing.T) { + out, err := (&YAMLFormatter{}).Format(sampleResult()) + if err != nil { + t.Fatal(err) + } + // Keys should be camelCase (json field names), not Go field names. + if !strings.Contains(out, "id: abc123") || !strings.Contains(out, "testCaseResults:") { + t.Errorf("yaml output unexpected:\n%s", out) + } +} + +func TestGitHubActionsFormatter(t *testing.T) { + out, err := (&GitHubActionsFormatter{}).Format(sampleResult()) + if err != nil { + t.Fatal(err) + } + checks := []string{ + "::group::", + "GET /products", + "::error title=POST /orders::", + "price: expected number%0Agot string", // newline escaped in data; colon only escaped in properties + "::endgroup::", + "::error title=Microcks contract test::", + } + for _, want := range checks { + if !strings.Contains(out, want) { + t.Errorf("github-actions output missing %q\n%s", want, out) + } + } + // No ::notice:: for the passing op unless verbose. + if strings.Contains(out, "::notice title=GET /products::") { + t.Errorf("unexpected ::notice:: without verbose:\n%s", out) + } +} + +func TestGitHubActionsVerbose(t *testing.T) { + t.Setenv("MICROCKS_ACTIONS_VERBOSE", "1") + out, err := (&GitHubActionsFormatter{}).Format(sampleResult()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "::notice title=GET /products::") { + t.Errorf("expected ::notice:: for passing op in verbose mode:\n%s", out) + } +} + +func TestGitHubActionsStepSummary(t *testing.T) { + summary := filepath.Join(t.TempDir(), "summary.md") + t.Setenv("GITHUB_STEP_SUMMARY", summary) + + if _, err := (&GitHubActionsFormatter{}).Format(sampleResult()); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(summary) + if err != nil { + t.Fatalf("step summary not written: %v", err) + } + s := string(data) + for _, want := range []string{"## Microcks contract test", "| Operation | Result |", "GET /products", "POST /orders"} { + if !strings.Contains(s, want) { + t.Errorf("step summary missing %q\n%s", want, s) + } + } +} + +func TestEscaping(t *testing.T) { + if got := escapeData("a%b\nc\rd"); got != "a%25b%0Ac%0Dd" { + t.Errorf("escapeData = %q", got) + } + if got := escapeProperty("a:b,c"); got != "a%3Ab%2Cc" { + t.Errorf("escapeProperty = %q", got) + } +} diff --git a/pkg/output/text_formatter.go b/pkg/output/text_formatter.go new file mode 100644 index 0000000..effab02 --- /dev/null +++ b/pkg/output/text_formatter.go @@ -0,0 +1,51 @@ +/* + * Copyright The Microcks 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 output + +import ( + "fmt" + "strings" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +// TextFormatter renders a human-readable summary of the test result. +type TextFormatter struct{} + +func (f *TextFormatter) Format(r *connectors.TestResult) (string, error) { + var b strings.Builder + + status := "SUCCESS" + if !r.Success { + status = "FAILURE" + } + fmt.Fprintf(&b, "Test %s: %s (%dms)\n", r.ID, status, r.ElapsedTime) + + for _, tc := range r.TestCaseResults { + mark := "PASS" + if !tc.Success { + mark = "FAIL" + } + fmt.Fprintf(&b, " [%s] %s\n", mark, tc.OperationName) + for _, s := range tc.TestStepResults { + if !s.Success && s.Message != "" { + fmt.Fprintf(&b, " %s: %s\n", s.RequestName, s.Message) + } + } + } + + return b.String(), nil +} diff --git a/pkg/output/yaml_formatter.go b/pkg/output/yaml_formatter.go new file mode 100644 index 0000000..7371f53 --- /dev/null +++ b/pkg/output/yaml_formatter.go @@ -0,0 +1,43 @@ +/* + * Copyright The Microcks 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 output + +import ( + "encoding/json" + + "github.com/microcks/microcks-cli/pkg/connectors" + "gopkg.in/yaml.v2" +) + +// YAMLFormatter renders the test result as YAML. It round-trips through JSON so +// the keys match the JSON field names (camelCase) rather than Go field names. +type YAMLFormatter struct{} + +func (f *YAMLFormatter) Format(r *connectors.TestResult) (string, error) { + j, err := json.Marshal(r) + if err != nil { + return "", err + } + var generic interface{} + if err := json.Unmarshal(j, &generic); err != nil { + return "", err + } + b, err := yaml.Marshal(generic) + if err != nil { + return "", err + } + return string(b), nil +} From 4577165ad8b4fc1655f073bb27f98cdd8bae7e07 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Wed, 1 Jul 2026 08:52:24 +0100 Subject: [PATCH 3/6] feat(test): wire --output into runTestAndWait for server and dry-run paths Signed-off-by: caesarsage --- cmd/test.go | 13 +++++++-- cmd/testDryRun.go | 69 ++++++++++++++++++++++++--------------------- cmd/testExecutor.go | 54 ++++++++++++++++++++++++++++++++--- 3 files changed, 98 insertions(+), 38 deletions(-) diff --git a/cmd/test.go b/cmd/test.go index 6d340bb..80a65bb 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" ) @@ -45,6 +46,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { readyTimeout time.Duration watch bool driver string + outputFormat string ) var testCmd = &cobra.Command{ @@ -82,6 +84,11 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { os.Exit(1) } + if !output.IsValid(outputFormat) { + fmt.Fprintln(os.Stderr, "--output must be one of: text, json, yaml, github-actions") + os.Exit(1) + } + // Collect optional HTTPS transport flags. config.InsecureTLS = globalClientOpts.InsecureTLS config.CaCertPaths = globalClientOpts.CaCertPaths @@ -121,6 +128,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { filteredOperations: filteredOperations, operationsHeaders: operationsHeaders, oAuth2Context: oAuth2Context, + outputFormat: outputFormat, } if !dryRun { @@ -214,11 +222,11 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { success, testResultID, err := runTestAndWait(mc, params) if err != nil { - fmt.Print(err) + fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Printf("Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) + fmt.Fprintf(progressWriter(outputFormat), "Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) if !success { os.Exit(1) @@ -237,6 +245,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { testCmd.Flags().DurationVar(&readyTimeout, "ready-timeout", 90*time.Second, "How long to wait for the ephemeral container to be ready (--dry-run only)") testCmd.Flags().BoolVar(&watch, "watch", false, "Watch the artifact file and re-run the test on change (--dry-run only)") testCmd.Flags().StringVar(&driver, "driver", "", "Container runtime for --dry-run: 'docker' or 'podman' (default: auto-detect)") + testCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text, json, yaml, or github-actions") return testCmd } diff --git a/cmd/testDryRun.go b/cmd/testDryRun.go index 9d3ff60..4f8ff1f 100644 --- a/cmd/testDryRun.go +++ b/cmd/testDryRun.go @@ -18,6 +18,7 @@ package cmd import ( "context" "fmt" + "io" "net/url" "os" "os/exec" @@ -68,8 +69,6 @@ func configureDriver(driver string) error { } } -// shouldUsePodman auto-detects podman only when it's clearly the intended -// runtime: no explicit DOCKER_HOST, podman on PATH, and docker absent. func shouldUsePodman() bool { if os.Getenv("DOCKER_HOST") != "" { return false // respect an explicitly configured endpoint @@ -142,14 +141,18 @@ func rewriteLocalEndpoint(testEndpoint string) (string, int, bool) { } func runDryRunTest(opts dryRunOptions) bool { + // Progress/diagnostics go to stderr for machine output formats so stdout + // carries only the formatted result. + progress := progressWriter(opts.params.outputFormat) + if err := validateDryRunOptions(opts); err != nil { - fmt.Println(err) + fmt.Fprintln(os.Stderr, err) return false } // Select the container runtime (docker default, podman wired via DOCKER_HOST). if err := configureDriver(opts.driver); err != nil { - fmt.Println(err) + fmt.Fprintln(os.Stderr, err) return false } @@ -164,32 +167,32 @@ func runDryRunTest(opts dryRunOptions) bool { // A localhost test endpoint refers to the user's machine, not the // container: expose the port and point Microcks at the host gateway. if rewritten, hostPort, ok := rewriteLocalEndpoint(opts.params.testEndpoint); ok { - fmt.Printf("Test endpoint %s is local: reaching it from the container as %s\n", opts.params.testEndpoint, rewritten) + fmt.Fprintf(progress, "Test endpoint %s is local: reaching it from the container as %s\n", opts.params.testEndpoint, rewritten) opts.params.testEndpoint = rewritten containerOpts = append(containerOpts, testcontainers.WithHostPortAccess(hostPort)) } - fmt.Printf("Starting ephemeral Microcks container (%s)...\n", opts.image) + fmt.Fprintf(progress, "Starting ephemeral Microcks container (%s)...\n", opts.image) startCtx, startCancel := context.WithTimeout(ctx, opts.readyTimeout) defer startCancel() container, err := microcks.Run(startCtx, opts.image, containerOpts...) if err != nil { - fmt.Printf("Failed to start ephemeral Microcks container: %s\n", err) - fmt.Println("Check that the container runtime is running, the port is free and the image is reachable (or raise --ready-timeout).") + fmt.Fprintf(os.Stderr, "Failed to start ephemeral Microcks container: %s\n", err) + fmt.Fprintln(os.Stderr, "Check that the container runtime is running, the port is free and the image is reachable (or raise --ready-timeout).") if container != nil { - terminateContainer(container) + terminateContainer(container, progress) } return false } - defer terminateContainer(container) + defer terminateContainer(container, progress) endpoint, err := container.HttpEndpoint(ctx) if err != nil { - fmt.Printf("Failed to resolve ephemeral Microcks endpoint: %s\n", err) + fmt.Fprintf(os.Stderr, "Failed to resolve ephemeral Microcks endpoint: %s\n", err) return false } - fmt.Printf("Ephemeral Microcks is ready at %s\n", endpoint) + fmt.Fprintf(progress, "Ephemeral Microcks is ready at %s\n", endpoint) // The uber-native image runs without Keycloak: a headless client with // the unauthenticated token is enough. @@ -198,30 +201,32 @@ func runDryRunTest(opts dryRunOptions) bool { success, testResultID, err := runTestAndWait(mc, opts.params) if err != nil { - fmt.Println(err) + fmt.Fprintln(os.Stderr, err) return false } if !opts.watch { return success } - printDetailsLink(endpoint, testResultID) + printDetailsLink(progress, endpoint, testResultID) return watchAndRerun(ctx, mc, endpoint, opts) } -func terminateContainer(container *microcks.MicrocksContainer) { +func terminateContainer(container *microcks.MicrocksContainer, progress io.Writer) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - fmt.Println("Tearing down ephemeral Microcks container...") + fmt.Fprintln(progress, "Tearing down ephemeral Microcks container...") if err := container.Terminate(ctx); err != nil { - fmt.Printf("Failed to terminate container %s: %s\n", container.GetContainerID(), err) + fmt.Fprintf(os.Stderr, "Failed to terminate container %s: %s\n", container.GetContainerID(), err) } } func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr string, opts dryRunOptions) bool { + progress := progressWriter(opts.params.outputFormat) + watcher, err := fsnotify.NewWatcher() if err != nil { - fmt.Printf("Failed to create file watcher: %s\n", err) + fmt.Fprintf(os.Stderr, "Failed to create file watcher: %s\n", err) return false } defer watcher.Close() @@ -230,15 +235,15 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr // (rename + create), which silently drops a watch set on the file itself. artifactPath, err := filepath.Abs(opts.artifact) if err != nil { - fmt.Printf("Failed to resolve artifact path: %s\n", err) + fmt.Fprintf(os.Stderr, "Failed to resolve artifact path: %s\n", err) return false } if err := watcher.Add(filepath.Dir(artifactPath)); err != nil { - fmt.Printf("Failed to watch %s: %s\n", filepath.Dir(artifactPath), err) + fmt.Fprintf(os.Stderr, "Failed to watch %s: %s\n", filepath.Dir(artifactPath), err) return false } - fmt.Printf("\nWatching %s for changes — press Ctrl+C to stop.\n", opts.artifact) + fmt.Fprintf(progress, "\nWatching %s for changes — press Ctrl+C to stop.\n", opts.artifact) rerun := make(chan struct{}, 1) var debounce *time.Timer @@ -246,7 +251,7 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr for { select { case <-ctx.Done(): - fmt.Println("\nStopping watch mode.") + fmt.Fprintln(progress, "\nStopping watch mode.") return true case event, ok := <-watcher.Events: @@ -275,32 +280,32 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr if !ok { return true } - fmt.Printf("Watch error: %s\n", err) + fmt.Fprintf(os.Stderr, "Watch error: %s\n", err) case <-rerun: - fmt.Println(strings.Repeat("-", 60)) - fmt.Printf("Artifact changed, re-importing %s ...\n", opts.artifact) + fmt.Fprintln(progress, strings.Repeat("-", 60)) + fmt.Fprintf(progress, "Artifact changed, re-importing %s ...\n", opts.artifact) if _, err := mc.UploadArtifact(opts.artifact, true); err != nil { // Invalid spec mid-edit is normal in a TDD loop: report and // keep watching, the next valid save recovers. - fmt.Printf("Re-import failed, waiting for next change: %s\n", err) + fmt.Fprintf(os.Stderr, "Re-import failed, waiting for next change: %s\n", err) continue } success, testResultID, err := runTestAndWait(mc, opts.params) if err != nil { - fmt.Printf("Test run failed, waiting for next change: %s\n", err) + fmt.Fprintf(os.Stderr, "Test run failed, waiting for next change: %s\n", err) continue } - printDetailsLink(serverAddr, testResultID) + printDetailsLink(progress, serverAddr, testResultID) if success { - fmt.Println("Contract test PASSED — waiting for next change.") + fmt.Fprintln(progress, "Contract test PASSED — waiting for next change.") } else { - fmt.Println("Contract test FAILED — waiting for next change.") + fmt.Fprintln(progress, "Contract test FAILED — waiting for next change.") } } } } -func printDetailsLink(serverAddr, testResultID string) { - fmt.Printf("Test details (live while watching): %s/#/tests/%s\n", serverAddr, testResultID) +func printDetailsLink(progress io.Writer, serverAddr, testResultID string) { + fmt.Fprintf(progress, "Test details (live while watching): %s/#/tests/%s\n", serverAddr, testResultID) } diff --git a/cmd/testExecutor.go b/cmd/testExecutor.go index 8fc7e57..b9d40b2 100644 --- a/cmd/testExecutor.go +++ b/cmd/testExecutor.go @@ -17,9 +17,12 @@ package cmd import ( "fmt" + "io" + "os" "time" "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/output" ) // testParams bundles the inputs needed to launch and poll a Microcks test. @@ -33,11 +36,26 @@ type testParams struct { filteredOperations string operationsHeaders string oAuth2Context string + outputFormat string } -// runTestAndWait creates a test on the Microcks server and polls its result -// until completion or timeout. Shared by the regular and --dry-run paths. +// progressWriter returns where human progress/diagnostics should go. For +// machine-readable output formats they go to stderr, leaving stdout for the +// formatted result only. +func progressWriter(format string) io.Writer { + if format != "" && output.OutputFormat(format) != output.FormatText { + return os.Stderr + } + return os.Stdout +} + +// runTestAndWait creates a test on the Microcks server, polls until completion +// or timeout, then renders the result in the requested output format (result to +// stdout, progress to stderr for machine formats). Shared by the regular and +// --dry-run paths. func runTestAndWait(mc connectors.MicrocksClient, params testParams) (bool, string, error) { + progress := progressWriter(params.outputFormat) + testResultID, err := mc.CreateTestResult(params.serviceRef, params.testEndpoint, params.runnerType, params.secretName, params.waitForMillis, params.filteredOperations, params.operationsHeaders, params.oAuth2Context) if err != nil { @@ -59,19 +77,47 @@ func runTestAndWait(mc connectors.MicrocksClient, params testParams) (bool, stri } 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)) + fmt.Fprintf(progress, "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.") + fmt.Fprintln(progress, "MicrocksTester waiting for 2 seconds before checking again or exiting.") time.Sleep(2 * time.Second) } + if err := renderTestResult(mc, testResultID, params.outputFormat); err != nil { + return false, testResultID, err + } + return success, testResultID, nil } +// renderTestResult fetches the full result and writes it to stdout in the +// requested format. +func renderTestResult(mc connectors.MicrocksClient, testResultID, format string) error { + if format == "" { + format = string(output.FormatText) + } + full, err := mc.GetFullTestResult(testResultID) + if err != nil { + return fmt.Errorf("Got error when retrieving full test result: %s", err) + } + formatter, err := output.NewFormatter(output.OutputFormat(format)) + if err != nil { + return err + } + rendered, err := formatter.Format(full) + if err != nil { + return fmt.Errorf("Got error when formatting test result: %s", err) + } + if rendered != "" { + fmt.Println(rendered) + } + return nil +} + func nowInMilliseconds() int64 { return time.Now().UnixNano() / int64(time.Millisecond) } From ceae5c3e0e6772a48125b52c889d9afbdcbb7c45 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Fri, 26 Jun 2026 12:10:18 +0100 Subject: [PATCH 4/6] docs: document --output formats and a github-actions CI sample Signed-off-by: caesarsage --- README.md | 45 +++++++++++++++++++++++++++++++++++++++ documentation/cmd/test.md | 1 + 2 files changed, 46 insertions(+) diff --git a/README.md b/README.md index 5e68147..104a730 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,51 @@ $ docker run -it quay.io/microcks/microcks-cli:latest microcks test 'Beer Catalo ``` +## Machine-readable test output + +`microcks test` accepts `--output` to control how the result is rendered: + +| Value | Output | +| --- | --- | +| `text` (default) | Human-readable summary | +| `json` | The full `TestResult` as JSON | +| `yaml` | The full `TestResult` as YAML | +| `github-actions` | GitHub Actions workflow commands (annotations + log groups + step summary) | + +For machine formats (`json`/`yaml`/`github-actions`), progress goes to **stderr** +and only the formatted result is written to **stdout**, so it can be piped or +parsed cleanly: + +```bash +microcks test "Pastry API:1.0.0" http://localhost:8080/api OPEN_API_SCHEMA \ + --microcksURL=http://localhost:8585/api --output=json > result.json +``` + +### GitHub Actions + +With `--output=github-actions`, failures surface as `::error::` annotations, +each operation is wrapped in a collapsible `::group::`, and a per-operation table +is appended to the job summary (`$GITHUB_STEP_SUMMARY`). Set +`MICROCKS_ACTIONS_VERBOSE=true` to also emit `::notice::` for passing operations. + +```yaml +# .github/workflows/contract-test.yml +name: contract-test +on: [pull_request] +jobs: + contract-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Microcks contract test + run: | + microcks test "Pastry API:1.0.0" "${{ env.API_URL }}" OPEN_API_SCHEMA \ + --microcksURL=${{ secrets.MICROCKS_URL }} \ + --keycloakClientId=${{ secrets.MICROCKS_CLIENT_ID }} \ + --keycloakClientSecret=${{ secrets.MICROCKS_CLIENT_SECRET }} \ + --output=github-actions +``` + ## Tekton tasks This repository also contains different [Tekton](https://tekton.dev/) tasks definitions and sample pipelines. You'll find under the `/tekton` folder the resource for current `v1beta1` Tekton API version and the older `v1alpha1` under `tekton/v1alpha1`. diff --git a/documentation/cmd/test.md b/documentation/cmd/test.md index 9fb0c79..6cbe7d1 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`, `yaml`, or `github-actions` | ### Options Inherited from Parent Commands From 2fc5e8d91480ada4b72a8364067fb0209e547b76 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Wed, 1 Jul 2026 09:49:26 +0100 Subject: [PATCH 5/6] feat(output): annotate github-actions failures at spec file:line Signed-off-by: caesarsage --- go.mod | 2 +- pkg/output/formatter.go | 19 +++++- pkg/output/github_actions_formatter.go | 19 +++++- pkg/output/openapi_linemap.go | 82 ++++++++++++++++++++++++++ pkg/output/output_test.go | 78 ++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 pkg/output/openapi_linemap.go diff --git a/go.mod b/go.mod index 8555b16..943dc76 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.40.0 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 microcks.io/testcontainers-go v0.3.3 ) @@ -73,6 +74,5 @@ require ( go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/sys v0.42.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect microcks.io/go-client v0.3.1 // indirect ) diff --git a/pkg/output/formatter.go b/pkg/output/formatter.go index 2f3dfcd..2cab172 100644 --- a/pkg/output/formatter.go +++ b/pkg/output/formatter.go @@ -42,8 +42,23 @@ type Formatter interface { Format(result *connectors.TestResult) (string, error) } +// Option configures a Formatter. +type Option func(*config) + +type config struct { + artifactPath string +} + +func WithArtifactPath(path string) Option { + return func(c *config) { c.artifactPath = path } +} + // NewFormatter returns the Formatter for the given format. -func NewFormatter(format OutputFormat) (Formatter, error) { +func NewFormatter(format OutputFormat, opts ...Option) (Formatter, error) { + c := &config{} + for _, o := range opts { + o(c) + } switch format { case FormatText: return &TextFormatter{}, nil @@ -52,7 +67,7 @@ func NewFormatter(format OutputFormat) (Formatter, error) { case FormatYAML: return &YAMLFormatter{}, nil case FormatGitHubActions: - return &GitHubActionsFormatter{}, nil + return &GitHubActionsFormatter{artifactPath: c.artifactPath}, nil default: return nil, fmt.Errorf("unsupported output format %q (use: text, json, yaml, github-actions)", format) } diff --git a/pkg/output/github_actions_formatter.go b/pkg/output/github_actions_formatter.go index 107c0e6..1ee86c5 100644 --- a/pkg/output/github_actions_formatter.go +++ b/pkg/output/github_actions_formatter.go @@ -27,7 +27,9 @@ import ( // a collapsible ::group:: per operation, ::error:: annotations for failures // (and ::notice:: for passes when MICROCKS_ACTIONS_VERBOSE is set), plus a // markdown table appended to $GITHUB_STEP_SUMMARY. -type GitHubActionsFormatter struct{} +type GitHubActionsFormatter struct { + artifactPath string +} func (f *GitHubActionsFormatter) Format(r *connectors.TestResult) (string, error) { verbose := os.Getenv("MICROCKS_ACTIONS_VERBOSE") != "" @@ -42,8 +44,8 @@ func (f *GitHubActionsFormatter) Format(r *connectors.TestResult) (string, error for _, s := range tc.TestStepResults { switch { case !s.Success: - fmt.Fprintf(&b, "::error title=%s::%s\n", - escapeProperty(tc.OperationName), escapeData(stepMessage(s))) + fmt.Fprintf(&b, "::error %s::%s\n", + f.errorProperties(tc.OperationName), escapeData(stepMessage(s))) case verbose: fmt.Fprintf(&b, "::notice title=%s::%s passed\n", escapeProperty(tc.OperationName), escapeData(s.RequestName)) @@ -67,6 +69,17 @@ func (f *GitHubActionsFormatter) Format(r *connectors.TestResult) (string, error return b.String(), nil } +func (f *GitHubActionsFormatter) errorProperties(operationName string) string { + props := []string{"title=" + escapeProperty(operationName)} + if f.artifactPath != "" { + props = append(props, "file="+escapeProperty(f.artifactPath)) + if line := operationLine(f.artifactPath, operationName); line > 0 { + props = append(props, fmt.Sprintf("line=%d", line)) + } + } + return strings.Join(props, ",") +} + // stepMessage returns the failure message, or a sensible default when empty. func stepMessage(s connectors.TestStepResult) string { if strings.TrimSpace(s.Message) != "" { diff --git a/pkg/output/openapi_linemap.go b/pkg/output/openapi_linemap.go new file mode 100644 index 0000000..dccdfa7 --- /dev/null +++ b/pkg/output/openapi_linemap.go @@ -0,0 +1,82 @@ +/* + * Copyright The Microcks 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 output + +import ( + "os" + "strings" + + yamlv3 "gopkg.in/yaml.v3" +) + +// operationLine returns the 1-based line of an operation (e.g. "GET /products") +// within an OpenAPI spec file, or 0 if it can't be determined (non-YAML spec, +// parse error, or operation not found). yaml.v3 nodes carry line numbers, which +// yaml.v2 does not expose. +func operationLine(specPath, operationName string) int { + method, path, ok := splitOperation(operationName) + if !ok { + return 0 + } + + data, err := os.ReadFile(specPath) + if err != nil { + return 0 + } + + var doc yamlv3.Node + if err := yamlv3.Unmarshal(data, &doc); err != nil { + return 0 + } + root := &doc + if root.Kind == yamlv3.DocumentNode && len(root.Content) > 0 { + root = root.Content[0] + } + + _, pathsNode := mappingEntry(root, "paths") + if pathsNode == nil { + return 0 + } + _, pathNode := mappingEntry(pathsNode, path) + if pathNode == nil { + return 0 + } + line, _ := mappingEntry(pathNode, method) + return line +} + +// splitOperation parses "GET /products" into ("get", "/products", true). +func splitOperation(operationName string) (method, path string, ok bool) { + parts := strings.SplitN(strings.TrimSpace(operationName), " ", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return strings.ToLower(parts[0]), parts[1], true +} + +// mappingEntry looks up key in a mapping node and returns the key node's line +// (where "key:" appears) and its value node. +func mappingEntry(node *yamlv3.Node, key string) (int, *yamlv3.Node) { + if node == nil || node.Kind != yamlv3.MappingNode { + return 0, nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i].Line, node.Content[i+1] + } + } + return 0, nil +} diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go index 2bd7698..2a98a9c 100644 --- a/pkg/output/output_test.go +++ b/pkg/output/output_test.go @@ -153,3 +153,81 @@ func TestEscaping(t *testing.T) { t.Errorf("escapeProperty = %q", got) } } + +const specFixture = `openapi: 3.0.0 +info: + title: X + version: 1.0.0 +paths: + /products: + get: + operationId: getProducts + responses: + "200": + description: ok + /orders: + post: + operationId: placeOrder + responses: + "201": + description: created +` + +func writeSpec(t *testing.T) string { + t.Helper() + p := filepath.Join(t.TempDir(), "spec.yaml") + if err := os.WriteFile(p, []byte(specFixture), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestOperationLine(t *testing.T) { + spec := writeSpec(t) + cases := map[string]int{ + "GET /products": 7, // line of "get:" under /products + "POST /orders": 13, // line of "post:" under /orders + "GET /nonexistent": 0, + "weird": 0, // no method/path split + } + for op, want := range cases { + if got := operationLine(spec, op); got != want { + t.Errorf("operationLine(%q) = %d, want %d", op, got, want) + } + } + if got := operationLine("/no/such/file.yaml", "GET /products"); got != 0 { + t.Errorf("missing file = %d, want 0", got) + } +} + +func TestGitHubActionsFileLineAnnotation(t *testing.T) { + spec := writeSpec(t) + result := &connectors.TestResult{ + Success: false, + TestCaseResults: []connectors.TestCaseResult{ + {Success: false, OperationName: "GET /products", TestStepResults: []connectors.TestStepResult{ + {Success: false, RequestName: "r", Message: "boom"}, + }}, + }, + } + formatter, err := NewFormatter(FormatGitHubActions, WithArtifactPath(spec)) + if err != nil { + t.Fatal(err) + } + out, err := formatter.Format(result) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"title=GET /products", "file=" + spec, "line=7"} { + if !strings.Contains(out, want) { + t.Errorf("annotation missing %q\n%s", want, out) + } + } + + // Without an artifact path, no file/line properties. + plain, _ := NewFormatter(FormatGitHubActions) + out2, _ := plain.Format(result) + if strings.Contains(out2, "file=") || strings.Contains(out2, "line=") { + t.Errorf("did not expect file/line without artifact path:\n%s", out2) + } +} From 809b1de714ec4fb718601b45cb082f2ba5e88a2a Mon Sep 17 00:00:00 2001 From: caesarsage Date: Wed, 1 Jul 2026 09:49:26 +0100 Subject: [PATCH 6/6] feat(test): thread --artifact to formatters for diff annotations Signed-off-by: caesarsage --- cmd/test.go | 1 + cmd/testExecutor.go | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/test.go b/cmd/test.go index 80a65bb..fc5d696 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -129,6 +129,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { operationsHeaders: operationsHeaders, oAuth2Context: oAuth2Context, outputFormat: outputFormat, + artifactPath: artifact, } if !dryRun { diff --git a/cmd/testExecutor.go b/cmd/testExecutor.go index b9d40b2..8e0cd4d 100644 --- a/cmd/testExecutor.go +++ b/cmd/testExecutor.go @@ -37,6 +37,7 @@ type testParams struct { operationsHeaders string oAuth2Context string outputFormat string + artifactPath string } // progressWriter returns where human progress/diagnostics should go. For @@ -87,7 +88,7 @@ func runTestAndWait(mc connectors.MicrocksClient, params testParams) (bool, stri time.Sleep(2 * time.Second) } - if err := renderTestResult(mc, testResultID, params.outputFormat); err != nil { + if err := renderTestResult(mc, testResultID, params.outputFormat, params.artifactPath); err != nil { return false, testResultID, err } @@ -95,8 +96,9 @@ func runTestAndWait(mc connectors.MicrocksClient, params testParams) (bool, stri } // renderTestResult fetches the full result and writes it to stdout in the -// requested format. -func renderTestResult(mc connectors.MicrocksClient, testResultID, format string) error { +// requested format. artifactPath (when set) lets the github-actions formatter +// map failures to file:line. +func renderTestResult(mc connectors.MicrocksClient, testResultID, format, artifactPath string) error { if format == "" { format = string(output.FormatText) } @@ -104,7 +106,7 @@ func renderTestResult(mc connectors.MicrocksClient, testResultID, format string) if err != nil { return fmt.Errorf("Got error when retrieving full test result: %s", err) } - formatter, err := output.NewFormatter(output.OutputFormat(format)) + formatter, err := output.NewFormatter(output.OutputFormat(format), output.WithArtifactPath(artifactPath)) if err != nil { return err }