From 901ea30bd9aeabfffc34fc9801c851dfaf43e956 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Thu, 9 Jul 2026 14:58:11 +0100 Subject: [PATCH 01/15] refactor(errors): remove the legacy exit shim Signed-off-by: caesarsage --- pkg/errors/error.go | 40 ---------------------------------------- watcher/main.go | 12 +++++++++--- 2 files changed, 9 insertions(+), 43 deletions(-) diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 6172ef27..1f68dac1 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -3,48 +3,8 @@ package errors import ( stderrors "errors" "fmt" - "log" - "os" ) -// Deprecated: these numeric codes and the Check*/Fatal helpers below are the -// legacy exit mechanism. New code classifies failures with a Kind (see Wrap) and -// lets cmd.Handle map Kind -> exit code. Kept as a shim until every call site is -// migrated, then removed. -const ( - // ErrorCommandSpecific is reserved for command specific indications - ErrorCommandSpecific = 1 - // ErrorConnectionFailure is returned on connection failure to API endpoint - ErrorConnectionFailure = 11 - // ErrorAPIResponse is returned on unexpected API response, i.e. authorization failure - ErrorAPIResponse = 12 - // ErrorResourceDoesNotExist is returned when the requested resource does not exist - ErrorResourceDoesNotExist = 13 - // ErrorGeneric is returned for generic error - ErrorGeneric = 20 -) - -// Deprecated: return errors.Wrap(kind, err) from a RunE command instead. -func CheckError(err error) { - if err != nil { - Fatal(ErrorGeneric, err) - } -} - -// Deprecated: return a KindNotFound-wrapped error instead. -func CheckConfigNil(isNil bool, path string) { - if isNil { - Fatal(ErrorGeneric, "No contexts defined in "+path) - } -} - -// Deprecated: only main/cmd.Handle should exit the process. Fatal is a wrapper -// for log.Fatal() to exit with a custom code. -func Fatal(exitcode int, args ...interface{}) { - log.Println(args...) - os.Exit(exitcode) -} - // Kind classifies why an operation failed. The library returns kinds; the cmd // layer maps them to exit codes, so pkg/* never depends on exit codes and stays // safe to embed. diff --git a/watcher/main.go b/watcher/main.go index 54f06cd1..cfa92eaf 100644 --- a/watcher/main.go +++ b/watcher/main.go @@ -2,18 +2,24 @@ package main import ( "fmt" + "os" "github.com/microcks/microcks-cli/pkg/config" - "github.com/microcks/microcks-cli/pkg/errors" "github.com/microcks/microcks-cli/pkg/watcher" ) func main() { watchFile, err := config.DefaultLocalWatchPath() - errors.CheckError(err) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } wm, err := watcher.NewWatchManger(watchFile) - errors.CheckError(err) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } fmt.Println("[INFO] microcks-watcher started...") wm.Run() From 481a7b8e6aad3e17b4c72dd544df4fafa0618b80 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Thu, 9 Jul 2026 14:58:11 +0100 Subject: [PATCH 02/15] ci: run tests and guard against stray process exits Signed-off-by: caesarsage --- .github/workflows/build-verify.yml | 13 +++++++++++++ CONTRIBUTING.md | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/build-verify.yml b/.github/workflows/build-verify.yml index 3ad1a217..7c315413 100644 --- a/.github/workflows/build-verify.yml +++ b/.github/workflows/build-verify.yml @@ -41,6 +41,19 @@ jobs: make clean make build-binaries + - name: Run tests + run: go test ./... + + - name: Guard against stray process exits + run: | + # pkg/* and cmd/* (except cmd/exit.go) must return a classified error, + # never exit or panic. Only the main entrypoints exit the process. + # See documentation/error-handling.md. + if grep -rnE '(os\.Exit|log\.Fatal|panic\()' --include='*.go' cmd pkg | grep -vE '_test\.go|cmd/exit\.go'; then + echo "::error::os.Exit/log.Fatal/panic found outside cmd/exit.go — return errors.Wrap(kind, err) instead." + exit 1 + fi + - name: Set environment for branch run: | set -x diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 500bfc70..dff14b13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,13 @@ Microcks has adopted a Code of Conduct that we expect project participants to ad We use Github to host code, to track issues and feature requests, as well as accept pull requests. +## Error handling + +Code under `pkg/` and `cmd/` must return errors, never exit or panic on a runtime +error: wrap the failure with a Kind (`return errors.Wrap(errors.KindConnection, err)`) +and let it flow up. Only the `main` entrypoints and `cmd.Handle` exit the process. +See [documentation/error-handling.md](documentation/error-handling.md); CI enforces this. + ## Issues [Open an issue](https://github.com/microcks/microcks/issues/new) **only** if you want to report a bug or a feature. Don't open issues for questions or support, instead join our [Discord #support channel](https://microcks.io/discord-invite) or our [GitHub discussions](https://github.com/orgs/microcks/discussions) and ask there. From ea3916c71d5af2cc85398ed651ef9d5c0330ee0a Mon Sep 17 00:00:00 2001 From: caesarsage Date: Fri, 26 Jun 2026 11:42:28 +0100 Subject: [PATCH 03/15] 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 6a215f11..395a055a 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"` @@ -452,6 +484,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 ef9e25e036d31eda7f07d8d308c72692df52dd4f Mon Sep 17 00:00:00 2001 From: caesarsage Date: Fri, 26 Jun 2026 11:42:28 +0100 Subject: [PATCH 04/15] 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 00000000..2f3dfcd7 --- /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 00000000..107c0e6e --- /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 00000000..0a359410 --- /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 00000000..2bd7698b --- /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 00000000..effab026 --- /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 00000000..7371f53a --- /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 819134bbb9b799821028822db926685f88557680 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Wed, 1 Jul 2026 08:52:24 +0100 Subject: [PATCH 05/15] feat(test): wire --output into runTestAndWait for server and dry-run paths Signed-off-by: caesarsage --- cmd/test.go | 10 ++++++++- cmd/testDryRun.go | 51 +++++++++++++++++++++++------------------- cmd/testExecutor.go | 54 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 28 deletions(-) diff --git a/cmd/test.go b/cmd/test.go index de46db70..657809a2 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -24,6 +24,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" ) @@ -44,6 +45,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { readyTimeout time.Duration watch bool driver string + outputFormat string ) var testCmd = &cobra.Command{ @@ -76,6 +78,10 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { return errors.Wrapf(errors.KindUsage, "--waitFor format is wrong. Accepted units are: milli, sec, min (e.g. 500milli, 30sec, 5min)") } + if !output.IsValid(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json, yaml, github-actions") + } + // Collect optional HTTPS transport flags. config.InsecureTLS = globalClientOpts.InsecureTLS config.CaCertPaths = globalClientOpts.CaCertPaths @@ -112,6 +118,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { filteredOperations: filteredOperations, operationsHeaders: operationsHeaders, oAuth2Context: oAuth2Context, + outputFormat: outputFormat, } if !dryRun { @@ -205,7 +212,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { return err } - 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 { return errors.ErrTestFailed @@ -225,6 +232,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 290b4102..1f8fb67c 100644 --- a/cmd/testDryRun.go +++ b/cmd/testDryRun.go @@ -18,6 +18,7 @@ package cmd import ( "context" "fmt" + "io" "net/url" "os" "os/exec" @@ -69,8 +70,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 @@ -143,6 +142,10 @@ func rewriteLocalEndpoint(testEndpoint string) (string, int, bool) { } func runDryRunTest(opts dryRunOptions) error { + // 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 { return errors.Wrap(errors.KindUsage, err) } @@ -163,30 +166,30 @@ func runDryRunTest(opts dryRunOptions) error { // 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 { if container != nil { - terminateContainer(container) + terminateContainer(container, progress) } return errors.Wrapf(errors.KindEnvironment, "failed to start ephemeral Microcks container: %v. "+ "Check that the container runtime is running, the port is free and the image is reachable (or raise --ready-timeout)", err) } - defer terminateContainer(container) + defer terminateContainer(container, progress) endpoint, err := container.HttpEndpoint(ctx) if err != nil { return errors.Wrapf(errors.KindEnvironment, "failed to resolve ephemeral Microcks endpoint: %v", err) } - 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. @@ -207,20 +210,22 @@ func runDryRunTest(opts dryRunOptions) error { } return errors.ErrTestFailed } - 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) error { + progress := progressWriter(opts.params.outputFormat) + watcher, err := fsnotify.NewWatcher() if err != nil { return fmt.Errorf("failed to create file watcher: %w", err) @@ -237,7 +242,7 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr return fmt.Errorf("failed to watch %s: %w", filepath.Dir(artifactPath), err) } - 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 @@ -245,7 +250,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 nil case event, ok := <-watcher.Events: @@ -274,32 +279,32 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr if !ok { return nil } - 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 87aecc5e..8983e96a 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 41c5140dd1ba9b308b38edfbdf4e05c40cebe33c Mon Sep 17 00:00:00 2001 From: caesarsage Date: Fri, 26 Jun 2026 12:10:18 +0100 Subject: [PATCH 06/15] 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 4ee5b2ef..8530e3ae 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,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 9fb0c794..6cbe7d18 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 b98f262b4fbd857979a9bd99eb62c7df1878b2a7 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Wed, 1 Jul 2026 09:49:26 +0100 Subject: [PATCH 07/15] 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 8555b167..943dc766 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 2f3dfcd7..2cab1721 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 107c0e6e..1ee86c58 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 00000000..dccdfa72 --- /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 2bd7698b..2a98a9c0 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 6e78f5296522a2094c4a95896f14a0ade4214e4e Mon Sep 17 00:00:00 2001 From: caesarsage Date: Wed, 1 Jul 2026 09:49:26 +0100 Subject: [PATCH 08/15] 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 657809a2..97d53e36 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -119,6 +119,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 8983e96a..6334340a 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 } From 9f7501a71dc14728e2db4723d5134fc3ba6f0bf3 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Sun, 2 Aug 2026 10:50:00 +0100 Subject: [PATCH 09/15] feat(cli): add service/test queries for editor integration and centralize command client/JSON helpers Signed-off-by: caesarsage --- README.md | 1 + cmd/cmd.go | 1 + cmd/command_client.go | 81 ++++++++++ cmd/context.go | 15 ++ cmd/context_test.go | 15 ++ cmd/importDir.go | 21 +-- cmd/importURL.go | 58 +------- cmd/login.go | 16 ++ cmd/logout.go | 16 ++ cmd/logout_test.go | 15 ++ cmd/service.go | 153 +++++++++++++++++++ cmd/service_test.go | 102 +++++++++++++ cmd/start.go | 16 ++ cmd/start_test.go | 16 ++ cmd/stop.go | 16 ++ cmd/test.go | 72 +-------- cmd/testDryRun.go | 14 +- cmd/testQuery.go | 128 ++++++++++++++++ cmd/test_query_test.go | 72 +++++++++ documentation/cmd/service.md | 43 ++++++ documentation/cmd/test.md | 19 +++ main.go | 15 ++ pkg/config/file_permission_unix.go | 16 ++ pkg/config/file_permission_windows.go | 16 ++ pkg/config/localconfig.go | 15 ++ pkg/connectors/container_client.go | 15 ++ pkg/connectors/microcks_client.go | 195 +++++++++++++++++-------- pkg/connectors/microcks_client_test.go | 137 +++++++++++++++++ pkg/errors/error.go | 15 ++ pkg/errors/error_test.go | 15 ++ pkg/output/json.go | 38 +++++ pkg/util/rand/rand.go | 15 ++ pkg/util/util.go | 15 ++ pkg/watcher/executor.go | 15 ++ pkg/watcher/watchManager.go | 15 ++ watcher/main.go | 15 ++ 36 files changed, 1233 insertions(+), 209 deletions(-) create mode 100644 cmd/command_client.go create mode 100644 cmd/service.go create mode 100644 cmd/service_test.go create mode 100644 cmd/testQuery.go create mode 100644 cmd/test_query_test.go create mode 100644 documentation/cmd/service.md create mode 100644 pkg/output/json.go diff --git a/README.md b/README.md index 8530e3ae..216738e4 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ microcks [command] [flags] | `import` | Import API spec files from local filesystem | [`import`](documentation/cmd/import.md) | | `import-dir` | Scan a directory and import API spec files. | [`import-dir`](documentation/cmd/importDir.md) | | `import-url` | Import API spec files directly from a remote URL | [`import-url`](documentation/cmd/importUrl.md) | +| `service` | List and inspect Microcks services | [`service`](documentation/cmd/service.md) | | `test` | Run tests against a deployed API using selected runner | [`test`](documentation/cmd/test.md) | | `version` | Print Microcks CLI version | [`version`](documentation/cmd/version.md) | diff --git a/cmd/cmd.go b/cmd/cmd.go index ec20c97c..be9cdfc2 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -44,6 +44,7 @@ func NewCommand() (*cobra.Command, error) { command.AddCommand(NewImportDirCommand(&clientOpts)) command.AddCommand(NewVersionCommand()) command.AddCommand(NewTestCommand(&clientOpts)) + command.AddCommand(NewServiceCommand(&clientOpts)) command.AddCommand(NewImportURLCommand(&clientOpts)) command.AddCommand(NewStartCommand(&clientOpts)) command.AddCommand(NewStopCommand(&clientOpts)) diff --git a/cmd/command_client.go b/cmd/command_client.go new file mode 100644 index 00000000..6379e989 --- /dev/null +++ b/cmd/command_client.go @@ -0,0 +1,81 @@ +/* + * 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 cmd + +import ( + "github.com/microcks/microcks-cli/pkg/config" + "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" +) + +func newCommandClient(globalClientOpts *connectors.ClientOptions) (connectors.MicrocksClient, string, error) { + config.InsecureTLS = globalClientOpts.InsecureTLS + config.CaCertPaths = globalClientOpts.CaCertPaths + config.Verbose = globalClientOpts.Verbose + + if globalClientOpts.ServerAddr != "" { + mc, err := connectors.NewMicrocksClient(globalClientOpts.ServerAddr) + if err != nil { + return nil, "", err + } + + if globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { + keycloakURL, err := mc.GetKeycloakURL() + if err != nil { + return nil, "", err + } + + oauthToken := "unauthenticated-token" + if keycloakURL != "null" { + kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + if err != nil { + return nil, "", err + } + + oauthToken, err = kc.ConnectAndGetToken() + if err != nil { + return nil, "", err + } + } + mc.SetOAuthToken(oauthToken) + } + return mc, globalClientOpts.ServerAddr, nil + } + + localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) + if err != nil { + return nil, "", err + } + if localConfig == nil { + return nil, "", errors.Wrapf(errors.KindUsage, "please login to perform this operation") + } + + clientOpts := *globalClientOpts + if clientOpts.Context == "" { + clientOpts.Context = localConfig.CurrentContext + } + + mc, err := connectors.NewClient(clientOpts) + if err != nil { + return nil, "", err + } + + ctx, err := localConfig.ResolveContext(clientOpts.Context) + if err != nil { + return nil, "", errors.Wrap(errors.KindNotFound, err) + } + return mc, ctx.Server.Server, nil +} diff --git a/cmd/context.go b/cmd/context.go index a83488df..696e460e 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -1,3 +1,18 @@ +/* + * 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 cmd import ( diff --git a/cmd/context_test.go b/cmd/context_test.go index 396a3b91..29cfaca8 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -1,3 +1,18 @@ +/* + * 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 cmd import ( diff --git a/cmd/importDir.go b/cmd/importDir.go index ae072b38..c6810c97 100644 --- a/cmd/importDir.go +++ b/cmd/importDir.go @@ -21,7 +21,6 @@ import ( "path/filepath" "strings" - "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" "github.com/spf13/cobra" @@ -123,25 +122,7 @@ func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm dirPath := args[0] - config.InsecureTLS = globalClientOpts.InsecureTLS - config.CaCertPaths = globalClientOpts.CaCertPaths - config.Verbose = globalClientOpts.Verbose - - localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) - if err != nil { - return err - } - - if localConfig == nil { - return errors.Wrapf(errors.KindUsage, "please login to perform this operation") - } - - if globalClientOpts.Context == "" { - globalClientOpts.Context = localConfig.CurrentContext - } - - // Create client - mc, err := connectors.NewClient(*globalClientOpts) + mc, _, err := newCommandClient(globalClientOpts) if err != nil { return err } diff --git a/cmd/importURL.go b/cmd/importURL.go index d73a7603..4d291ab0 100644 --- a/cmd/importURL.go +++ b/cmd/importURL.go @@ -21,7 +21,6 @@ import ( "strconv" "strings" - "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" "github.com/spf13/cobra" @@ -40,60 +39,9 @@ func NewImportURLCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm specificationFiles := args[0] - config.InsecureTLS = globalClientOpts.InsecureTLS - config.CaCertPaths = globalClientOpts.CaCertPaths - config.Verbose = globalClientOpts.Verbose - - var mc connectors.MicrocksClient - - if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { - // create client with server address - var err error - mc, err = connectors.NewMicrocksClient(globalClientOpts.ServerAddr) - if err != nil { - return err - } - - keycloakURL, err := mc.GetKeycloakURL() - if err != nil { - return err - } - - oauthToken := "unauthenticated-token" - if keycloakURL != "null" { - // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. - kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) - if err != nil { - return err - } - - oauthToken, err = kc.ConnectAndGetToken() - if err != nil { - return err - } - } - - //Set Auth token - mc.SetOAuthToken(oauthToken) - } else { - - localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) - if err != nil { - return err - } - - if localConfig == nil { - return errors.Wrapf(errors.KindUsage, "please login to perform this operation") - } - - if globalClientOpts.Context == "" { - globalClientOpts.Context = localConfig.CurrentContext - } - - mc, err = connectors.NewClient(*globalClientOpts) - if err != nil { - return err - } + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err } sepSpecificationFiles := strings.Split(specificationFiles, ",") for _, f := range sepSpecificationFiles { diff --git a/cmd/login.go b/cmd/login.go index 47868bfb..17f0cba4 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -1,3 +1,19 @@ +/* + * 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 cmd import ( diff --git a/cmd/logout.go b/cmd/logout.go index 2438f751..43ecddb2 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -1,3 +1,19 @@ +/* + * 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 cmd import ( diff --git a/cmd/logout_test.go b/cmd/logout_test.go index 2801f445..de56e12a 100644 --- a/cmd/logout_test.go +++ b/cmd/logout_test.go @@ -1,3 +1,18 @@ +/* + * 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 cmd import ( diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 00000000..642c40a9 --- /dev/null +++ b/cmd/service.go @@ -0,0 +1,153 @@ +/* + * 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 cmd + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "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" +) + +func NewServiceCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + serviceCmd := &cobra.Command{ + Use: "service", + Short: "List and inspect Microcks services", + } + + serviceCmd.AddCommand(newServiceListCommand(globalClientOpts)) + serviceCmd.AddCommand(newServiceGetCommand(globalClientOpts)) + + return serviceCmd +} + +func newServiceListCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var ( + page int + size int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List Microcks services", + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + if page < 0 { + return errors.Wrapf(errors.KindUsage, "--page must be greater than or equal to 0") + } + if size <= 0 { + return errors.Wrapf(errors.KindUsage, "--size must be greater than 0") + } + + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err + } + + services, err := mc.ListServices(page, size) + if err != nil { + return err + } + + if outputFormat == "json" { + return output.WriteJSON(os.Stdout, services) + } + return printServices(services) + }, + } + cmd.Flags().IntVar(&page, "page", 0, "Page index to fetch") + cmd.Flags().IntVar(&size, "size", 50, "Number of services to fetch") + cmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return cmd +} + +func newServiceGetCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get Microcks service details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err + } + + service, err := mc.GetService(args[0]) + if err != nil { + return err + } + + if outputFormat == "json" { + return output.WriteJSON(os.Stdout, service) + } + return printServiceDetail(service) + }, + } + cmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return cmd +} + +func printServices(services []connectors.Service) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + defer func() { _ = w.Flush() }() + if _, err := fmt.Fprintln(w, "ID\tNAME\tVERSION\tTYPE"); err != nil { + return err + } + for _, service := range services { + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", service.ID, service.Name, service.Version, service.Type); err != nil { + return err + } + } + return nil +} + +func printServiceDetail(detail *connectors.ServiceDetail) error { + service := detail.Service + if _, err := fmt.Printf("%s:%s %s\n", service.Name, service.Version, service.Type); err != nil { + return err + } + if len(service.Operations) == 0 { + return nil + } + for _, operation := range service.Operations { + parts := []string{operation.Name} + if operation.Method != "" { + parts = append(parts, operation.Method) + } + if len(operation.ResourcePaths) > 0 { + parts = append(parts, strings.Join(operation.ResourcePaths, ",")) + } + if _, err := fmt.Printf("- %s\n", strings.Join(parts, " ")); err != nil { + return err + } + } + return nil +} diff --git a/cmd/service_test.go b/cmd/service_test.go new file mode 100644 index 00000000..ed2ff5f3 --- /dev/null +++ b/cmd/service_test.go @@ -0,0 +1,102 @@ +/* + * 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 cmd + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestServiceListCommandOutputsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/services" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode([]map[string]string{{ + "id": "svc-1", + "name": "Catalog API", + "version": "1.0.0", + "type": "REST", + }}) + })) + defer server.Close() + + out, err := executeCLIForTest(t, "service", "list", "--microcksURL", server.URL, "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if !strings.Contains(out, `"id": "svc-1"`) { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestServiceGetCommandOutputsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/services/svc-1" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "service": map[string]string{ + "id": "svc-1", + "name": "Catalog API", + "version": "1.0.0", + "type": "REST", + }, + }) + })) + defer server.Close() + + out, err := executeCLIForTest(t, "service", "get", "svc-1", "--microcksURL", server.URL, "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if !strings.Contains(out, `"name": "Catalog API"`) { + t.Fatalf("unexpected output: %s", out) + } +} + +func executeCLIForTest(t *testing.T, args ...string) (string, error) { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe returned error: %v", err) + } + os.Stdout = w + + command, err := NewCommand() + if err != nil { + t.Fatalf("NewCommand returned error: %v", err) + } + command.SetArgs(append(args, "--config", t.TempDir()+"/config.yaml")) + + execErr := command.Execute() + _ = w.Close() + os.Stdout = oldStdout + + out, readErr := io.ReadAll(r) + if readErr != nil { + t.Fatalf("ReadAll returned error: %v", readErr) + } + return string(out), execErr +} diff --git a/cmd/start.go b/cmd/start.go index 9f544686..79f8723a 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -1,3 +1,19 @@ +/* + * 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 cmd import ( diff --git a/cmd/start_test.go b/cmd/start_test.go index 5e6e3d7a..dba9e0d4 100644 --- a/cmd/start_test.go +++ b/cmd/start_test.go @@ -1,3 +1,19 @@ +/* + * 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 cmd import ( diff --git a/cmd/stop.go b/cmd/stop.go index 50a33afc..6acfa80e 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -1,3 +1,19 @@ +/* + * 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 cmd import ( diff --git a/cmd/test.go b/cmd/test.go index 97d53e36..c2bb880e 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -21,7 +21,6 @@ import ( "strings" "time" - "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" @@ -82,11 +81,6 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json, yaml, github-actions") } - // 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") { @@ -146,66 +140,9 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { }) } - var mc connectors.MicrocksClient - var serverAddr string - - if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { - - // create client with server address - serverAddr = globalClientOpts.ServerAddr - var err error - mc, err = connectors.NewMicrocksClient(serverAddr) - if err != nil { - return err - } - - keycloakURL, err := mc.GetKeycloakURL() - if err != nil { - return err - } - - oauthToken := "unauthenticated-token" - if keycloakURL != "null" { - // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. - kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) - if err != nil { - return err - } - - oauthToken, err = kc.ConnectAndGetToken() - if err != nil { - return err - } - } - - // Then - launch the test on Microcks Server. - mc.SetOAuthToken(oauthToken) - - } else { - localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) - if err != nil { - return err - } - - if localConfig == nil { - return errors.Wrapf(errors.KindUsage, "please login to perform this operation") - } - - if globalClientOpts.Context == "" { - globalClientOpts.Context = localConfig.CurrentContext - } - - mc, err = connectors.NewClient(*globalClientOpts) - if err != nil { - return err - } - - ctx, err := localConfig.ResolveContext(globalClientOpts.Context) - if err != nil { - return errors.Wrap(errors.KindNotFound, err) - } - - serverAddr = ctx.Server.Server + mc, serverAddr, err := newCommandClient(globalClientOpts) + if err != nil { + return err } success, testResultID, err := runTestAndWait(mc, params) @@ -235,5 +172,8 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { 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") + testCmd.AddCommand(newTestListCommand(globalClientOpts)) + testCmd.AddCommand(newTestGetCommand(globalClientOpts)) + return testCmd } diff --git a/cmd/testDryRun.go b/cmd/testDryRun.go index 1f8fb67c..1862395c 100644 --- a/cmd/testDryRun.go +++ b/cmd/testDryRun.go @@ -99,15 +99,15 @@ func setupPodman() error { func validateDryRunOptions(opts dryRunOptions) error { if opts.artifact == "" { - return fmt.Errorf("--artifact is required with --dry-run") + return errors.Wrapf(errors.KindUsage, "--artifact is required with --dry-run") } if _, err := os.Stat(opts.artifact); err != nil { - return fmt.Errorf("cannot read --artifact file %q: %v", opts.artifact, err) + return errors.Wrapf(errors.KindUsage, "cannot read --artifact file %q: %v", opts.artifact, err) } // The uber-native flavor runs without Keycloak, which is what makes the // zero-config dry-run possible. Fail fast on other flavors. if !strings.Contains(opts.image, "-native") { - return fmt.Errorf("--dry-run requires the uber-native image variant (got %q). "+ + return errors.Wrapf(errors.KindUsage, "--dry-run requires the uber-native image variant (got %q). "+ "Use the default or pass --image with a *-native tag", opts.image) } return nil @@ -147,7 +147,7 @@ func runDryRunTest(opts dryRunOptions) error { progress := progressWriter(opts.params.outputFormat) if err := validateDryRunOptions(opts); err != nil { - return errors.Wrap(errors.KindUsage, err) + return err } // Select the container runtime (docker default, podman wired via DOCKER_HOST). @@ -228,7 +228,7 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr watcher, err := fsnotify.NewWatcher() if err != nil { - return fmt.Errorf("failed to create file watcher: %w", err) + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to create file watcher: %w", err)) } defer watcher.Close() @@ -236,10 +236,10 @@ 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 { - return fmt.Errorf("failed to resolve artifact path: %w", err) + return errors.Wrap(errors.KindUsage, fmt.Errorf("failed to resolve artifact path: %w", err)) } if err := watcher.Add(filepath.Dir(artifactPath)); err != nil { - return fmt.Errorf("failed to watch %s: %w", filepath.Dir(artifactPath), err) + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to watch %s: %w", filepath.Dir(artifactPath), err)) } fmt.Fprintf(progress, "\nWatching %s for changes — press Ctrl+C to stop.\n", opts.artifact) diff --git a/cmd/testQuery.go b/cmd/testQuery.go new file mode 100644 index 00000000..f60be7dc --- /dev/null +++ b/cmd/testQuery.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 cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "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" +) + +func newTestListCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var ( + serviceID string + page int + size int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List Microcks test results", + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + if page < 0 { + return errors.Wrapf(errors.KindUsage, "--page must be greater than or equal to 0") + } + if size <= 0 { + return errors.Wrapf(errors.KindUsage, "--size must be greater than 0") + } + + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err + } + + tests, err := mc.ListTestResults(serviceID, page, size) + if err != nil { + return err + } + + if outputFormat == "json" { + return output.WriteJSON(os.Stdout, tests) + } + return printTestResults(tests) + }, + } + cmd.Flags().StringVar(&serviceID, "serviceId", "", "Service id to filter tests") + cmd.Flags().IntVar(&page, "page", 0, "Page index to fetch") + cmd.Flags().IntVar(&size, "size", 50, "Number of test results to fetch") + cmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return cmd +} + +func newTestGetCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a Microcks test result", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + + mc, _, err := newCommandClient(globalClientOpts) + if err != nil { + return err + } + + result, err := mc.GetFullTestResult(args[0]) + if err != nil { + return err + } + + if outputFormat == "json" { + return output.WriteJSON(os.Stdout, result) + } + return printTestResults([]connectors.TestResultSummary{{ + 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, + }}) + }, + } + cmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return cmd +} + +func printTestResults(results []connectors.TestResultSummary) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + defer func() { _ = w.Flush() }() + if _, err := fmt.Fprintln(w, "ID\tSERVICE ID\tSUCCESS\tIN PROGRESS\tELAPSED"); err != nil { + return err + } + for _, result := range results { + if _, err := fmt.Fprintf(w, "%s\t%s\t%t\t%t\t%dms\n", result.ID, result.ServiceID, result.Success, result.InProgress, result.ElapsedTime); err != nil { + return err + } + } + return nil +} diff --git a/cmd/test_query_test.go b/cmd/test_query_test.go new file mode 100644 index 00000000..fc7e51f1 --- /dev/null +++ b/cmd/test_query_test.go @@ -0,0 +1,72 @@ +/* + * 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 cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTestListCommandOutputsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tests" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("serviceId"); got != "svc-1" { + t.Fatalf("unexpected serviceId: %s", got) + } + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": "test-1", + "serviceId": "svc-1", + "success": true, + }}) + })) + defer server.Close() + + out, err := executeCLIForTest(t, "test", "list", "--microcksURL", server.URL, "--serviceId", "svc-1", "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if !strings.Contains(out, `"id": "test-1"`) { + t.Fatalf("unexpected output: %s", out) + } +} + +func TestTestGetCommandOutputsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tests/test-1" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "test-1", + "serviceId": "svc-1", + "success": false, + }) + })) + defer server.Close() + + out, err := executeCLIForTest(t, "test", "get", "test-1", "--microcksURL", server.URL, "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if !strings.Contains(out, `"id": "test-1"`) { + t.Fatalf("unexpected output: %s", out) + } +} diff --git a/documentation/cmd/service.md b/documentation/cmd/service.md new file mode 100644 index 00000000..0be7771b --- /dev/null +++ b/documentation/cmd/service.md @@ -0,0 +1,43 @@ +## `microcks service` - List and Inspect Microcks Services +Lists services known by the selected Microcks server and retrieves service details. + +### Usage +```bash +microcks service list [flags] +microcks service get [flags] +``` + +### Examples +```bash +# List services from the current context +microcks service list + +# List services as JSON for tools and editor integrations +microcks service list --output json + +# Get service details by id +microcks service get 64f1d8c9e4b02c1c4d6c7a90 --output json + +# Get service details by name and version +microcks service get "E-Commerce Platform API:2.0.0" --output json +``` + +### Options +| Flag | Description | +| ---------- | ------------------------------------------------ | +| `-h, --help` | help for service | +| `--output` | Output format: `text` (default) or `json` | +| `--page` | Page index to fetch for `service list` | +| `--size` | Number of services to fetch for `service list` | + +### Options Inherited from Parent Commands +| Flag | Description | +| ------------------------ | ------------------------------------------- | +| `--config` | Path to Microcks config file | +| `--microcks-context` | Name of the Microcks context to use | +| `--verbose` | Produce dumps of HTTP exchanges | +| `--insecure-tls` | Allow insecure HTTPS connections | +| `--caCerts` | Comma-separated paths of CA cert files | +| `--keycloakClientId` | Keycloak Realm Service Account ClientId | +| `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | +| `--microcksURL` | Microcks API URL | diff --git a/documentation/cmd/test.md b/documentation/cmd/test.md index 6cbe7d18..92791acd 100644 --- a/documentation/cmd/test.md +++ b/documentation/cmd/test.md @@ -4,6 +4,8 @@ Runs contract or integration tests against a deployed API using the selected run ### Usage ```bash microcks test [flags] +microcks test list [flags] +microcks test get [flags] ``` ### Example @@ -19,6 +21,15 @@ microcks test Beer Catalog API:0.9 http://localhost:9090/api/ POSTMAN \ --microcksURL \ --keycloakClientId \ --keycloakClientSecret \ + +# List recent test results as JSON +microcks test list --output json + +# List recent test results for a service +microcks test list --serviceId --output json + +# Get a full test result as JSON +microcks test get --output json ``` ### Runner Options @@ -36,6 +47,14 @@ One of: | `--oAuth2Context` | OAuth2 client context as JSON string | | `--output` | Output format: `text` (default), `json`, `yaml`, or `github-actions` | +### `test list` and `test get` Options +| Flag | Description | +| ------------- | ------------------------------------------------ | +| `--output` | Output format: `text` (default) or `json` | +| `--serviceId` | Service id to filter `test list` results | +| `--page` | Page index to fetch for `test list` | +| `--size` | Number of test results to fetch for `test list` | + ### Options Inherited from Parent Commands | Flag | Description | diff --git a/main.go b/main.go index 99ad6357..3a56849a 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,18 @@ +/* + * 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 main import ( diff --git a/pkg/config/file_permission_unix.go b/pkg/config/file_permission_unix.go index b22031d1..d474c397 100644 --- a/pkg/config/file_permission_unix.go +++ b/pkg/config/file_permission_unix.go @@ -1,5 +1,21 @@ //go:build !windows +/* + * 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 config import ( diff --git a/pkg/config/file_permission_windows.go b/pkg/config/file_permission_windows.go index e92c374f..42b3c6ba 100644 --- a/pkg/config/file_permission_windows.go +++ b/pkg/config/file_permission_windows.go @@ -1,5 +1,21 @@ //go:build windows +/* + * 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 config import ( diff --git a/pkg/config/localconfig.go b/pkg/config/localconfig.go index 1e5eeda6..6a3c728d 100644 --- a/pkg/config/localconfig.go +++ b/pkg/config/localconfig.go @@ -1,3 +1,18 @@ +/* + * 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 config import ( diff --git a/pkg/connectors/container_client.go b/pkg/connectors/container_client.go index d2bfb61c..cd626e05 100644 --- a/pkg/connectors/container_client.go +++ b/pkg/connectors/container_client.go @@ -1,3 +1,18 @@ +/* + * 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 connectors import ( diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index 395a055a..4fd01476 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -48,6 +48,9 @@ type MicrocksClient interface { HttpClient() *http.Client GetKeycloakURL() (string, error) SetOAuthToken(oauthToken string) + ListServices(page int, size int) ([]Service, error) + GetService(ref string) (*ServiceDetail, error) + ListTestResults(serviceID string, page int, size int) ([]TestResultSummary, error) 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) @@ -55,6 +58,28 @@ type MicrocksClient interface { DownloadArtifact(artifactURL string, mainArtifact bool, secret string) (string, error) } +// Service represents a Microcks service summary. +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Type string `json:"type"` + Operations []Operation `json:"operations,omitempty"` +} + +// Operation represents a Microcks service operation. +type Operation struct { + Name string `json:"name"` + Method string `json:"method,omitempty"` + ResourcePaths []string `json:"resourcePaths,omitempty"` +} + +// ServiceDetail represents the Microcks service detail response used by the UI. +type ServiceDetail struct { + Service Service `json:"service"` + MessagesMap map[string][]json.RawMessage `json:"messagesMap,omitempty"` +} + // TestResultSummary represents a simple view on Microcks TestResult type TestResultSummary struct { ID string `json:"id"` @@ -373,6 +398,111 @@ func (c *microcksClient) SetOAuthToken(oauthToken string) { c.AuthToken = oauthToken } +func (c *microcksClient) ListServices(page int, size int) ([]Service, error) { + values := url.Values{} + values.Set("page", strconv.Itoa(page)) + values.Set("size", strconv.Itoa(size)) + + var services []Service + if err := c.getJSON("services", values, &services, "Microcks for listing services"); err != nil { + return nil, err + } + return services, nil +} + +func (c *microcksClient) GetService(ref string) (*ServiceDetail, error) { + id := ref + if strings.Contains(ref, ":") { + serviceID, err := c.resolveServiceID(ref) + if err != nil { + return nil, err + } + id = serviceID + } + + var detail ServiceDetail + if err := c.getJSON("services/"+id, nil, &detail, "Microcks for getting service detail"); err != nil { + return nil, err + } + return &detail, nil +} + +func (c *microcksClient) ListTestResults(serviceID string, page int, size int) ([]TestResultSummary, error) { + values := url.Values{} + values.Set("page", strconv.Itoa(page)) + values.Set("size", strconv.Itoa(size)) + if serviceID != "" { + values.Set("serviceId", serviceID) + } + + var tests []TestResultSummary + if err := c.getJSON("tests", values, &tests, "Microcks for listing tests"); err != nil { + return nil, err + } + return tests, nil +} + +func (c *microcksClient) resolveServiceID(ref string) (string, error) { + name, version, ok := strings.Cut(ref, ":") + if !ok || name == "" || version == "" { + return "", errors.Wrapf(errors.KindUsage, "service reference %q must be :", ref) + } + services, err := c.ListServices(0, 100) + if err != nil { + return "", err + } + for _, service := range services { + if service.Name == name && service.Version == version { + return service.ID, nil + } + } + return "", errors.Wrapf(errors.KindNotFound, "service %q does not exist", ref) +} + +func (c *microcksClient) getJSON(path string, query url.Values, out any, dumpLabel string) error { + rel := &url.URL{Path: path} + if len(query) > 0 { + rel.RawQuery = query.Encode() + } + u := c.APIURL.ResolveReference(rel) + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AuthToken) + + config.DumpRequestIfRequired(dumpLabel, req, false) + + resp, err := c.httpClient.Do(req) + if err != nil { + return errors.Wrap(errors.KindConnection, err) + } + defer resp.Body.Close() + + config.DumpResponseIfRequired(dumpLabel, resp, true) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(errors.KindConnection, fmt.Errorf("reading Microcks response: %w", err)) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + kind := errors.KindAPI + if resp.StatusCode == http.StatusNotFound { + kind = errors.KindNotFound + } + return errors.Wrapf(kind, "Microcks returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + if err := json.Unmarshal(body, out); err != nil { + return errors.Wrap(errors.KindAPI, fmt.Errorf("parsing Microcks response: %w", err)) + } + return nil +} + 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"} @@ -447,77 +577,20 @@ 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) - - 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) - - // Dump request if verbose required. - config.DumpRequestIfRequired("Microcks for getting status", req, false) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, errors.Wrap(errors.KindConnection, err) - } - defer resp.Body.Close() - - // Dump response if verbose required. - config.DumpResponseIfRequired("Microcks for getting status test", resp, true) - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrap(errors.KindConnection, fmt.Errorf("reading test result response: %w", err)) - } - result := TestResultSummary{} - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse test result response: %w", err) + if err := c.getJSON("tests/"+testResultID, nil, &result, "Microcks for getting status"); err != nil { + return nil, err } - 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) + if err := c.getJSON("tests/"+testResultID, nil, &result, "Microcks for getting full test result"); err != nil { + return nil, err } - return &result, nil } diff --git a/pkg/connectors/microcks_client_test.go b/pkg/connectors/microcks_client_test.go index e3853bb1..98c20df9 100644 --- a/pkg/connectors/microcks_client_test.go +++ b/pkg/connectors/microcks_client_test.go @@ -1,6 +1,22 @@ +/* + * 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 connectors import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -8,6 +24,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/microcks/microcks-cli/pkg/errors" ) func TestUploadArtifactStreamsWithoutBuffering(t *testing.T) { @@ -108,3 +126,122 @@ func TestDownloadArtifactReturnsResponseBody(t *testing.T) { t.Fatalf("expected response body %q, got %q", expectedBody, msg) } } + +func TestListServicesFetchesServicesEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/services" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("unexpected page: %s", got) + } + if got := r.URL.Query().Get("size"); got != "25" { + t.Fatalf("unexpected size: %s", got) + } + _ = json.NewEncoder(w).Encode([]Service{{ + ID: "svc-1", + Name: "Catalog API", + Version: "1.0.0", + Type: "REST", + }}) + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + services, err := client.ListServices(1, 25) + if err != nil { + t.Fatalf("ListServices returned error: %v", err) + } + if len(services) != 1 || services[0].ID != "svc-1" { + t.Fatalf("unexpected services: %#v", services) + } +} + +func TestGetServiceResolvesNameVersionReference(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/services": + _ = json.NewEncoder(w).Encode([]Service{{ + ID: "svc-1", + Name: "Catalog API", + Version: "1.0.0", + Type: "REST", + }}) + case "/api/services/svc-1": + _ = json.NewEncoder(w).Encode(ServiceDetail{ + Service: Service{ + ID: "svc-1", + Name: "Catalog API", + Version: "1.0.0", + Type: "REST", + }, + }) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + detail, err := client.GetService("Catalog API:1.0.0") + if err != nil { + t.Fatalf("GetService returned error: %v", err) + } + if detail.Service.ID != "svc-1" { + t.Fatalf("unexpected service detail: %#v", detail) + } +} + +func TestListTestResultsFetchesTestsEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tests" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("serviceId"); got != "svc-1" { + t.Fatalf("unexpected serviceId: %s", got) + } + _ = json.NewEncoder(w).Encode([]TestResultSummary{{ + ID: "test-1", + ServiceID: "svc-1", + Success: true, + }}) + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + results, err := client.ListTestResults("svc-1", 0, 50) + if err != nil { + t.Fatalf("ListTestResults returned error: %v", err) + } + if len(results) != 1 || results[0].ID != "test-1" { + t.Fatalf("unexpected test results: %#v", results) + } +} + +func TestGetFullTestResultClassifiesNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "missing", http.StatusNotFound) + })) + defer server.Close() + + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } + _, err = client.GetFullTestResult("missing") + if err == nil { + t.Fatal("expected error, got nil") + } + if got := errors.KindOf(err); got != errors.KindNotFound { + t.Fatalf("KindOf = %v, want KindNotFound", got) + } +} diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 1f68dac1..f795bb51 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -1,3 +1,18 @@ +/* + * 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 errors import ( diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 68dd8bd9..d4861490 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -1,3 +1,18 @@ +/* + * 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 errors import ( diff --git a/pkg/output/json.go b/pkg/output/json.go new file mode 100644 index 00000000..b484e327 --- /dev/null +++ b/pkg/output/json.go @@ -0,0 +1,38 @@ +/* + * 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" + "fmt" + "io" +) + +// WriteJSON writes value as indented JSON followed by a newline. +func WriteJSON(w io.Writer, value any) error { + b, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(b)) + return err +} + +// IsTextOrJSON reports whether s is a supported output value for list/get +// commands that have not opted into the full test-result formatter set. +func IsTextOrJSON(s string) bool { + return s == "text" || s == "json" +} diff --git a/pkg/util/rand/rand.go b/pkg/util/rand/rand.go index 1e748bf9..23f4ee58 100644 --- a/pkg/util/rand/rand.go +++ b/pkg/util/rand/rand.go @@ -1,3 +1,18 @@ +/* + * 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 rand import ( diff --git a/pkg/util/util.go b/pkg/util/util.go index 5c3b6636..2446d4f4 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -1,3 +1,18 @@ +/* + * 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 util import ( diff --git a/pkg/watcher/executor.go b/pkg/watcher/executor.go index 151e9d5f..60207f88 100644 --- a/pkg/watcher/executor.go +++ b/pkg/watcher/executor.go @@ -1,3 +1,18 @@ +/* + * 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 watcher import ( diff --git a/pkg/watcher/watchManager.go b/pkg/watcher/watchManager.go index 6e0167c7..de0240c8 100644 --- a/pkg/watcher/watchManager.go +++ b/pkg/watcher/watchManager.go @@ -1,3 +1,18 @@ +/* + * 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 watcher import ( diff --git a/watcher/main.go b/watcher/main.go index cfa92eaf..3d9df491 100644 --- a/watcher/main.go +++ b/watcher/main.go @@ -1,3 +1,18 @@ +/* + * 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 main import ( From ade22a7a5a954957c3d1c6022fbdbdfbdcbce65f Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 4 Aug 2026 16:06:34 +0100 Subject: [PATCH 10/15] feat(capabilities): add capabilities command for editor discovery Signed-off-by: caesarsage --- README.md | 1 + cmd/capabilities.go | 104 ++++++++++++++++++++++++++++++ cmd/capabilities_test.go | 88 +++++++++++++++++++++++++ cmd/cmd.go | 1 + documentation/cmd/capabilities.md | 97 ++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 cmd/capabilities.go create mode 100644 cmd/capabilities_test.go create mode 100644 documentation/cmd/capabilities.md diff --git a/README.md b/README.md index 216738e4..64d8e014 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ microcks [command] [flags] | `login` | Log in to a Microcks instance using Keycloak credentials | [`login`](documentation/cmd/login.md) | | `logout` | Log out and remove authentication from a given context | [`logout`](documentation/cmd/logout.md) | | `context` | Manage CLI contexts (list, use, delete) | [`context`](documentation/cmd/context.md) | +| `capabilities` | List machine-readable CLI capabilities | [`capabilities`](documentation/cmd/capabilities.md) | | `start` | Start a local Microcks instance via Docker/Podman | [`start`](documentation/cmd/start.md) | | `stop` | Stop a local Microcks instance | [`stop`](documentation/cmd/stop.md) | | `import` | Import API spec files from local filesystem | [`import`](documentation/cmd/import.md) | diff --git a/cmd/capabilities.go b/cmd/capabilities.go new file mode 100644 index 00000000..c775f800 --- /dev/null +++ b/cmd/capabilities.go @@ -0,0 +1,104 @@ +/* + * 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 cmd + +import ( + "fmt" + "os" + + "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" + "github.com/microcks/microcks-cli/version" + "github.com/spf13/cobra" +) + +const capabilitiesSchemaVersion = "v1" + +var supportedCapabilities = []string{ + "auth.login", + "auth.login.sso", + "auth.logout", + "context.list", + "context.list.json", + "context.use", + "context.use.json", + "context.delete", + "context.delete.json", + "instance.start", + "instance.start.json", + "instance.stop", + "artifact.import.file", + "artifact.import.file.json", + "artifact.import.file.watch", + "artifact.import.directory", + "artifact.import.url", + "service.list.json", + "service.get.json", + "test.run", + "test.run.output.json", + "test.run.output.yaml", + "test.run.output.github-actions", + "test.dry-run", + "test.dry-run.watch", + "test.dry-run.watch.events.json", + "test.list.json", + "test.get.json", +} + +type capabilitiesDocument struct { + SchemaVersion string `json:"schemaVersion"` + CLIVersion string `json:"cliVersion"` + Capabilities []string `json:"capabilities"` +} + +func NewCapabilitiesCommand() *cobra.Command { + var outputFormat string + + command := &cobra.Command{ + Use: "capabilities", + Short: "List machine-readable Microcks CLI capabilities", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + + document := capabilitiesDocument{ + SchemaVersion: capabilitiesSchemaVersion, + CLIVersion: version.Version, + Capabilities: supportedCapabilities, + } + if outputFormat == "json" { + return errors.Wrap( + errors.KindEnvironment, + output.WriteJSON(os.Stdout, document), + ) + } + + for _, capability := range document.Capabilities { + if _, err := fmt.Fprintln(os.Stdout, capability); err != nil { + return errors.Wrap( + errors.KindEnvironment, + fmt.Errorf("writing capabilities output: %w", err), + ) + } + } + return nil + }, + } + command.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") + return command +} diff --git a/cmd/capabilities_test.go b/cmd/capabilities_test.go new file mode 100644 index 00000000..6fa3679f --- /dev/null +++ b/cmd/capabilities_test.go @@ -0,0 +1,88 @@ +/* + * 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 cmd + +import ( + "encoding/json" + "slices" + "testing" +) + +func TestCapabilitiesCommandOutputsJSON(t *testing.T) { + out, err := executeCLIForTest(t, "capabilities", "--output", "json") + if err != nil { + t.Fatalf("command returned error: %v", err) + } + + var document capabilitiesDocument + if err := json.Unmarshal([]byte(out), &document); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if document.SchemaVersion != capabilitiesSchemaVersion { + t.Fatalf("unexpected schema version: %s", document.SchemaVersion) + } + if document.CLIVersion == "" { + t.Fatal("expected a CLI version") + } + expectedCapabilities := []string{ + "auth.login", + "auth.login.sso", + "auth.logout", + "context.list", + "context.list.json", + "context.use", + "context.use.json", + "context.delete", + "context.delete.json", + "instance.start", + "instance.start.json", + "instance.stop", + "artifact.import.file", + "artifact.import.file.json", + "artifact.import.file.watch", + "artifact.import.directory", + "artifact.import.url", + "service.list.json", + "service.get.json", + "test.run", + "test.run.output.json", + "test.run.output.yaml", + "test.run.output.github-actions", + "test.dry-run", + "test.dry-run.watch", + "test.dry-run.watch.events.json", + "test.list.json", + "test.get.json", + } + if !slices.Equal(document.Capabilities, expectedCapabilities) { + t.Fatalf("unexpected capabilities:\n got: %#v\nwant: %#v", document.Capabilities, expectedCapabilities) + } + + seen := make(map[string]struct{}, len(document.Capabilities)) + for _, capability := range document.Capabilities { + if _, duplicate := seen[capability]; duplicate { + t.Errorf("duplicate capability %q", capability) + } + seen[capability] = struct{}{} + } +} + +func TestCapabilitiesCommandRejectsUnsupportedOutput(t *testing.T) { + _, err := executeCLIForTest(t, "capabilities", "--output", "yaml") + if err == nil { + t.Fatal("expected unsupported output format to fail") + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go index be9cdfc2..5f2050ba 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -43,6 +43,7 @@ func NewCommand() (*cobra.Command, error) { command.AddCommand(NewImportCommand(&clientOpts)) command.AddCommand(NewImportDirCommand(&clientOpts)) command.AddCommand(NewVersionCommand()) + command.AddCommand(NewCapabilitiesCommand()) command.AddCommand(NewTestCommand(&clientOpts)) command.AddCommand(NewServiceCommand(&clientOpts)) command.AddCommand(NewImportURLCommand(&clientOpts)) diff --git a/documentation/cmd/capabilities.md b/documentation/cmd/capabilities.md new file mode 100644 index 00000000..2f3746e8 --- /dev/null +++ b/documentation/cmd/capabilities.md @@ -0,0 +1,97 @@ +## `microcks capabilities` - List CLI capabilities + +Lists stable capability identifiers that integrations can use to detect +whether a Microcks CLI release supports the commands they require. +Capability identifiers describe public workflows and machine-readable +contracts; they are not a copy of every CLI flag. +The command runs locally and does not require a Microcks server or a configured +context. + +```sh +microcks capabilities --output json +``` + +Example output: + +```json +{ + "schemaVersion": "v1", + "cliVersion": "1.0.3", + "capabilities": [ + "auth.login", + "auth.login.sso", + "auth.logout", + "context.list", + "context.list.json", + "context.use", + "context.use.json", + "context.delete", + "context.delete.json", + "instance.start", + "instance.start.json", + "instance.stop", + "artifact.import.file", + "artifact.import.file.json", + "artifact.import.file.watch", + "artifact.import.directory", + "artifact.import.url", + "service.list.json", + "service.get.json", + "test.run", + "test.run.output.json", + "test.run.output.yaml", + "test.run.output.github-actions", + "test.dry-run", + "test.dry-run.watch", + "test.dry-run.watch.events.json", + "test.list.json", + "test.get.json" + ] +} +``` + +### Capability identifiers + +| Capability | Available workflow or contract | +| --- | --- | +| `auth.login` | Log in with username and password | +| `auth.login.sso` | Log in through the browser-based SSO flow | +| `auth.logout` | Remove authentication for a context | +| `context.list` | List configured contexts as text | +| `context.list.json` | List configured contexts using the stable JSON contract | +| `context.use` | Select the current context | +| `context.use.json` | Select a context and return the selection as JSON | +| `context.delete` | Delete a configured context | +| `context.delete.json` | Delete a context and return the result as JSON | +| `instance.start` | Start a local Microcks container | +| `instance.start.json` | Start an instance and return its server/context as JSON | +| `instance.stop` | Stop a local Microcks container | +| `artifact.import.file` | Import one or more local artifact files | +| `artifact.import.file.json` | Import local artifacts and return their identifiers as JSON | +| `artifact.import.file.watch` | Re-import local artifacts when files change | +| `artifact.import.directory` | Import artifacts discovered in a directory | +| `artifact.import.url` | Import artifacts from remote URLs | +| `service.list.json` | List services using the stable JSON contract | +| `service.get.json` | Retrieve service details using the stable JSON contract | +| `test.run` | Run a test against a target endpoint | +| `test.run.output.json` | Render a test result as JSON | +| `test.run.output.yaml` | Render a test result as YAML | +| `test.run.output.github-actions` | Render annotations for GitHub Actions | +| `test.dry-run` | Run a test with an ephemeral Microcks container | +| `test.dry-run.watch` | Re-run an ephemeral test when its artifact changes | +| `test.dry-run.watch.events.json` | Emit NDJSON lifecycle and result events while watching | +| `test.list.json` | List test results using the stable JSON contract | +| `test.get.json` | Retrieve a test result using the stable JSON contract | + +`test.dry-run.watch` describes the interactive text workflow. +`test.dry-run.watch.events.json` guarantees newline-delimited `ready`, +`imported`, `test-result`, `waiting`, `error`, and `stopped` events. + +Capabilities describe the behavior of the installed CLI binary. They do not +report optional features enabled by a particular Microcks server. + +### Options + +| Flag | Description | +| --- | --- | +| `--output` | Output format: `text` or `json` (default: `text`) | From aa74afe121b74d9dad63723cbf376b2e7fbd84a2 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 4 Aug 2026 16:06:34 +0100 Subject: [PATCH 11/15] feat(context): add JSON output and harden delete cleanup Signed-off-by: caesarsage --- cmd/context.go | 151 +++++++++++++++++++++++++++-------- cmd/context_test.go | 44 ++++++++++ cmd/service_test.go | 8 +- documentation/cmd/context.md | 11 ++- 4 files changed, 177 insertions(+), 37 deletions(-) diff --git a/cmd/context.go b/cmd/context.go index 696e460e..a535b3b9 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -17,19 +17,21 @@ package cmd import ( "fmt" - "log" "os" + "slices" "strings" "text/tabwriter" "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" ) func NewContextCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { var delete bool + var outputFormat string ctxCmd := &cobra.Command{ Use: "context [CONTEXT]", Aliases: []string{"ctx"}, @@ -43,20 +45,43 @@ microcks context http://localhost:8080 # Delete Microcks context microcks context http://localhost:8080 --delete`, RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } configPath := globalClientOpts.ConfigPath localCfg, err := config.ReadLocalConfig(configPath) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if delete { if len(args) == 0 { return errors.Wrapf(errors.KindUsage, "context --delete requires a CONTEXT argument") } - return deleteContext(args[0], configPath) + if err := deleteContext(args[0], configPath); err != nil { + return err + } + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, contextMutationResult{ + Name: args[0], + Action: "deleted", + })) + } + _, err = fmt.Printf("Context '%s' deleted\n", args[0]) + return errors.Wrap(errors.KindEnvironment, err) } if len(args) == 0 { - return printMicrocksContexts(configPath) + contexts, err := listMicrocksContexts(configPath) + if err != nil { + return err + } + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, contexts)) + } + if len(contexts) == 0 { + return errors.Wrapf(errors.KindUsage, "no contexts defined in %s", configPath) + } + return printMicrocksContexts(contexts) } ctxName := args[0] @@ -64,22 +89,21 @@ microcks context http://localhost:8080 --delete`, return errors.Wrapf(errors.KindUsage, "no contexts defined in %s", configPath) } if localCfg.CurrentContext == ctxName { - fmt.Printf("Already at context '%s'\n", localCfg.CurrentContext) - return nil + return writeContextSelection(outputFormat, localCfg, ctxName, "unchanged") } if _, err = localCfg.ResolveContext(ctxName); err != nil { return errors.Wrap(errors.KindNotFound, err) } localCfg.CurrentContext = ctxName if err := config.WriteLocalConfig(*localCfg, configPath); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } - fmt.Printf("Switched to context '%s'\n", localCfg.CurrentContext) - return nil + return writeContextSelection(outputFormat, localCfg, ctxName, "selected") }, } ctxCmd.Flags().BoolVarP(&delete, "delete", "d", false, "Delete a context") + ctxCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") return ctxCmd } @@ -87,64 +111,125 @@ microcks context http://localhost:8080 --delete`, func deleteContext(context, configPath string) error { localCfg, err := config.ReadLocalConfig(configPath) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if localCfg == nil { return errors.Wrapf(errors.KindUsage, "nothing to delete") } + contextIndex := slices.IndexFunc(localCfg.Contexts, func(ref config.ContextRef) bool { + return ref.Name == context + }) + if contextIndex < 0 { + return errors.Wrapf(errors.KindNotFound, "context %q does not exist", context) + } + resolved, err := localCfg.ResolveContext(context) + if err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } serverName, ok := localCfg.RemoveContext(context) if !ok { - return errors.Wrapf(errors.KindNotFound, "context %q does not exist", context) + return errors.Wrapf(errors.KindAPI, "context %q disappeared while deleting it", context) + } + userStillReferenced := slices.ContainsFunc(localCfg.Contexts, func(ref config.ContextRef) bool { + return ref.User == resolved.User.Name + }) + if !userStillReferenced && !localCfg.RemoveUser(resolved.User.Name) { + return errors.Wrapf(errors.KindAPI, "user %q referenced by context %q does not exist", resolved.User.Name, context) + } + serverStillReferenced := slices.ContainsFunc(localCfg.Contexts, func(ref config.ContextRef) bool { + return ref.Server == serverName + }) + if !serverStillReferenced && !localCfg.RemoveServer(serverName) { + return errors.Wrapf(errors.KindAPI, "server %q referenced by context %q does not exist", serverName, context) } - _ = localCfg.RemoveUser(context) - _ = localCfg.RemoveServer(serverName) if localCfg.IsEmpty() { if err := localCfg.DeleteLocalConfig(configPath); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } } else { if localCfg.CurrentContext == context { localCfg.CurrentContext = "" } if err := config.ValidateLocalConfig(*localCfg); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if err := config.WriteLocalConfig(*localCfg, configPath); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } } - fmt.Printf("Context '%s' deleted\n", context) return nil } -func printMicrocksContexts(configPath string) error { +type contextSummary struct { + Name string `json:"name"` + Server string `json:"server"` + Current bool `json:"current"` +} + +type contextMutationResult struct { + Name string `json:"name"` + Server string `json:"server,omitempty"` + Action string `json:"action"` +} + +func listMicrocksContexts(configPath string) ([]contextSummary, error) { localCfg, err := config.ReadLocalConfig(configPath) if err != nil { - return err + return nil, errors.Wrap(errors.KindEnvironment, err) } if localCfg == nil { - return errors.Wrapf(errors.KindUsage, "no contexts defined in %s", configPath) + return []contextSummary{}, nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer func() { _ = w.Flush() }() - columnNames := []string{"CURRENT", "NAME", "SERVER"} - if _, err = fmt.Fprintf(w, "%s\n", strings.Join(columnNames, "\t")); err != nil { - return err - } - + contexts := make([]contextSummary, 0, len(localCfg.Contexts)) for _, contextRef := range localCfg.Contexts { - context, err := localCfg.ResolveContext(contextRef.Name) + resolved, err := localCfg.ResolveContext(contextRef.Name) if err != nil { - log.Printf("Context '%s' had error: %v", contextRef.Name, err) + return nil, errors.Wrap(errors.KindEnvironment, fmt.Errorf("resolving context %q: %w", contextRef.Name, err)) } + contexts = append(contexts, contextSummary{ + Name: resolved.Name, + Server: resolved.Server.Server, + Current: localCfg.CurrentContext == resolved.Name, + }) + } + return contexts, nil +} + +func printMicrocksContexts(contexts []contextSummary) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + columnNames := []string{"CURRENT", "NAME", "SERVER"} + if _, err := fmt.Fprintf(w, "%s\n", strings.Join(columnNames, "\t")); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + for _, context := range contexts { prefix := " " - if localCfg.CurrentContext == context.Name { + if context.Current { prefix = "*" } - if _, err = fmt.Fprintf(w, "%s\t%s\t%s\n", prefix, context.Name, context.Server.Server); err != nil { - return err + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", prefix, context.Name, context.Server); err != nil { + return errors.Wrap(errors.KindEnvironment, err) } } - return nil + return errors.Wrap(errors.KindEnvironment, w.Flush()) +} + +func writeContextSelection(outputFormat string, localCfg *config.LocalConfig, name, action string) error { + resolved, err := localCfg.ResolveContext(name) + if err != nil { + return errors.Wrap(errors.KindNotFound, err) + } + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, contextMutationResult{ + Name: resolved.Name, + Server: resolved.Server.Server, + Action: action, + })) + } + if action == "unchanged" { + _, err = fmt.Printf("Already at context '%s'\n", name) + } else { + _, err = fmt.Printf("Switched to context '%s'\n", name) + } + return errors.Wrap(errors.KindEnvironment, err) } diff --git a/cmd/context_test.go b/cmd/context_test.go index 29cfaca8..599860f9 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -16,6 +16,7 @@ package cmd import ( + "encoding/json" "os" "testing" @@ -24,6 +25,49 @@ import ( "github.com/stretchr/testify/require" ) +func TestContextListOutputsJSON(t *testing.T) { + configPath := t.TempDir() + "/config.yaml" + require.NoError(t, os.WriteFile(configPath, []byte(testConfig), 0o600)) + + out, err := executeCLIForTest(t, "context", "--output", "json", "--config", configPath) + require.NoError(t, err) + + var contexts []contextSummary + require.NoError(t, json.Unmarshal([]byte(out), &contexts)) + require.Len(t, contexts, 2) + assert.Equal(t, "http://localhost:8083", contexts[1].Name) + assert.True(t, contexts[1].Current) +} + +func TestContextListOutputsEmptyJSONArrayWithoutConfig(t *testing.T) { + configPath := t.TempDir() + "/missing-config.yaml" + + out, err := executeCLIForTest(t, "context", "--output", "json", "--config", configPath) + require.NoError(t, err) + assert.JSONEq(t, "[]", out) +} + +func TestContextUseOutputsJSON(t *testing.T) { + configPath := t.TempDir() + "/config.yaml" + require.NoError(t, os.WriteFile(configPath, []byte(testConfig), 0o600)) + + out, err := executeCLIForTest( + t, + "context", + "http://localhost:8080", + "--output", + "json", + "--config", + configPath, + ) + require.NoError(t, err) + + var result contextMutationResult + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "selected", result.Action) + assert.Equal(t, "http://localhost:8080", result.Server) +} + const testConfig = `current-context: http://localhost:8083 contexts: - name: http://localhost:8080 diff --git a/cmd/service_test.go b/cmd/service_test.go index ed2ff5f3..1376457a 100644 --- a/cmd/service_test.go +++ b/cmd/service_test.go @@ -12,7 +12,7 @@ * 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 cmd @@ -22,6 +22,7 @@ import ( "net/http" "net/http/httptest" "os" + "slices" "strings" "testing" ) @@ -88,7 +89,10 @@ func executeCLIForTest(t *testing.T, args ...string) (string, error) { if err != nil { t.Fatalf("NewCommand returned error: %v", err) } - command.SetArgs(append(args, "--config", t.TempDir()+"/config.yaml")) + if !slices.Contains(args, "--config") { + args = append(args, "--config", t.TempDir()+"/config.yaml") + } + command.SetArgs(args) execErr := command.Execute() _ = w.Close() diff --git a/documentation/cmd/context.md b/documentation/cmd/context.md index 9a082737..4ea74704 100644 --- a/documentation/cmd/context.md +++ b/documentation/cmd/context.md @@ -16,12 +16,21 @@ microcks context/ctx http://localhost:8080 # Delete the context microcks context/ctx http://localhost:8080 --delete/-d + +# List contexts for editor and automation integrations +microcks context --output json ``` + +JSON mode writes an array of `{name, server, current}` objects. When no local +config exists yet, it writes `[]`; this is a valid disconnected state for +editor and automation consumers. + ### Options | Flag | Description | | -------------- | ---------------------------- | | `-d, --delete` | Delete the specified context | | `-h, --help` | help for context | +| `--output` | Output format: `text` or `json` | ### Options Inherited from Parent Commands | Flag | Description | @@ -35,5 +44,3 @@ microcks context/ctx http://localhost:8080 --delete/-d | `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | | `--microcksURL` | Microcks API URL | - - From b6d44304c7c9b449f2947a95bffe8daa2fa285d7 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 4 Aug 2026 16:06:34 +0100 Subject: [PATCH 12/15] feat(start): add JSON output and route progress to stderr Signed-off-by: caesarsage --- cmd/start.go | 77 +++++++++++++++++++++++------- documentation/cmd/start.md | 12 ++++- pkg/connectors/container_client.go | 26 ++++++++-- 3 files changed, 93 insertions(+), 22 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 79f8723a..ee5bf711 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -12,18 +12,20 @@ * 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 cmd import ( "fmt" "net/http" + "os" "time" "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" ) @@ -36,6 +38,7 @@ func NewStartCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command driver string readyTimeout time.Duration noWait bool + outputFormat string ) var startCmd = &cobra.Command{ Use: "start", @@ -52,19 +55,23 @@ microcks start --driver [driver you wnat either 'docker' or 'podman'] # Define name of your microcks container/instance microcks start --name [name of you container/instance]`, RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + progress := progressWriter(outputFormat) configFile := globalClientOpts.ConfigPath localConfig, err := config.ReadLocalConfig(configFile) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if localConfig == nil { localConfig = &config.LocalConfig{} } - instance, _ := localConfig.GetInstance(name) - if instance == nil { + instance, err := localConfig.GetInstance(name) + if err != nil { instance = &config.Instance{} } @@ -81,12 +88,17 @@ microcks start --name [name of you container/instance]`, return errors.Wrap(errors.KindEnvironment, err) } exists, err := containerClient.ContainerExists(instance.ContainerID) - containerClient.CloseClient() + closeErr := containerClient.CloseClient() if err != nil { return errors.Wrap(errors.KindEnvironment, err) } + if closeErr != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("closing container client: %w", closeErr)) + } if !exists { - fmt.Printf("Container for instance %s no longer exists, recreating it\n", name) + if _, err := fmt.Fprintf(progress, "Container for instance %s no longer exists, recreating it\n", name); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } instance.Status = "" instance.ContainerID = "" } @@ -94,39 +106,53 @@ microcks start --name [name of you container/instance]`, switch instance.Status { case "Running": - fmt.Printf("Microcks instance with name %s is already running", name) - return nil + server := fmt.Sprintf("http://localhost:%s", instance.Port) + return writeStartResult(outputFormat, instanceStartResult{ + Name: name, Server: server, Context: server, Status: "running", + }) case "Exited": containerClient, err := connectors.NewContainerClient(instance.Driver) if err != nil { return errors.Wrap(errors.KindEnvironment, err) } - defer containerClient.CloseClient() - if err := containerClient.StartContainer(instance.ContainerID); err != nil { + if closeErr := containerClient.CloseClient(); closeErr != nil { + return errors.Wrapf(errors.KindEnvironment, "failed to start container: %v; closing container client: %v", err, closeErr) + } return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to start container: %w", err)) } + if err := containerClient.CloseClient(); err != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("closing container client: %w", err)) + } instance.Status = "Running" default: containerClient, err := connectors.NewContainerClient(driver) if err != nil { return errors.Wrap(errors.KindEnvironment, err) } - defer containerClient.CloseClient() - containerId, err := containerClient.CreateContainer(connectors.ContainerOpts{ Image: imageName, Port: hostPort, Name: name, AutoRemove: autoRemove, + Output: progress, }) if err != nil { + if closeErr := containerClient.CloseClient(); closeErr != nil { + return errors.Wrapf(errors.KindEnvironment, "failed to create container: %v; closing container client: %v", err, closeErr) + } return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to create container: %w", err)) } if err := containerClient.StartContainer(containerId); err != nil { + if closeErr := containerClient.CloseClient(); closeErr != nil { + return errors.Wrapf(errors.KindEnvironment, "failed to start container: %v; closing container client: %v", err, closeErr) + } return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to start container: %w", err)) } + if err := containerClient.CloseClient(); err != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("closing container client: %w", err)) + } instance.ContainerID = containerId instance.AutoRemove = autoRemove @@ -179,22 +205,25 @@ microcks start --name [name of you container/instance]`, // Save configs to config file if err := config.WriteLocalConfig(*localConfig, configFile); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } // The container being up doesn't mean the Microcks server inside // is serving traffic yet: wait until HTTP is actually answering // so chained commands (import, test) don't race the boot. if !noWait { - fmt.Printf("Waiting for Microcks to be ready at %s ...\n", server) + if _, err := fmt.Fprintf(progress, "Waiting for Microcks to be ready at %s ...\n", server); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } if err := waitForReady(server, readyTimeout); err != nil { return errors.Wrapf(errors.KindEnvironment, "Microcks container is started but the server is not ready: %v. "+ "It may still be booting — retry shortly or raise --ready-timeout", err) } } - fmt.Printf("Microcks started successfully at %s\n", server) - return nil + return writeStartResult(outputFormat, instanceStartResult{ + Name: name, Server: server, Context: server, Status: "running", + }) }, } startCmd.Flags().StringVar(&name, "name", "microcks", "name for your Microcks instance") @@ -204,9 +233,25 @@ microcks start --name [name of you container/instance]`, startCmd.Flags().StringVar(&driver, "driver", "docker", "use --driver to change driver from docker to podman") startCmd.Flags().DurationVar(&readyTimeout, "ready-timeout", 60*time.Second, "how long to wait for the Microcks server to be ready before failing") startCmd.Flags().BoolVar(&noWait, "no-wait", false, "return as soon as the container is started, without waiting for the Microcks server to be ready") + startCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") return startCmd } +type instanceStartResult struct { + Name string `json:"name"` + Server string `json:"server"` + Context string `json:"context"` + Status string `json:"status"` +} + +func writeStartResult(outputFormat string, result instanceStartResult) error { + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, result)) + } + _, err := fmt.Printf("Microcks started successfully at %s\n", result.Server) + return errors.Wrap(errors.KindEnvironment, err) +} + // waitForReady polls the Microcks API until it answers with 200 or the // timeout elapses. HTTP being up is the signal users care about — the // Spring Boot app inside the container takes a while after the container diff --git a/documentation/cmd/start.md b/documentation/cmd/start.md index 65d8e899..e9c8dac1 100644 --- a/documentation/cmd/start.md +++ b/documentation/cmd/start.md @@ -15,15 +15,21 @@ microcks start microcks start --port [Port you want] # Define your driver (by default docker) -microcks start --driver [driver you wnat either 'docker' or 'podman'] +microcks start --driver [docker-or-podman] # Define name of your microcks container/instance microcks start --name [name of you container/instance] # Auto remove the container on exit microcks start --rm + +# Start and return the selected local context as JSON +microcks start --output json ``` +In JSON mode, stdout contains only the structured start result. Image-pull and +readiness progress is written to stderr. + ### Options | Flag | Description | | ----------- | -------------------------------------------------------------------------------- | @@ -33,6 +39,9 @@ microcks start --rm | `--image` | Container image to use (default: `quay.io/microcks/microcks-uber:latest-native`) | | `--rm` | Auto-remove the container when it exits (like Docker `--rm`) | | `--driver` | Container driver to use (`docker` or `podman`, default: `docker`) | +| `--ready-timeout` | How long to wait for Microcks to answer before failing (default: `1m`) | +| `--no-wait` | Return after the container starts without waiting for Microcks readiness | +| `--output` | Output format: `text` or `json` | ### Options Inherited from Parent Commands | Flag | Description | @@ -45,4 +54,3 @@ microcks start --rm | `--keycloakClientId` | Keycloak Realm Service Account ClientId | | `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | | `--microcksURL` | Microcks API URL | - diff --git a/pkg/connectors/container_client.go b/pkg/connectors/container_client.go index cd626e05..8defeab3 100644 --- a/pkg/connectors/container_client.go +++ b/pkg/connectors/container_client.go @@ -18,6 +18,7 @@ package connectors import ( "context" "fmt" + "io" "os" "os/exec" "runtime" @@ -49,6 +50,7 @@ type ContainerOpts struct { Port string AutoRemove bool Name string + Output io.Writer } const ( @@ -136,7 +138,7 @@ func NewPodmanClient() (*containerClient, error) { return &containerClient{cli: cli}, nil } -func (cli *containerClient) CreateContainer(opts ContainerOpts) (string, error) { +func (cli *containerClient) CreateContainer(opts ContainerOpts) (containerID string, resultErr error) { ctx := context.Background() // Define exposed port and bindings @@ -154,13 +156,29 @@ func (cli *containerClient) CreateContainer(opts ContainerOpts) (string, error) if err != nil { return "", err } - defer out.Close() + defer func() { + if err := out.Close(); err != nil { + if resultErr == nil { + resultErr = fmt.Errorf("closing image pull stream: %w", err) + } else { + resultErr = fmt.Errorf("%v; closing image pull stream: %w", resultErr, err) + } + } + }() - fd, isTerminal := term.GetFdInfo(os.Stdout) + progress := opts.Output + if progress == nil { + progress = os.Stdout + } + var fd uintptr + var isTerminal bool + if outputFile, ok := progress.(*os.File); ok { + fd, isTerminal = term.GetFdInfo(outputFile) + } err = jsonmessage.DisplayJSONMessagesStream( out, - os.Stdout, + progress, fd, isTerminal, nil, From 65e3ccf659693881c4c6b5e5d91bd2f7f881f484 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 4 Aug 2026 16:06:34 +0100 Subject: [PATCH 13/15] feat(import): add JSON output Signed-off-by: caesarsage --- cmd/import.go | 48 ++++++++++++++++++++++++++++++------- documentation/cmd/import.md | 1 + 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/cmd/import.go b/cmd/import.go index 0bb9e98a..d52861d3 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -17,18 +17,21 @@ package cmd import ( "fmt" + "os" "strconv" "strings" "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/microcks/microcks-cli/pkg/watcher" "github.com/spf13/cobra" ) func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { var watch bool + var outputFormat string var importCmd = &cobra.Command{ Use: "import", @@ -36,6 +39,12 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command Long: `import API artifacts on Microcks server`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if !output.IsTextOrJSON(outputFormat) { + return errors.Wrapf(errors.KindUsage, "--output must be one of: text, json") + } + if watch && outputFormat == "json" { + return errors.Wrapf(errors.KindUsage, "--output json is not supported with --watch") + } // Parse subcommand args first. if len(args) == 0 { return errors.Wrapf(errors.KindUsage, "import requires a argument") @@ -51,7 +60,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // Read local config file in case we need some context info. localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } // Prepare Microcks client. @@ -115,6 +124,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // Handle multiple specification files separated by comma. sepSpecificationFiles := strings.Split(specificationFiles, ",") + results := make([]artifactImportResult, 0, len(sepSpecificationFiles)) for _, f := range sepSpecificationFiles { mainArtifact := true var err error @@ -125,7 +135,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command f = pathAndMainArtifact[0] mainArtifact, err = strconv.ParseBool(pathAndMainArtifact[1]) if err != nil { - fmt.Printf("Cannot parse '%s' as Bool, default to true\n", pathAndMainArtifact[1]) + return errors.Wrapf(errors.KindUsage, "cannot parse %q as artifact primary flag", pathAndMainArtifact[1]) } } @@ -138,18 +148,25 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command if !mainArtifact { action = "completed" } - fmt.Printf("Microcks has %s '%s'\n", action, msg) + results = append(results, artifactImportResult{ + File: f, ID: msg, Primary: mainArtifact, Action: action, + }) + if outputFormat == "text" { + if _, err := fmt.Printf("Microcks has %s '%s'\n", action, msg); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } // If watch flag is provided, update watch config. if watch { watchFile, err := config.DefaultLocalWatchPath() if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } watchCfg, err := config.ReadLocalWatchConfig(watchFile) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } if watchCfg == nil { watchCfg = &config.WatchConfig{} @@ -169,7 +186,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // Write watch file. if err := config.WriteLocalWatchConfig(*watchCfg, watchFile); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } } } @@ -178,21 +195,34 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command if watch { watchFile, err := config.DefaultLocalWatchPath() if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } wm, err := watcher.NewWatchManger(watchFile) if err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } - fmt.Println("Watch mode enabled - microcks-watcher started...") + if _, err := fmt.Println("Watch mode enabled - microcks-watcher started..."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } wm.Run() } + if outputFormat == "json" { + return errors.Wrap(errors.KindEnvironment, output.WriteJSON(os.Stdout, results)) + } return nil }, } importCmd.Flags().BoolVar(&watch, "watch", false, "Keep watch on file changes and re-import it on change") + importCmd.Flags().StringVar(&outputFormat, "output", "text", "Output format: text or json") return importCmd } + +type artifactImportResult struct { + File string `json:"file"` + ID string `json:"id"` + Primary bool `json:"primary"` + Action string `json:"action"` +} diff --git a/documentation/cmd/import.md b/documentation/cmd/import.md index 31eb189f..16e30d09 100644 --- a/documentation/cmd/import.md +++ b/documentation/cmd/import.md @@ -32,6 +32,7 @@ microcks import ./api.yaml --microcksURL | ----------- | --------------------------------------------------- | | `-h, --help`| help for import | | `--watch` | Watch the file(s) and auto-reimport them on changes | +| `--output` | Output format: `text` or `json` (`json` cannot be combined with `--watch`) | ### Options Inherited from Parent Commands | Flag | Description | From 09000fae863ebe78bc89a8fc8ce0c7041945a54e Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 4 Aug 2026 16:06:34 +0100 Subject: [PATCH 14/15] feat(test): stream dry-run watch results as JSON events Signed-off-by: caesarsage --- cmd/testDryRun.go | 200 ++++++++++++++++++++++++++++++----- cmd/testDryRunEvents.go | 60 +++++++++++ cmd/testDryRunEvents_test.go | 48 +++++++++ cmd/testExecutor.go | 16 ++- documentation/cmd/test.md | 14 +++ 5 files changed, 307 insertions(+), 31 deletions(-) create mode 100644 cmd/testDryRunEvents.go create mode 100644 cmd/testDryRunEvents_test.go diff --git a/cmd/testDryRun.go b/cmd/testDryRun.go index 1862395c..69b2adcf 100644 --- a/cmd/testDryRun.go +++ b/cmd/testDryRun.go @@ -32,6 +32,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" + "github.com/microcks/microcks-cli/pkg/output" "github.com/testcontainers/testcontainers-go" microcks "microcks.io/testcontainers-go" ) @@ -141,10 +142,25 @@ func rewriteLocalEndpoint(testEndpoint string) (string, int, bool) { return u.String(), port, true } -func runDryRunTest(opts dryRunOptions) error { +func runDryRunTest(opts dryRunOptions) (resultErr error) { // Progress/diagnostics go to stderr for machine output formats so stdout // carries only the formatted result. progress := progressWriter(opts.params.outputFormat) + eventMode := opts.watch && opts.params.outputFormat == string(output.FormatJSON) + var events *dryRunEventWriter + if eventMode { + events = newDryRunEventWriter(os.Stdout) + defer func() { + if err := events.emit(dryRunWatchEvent{Type: "stopped"}); err != nil { + resultErr = errors.Wrapf( + errors.KindEnvironment, + "writing dry-run stopped event: %v (previous error: %v)", + err, + resultErr, + ) + } + }() + } if err := validateDryRunOptions(opts); err != nil { return err @@ -166,30 +182,62 @@ func runDryRunTest(opts dryRunOptions) error { // 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.Fprintf(progress, "Test endpoint %s is local: reaching it from the container as %s\n", opts.params.testEndpoint, rewritten) + if _, err := fmt.Fprintf(progress, "Test endpoint %s is local: reaching it from the container as %s\n", opts.params.testEndpoint, rewritten); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } opts.params.testEndpoint = rewritten containerOpts = append(containerOpts, testcontainers.WithHostPortAccess(hostPort)) } - fmt.Fprintf(progress, "Starting ephemeral Microcks container (%s)...\n", opts.image) + if _, err := fmt.Fprintf(progress, "Starting ephemeral Microcks container (%s)...\n", opts.image); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } startCtx, startCancel := context.WithTimeout(ctx, opts.readyTimeout) defer startCancel() container, err := microcks.Run(startCtx, opts.image, containerOpts...) if err != nil { if container != nil { - terminateContainer(container, progress) + if terminateErr := terminateContainer(container, progress); terminateErr != nil { + return errors.Wrapf( + errors.KindEnvironment, + "failed to start ephemeral Microcks container: %v; cleanup also failed: %v", + err, + terminateErr, + ) + } } return errors.Wrapf(errors.KindEnvironment, "failed to start ephemeral Microcks container: %v. "+ "Check that the container runtime is running, the port is free and the image is reachable (or raise --ready-timeout)", err) } - defer terminateContainer(container, progress) + defer func() { + if err := terminateContainer(container, progress); err != nil { + resultErr = errors.Wrapf( + errors.KindEnvironment, + "tearing down ephemeral Microcks container: %v (previous error: %v)", + err, + resultErr, + ) + } + }() endpoint, err := container.HttpEndpoint(ctx) if err != nil { return errors.Wrapf(errors.KindEnvironment, "failed to resolve ephemeral Microcks endpoint: %v", err) } - fmt.Fprintf(progress, "Ephemeral Microcks is ready at %s\n", endpoint) + if _, err := fmt.Fprintf(progress, "Ephemeral Microcks is ready at %s\n", endpoint); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + if events != nil { + if err := events.emit(dryRunWatchEvent{Type: "ready", Endpoint: endpoint}); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + if err := events.emit(dryRunWatchEvent{ + Type: "imported", Artifact: opts.artifact, Service: opts.params.serviceRef, + }); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } // The uber-native image runs without Keycloak: a headless client with // the unauthenticated token is enough. @@ -199,10 +247,23 @@ func runDryRunTest(opts dryRunOptions) error { } mc.SetOAuthToken("unauthenticated-token") - success, testResultID, err := runTestAndWait(mc, opts.params) + params := opts.params + params.suppressOutput = eventMode + success, testResultID, err := runTestAndWait(mc, params) if err != nil { + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } return err } + if events != nil { + if err := events.emitTestResult(mc, testResultID); err != nil { + return errors.Wrap(errors.KindAPI, err) + } + if err := events.emit(dryRunWatchEvent{Type: "waiting"}); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } if !opts.watch { if success { @@ -210,27 +271,52 @@ func runDryRunTest(opts dryRunOptions) error { } return errors.ErrTestFailed } - printDetailsLink(progress, endpoint, testResultID) - return watchAndRerun(ctx, mc, endpoint, opts) + if err := printDetailsLink(progress, endpoint, testResultID); err != nil { + return err + } + return watchAndRerun(ctx, mc, endpoint, opts, events) } -func terminateContainer(container *microcks.MicrocksContainer, progress io.Writer) { +func terminateContainer(container *microcks.MicrocksContainer, progress io.Writer) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - fmt.Fprintln(progress, "Tearing down ephemeral Microcks container...") + if _, err := fmt.Fprintln(progress, "Tearing down ephemeral Microcks container..."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } if err := container.Terminate(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Failed to terminate container %s: %s\n", container.GetContainerID(), err) + return errors.Wrapf( + errors.KindEnvironment, + "failed to terminate container %s: %v", + container.GetContainerID(), + err, + ) } + return nil } -func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr string, opts dryRunOptions) error { +func watchAndRerun( + ctx context.Context, + mc connectors.MicrocksClient, + serverAddr string, + opts dryRunOptions, + events *dryRunEventWriter, +) (resultErr error) { progress := progressWriter(opts.params.outputFormat) watcher, err := fsnotify.NewWatcher() if err != nil { return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to create file watcher: %w", err)) } - defer watcher.Close() + defer func() { + if err := watcher.Close(); err != nil { + resultErr = errors.Wrapf( + errors.KindEnvironment, + "closing file watcher: %v (previous error: %v)", + err, + resultErr, + ) + } + }() // Watch the directory, not the file: editors replace files on save // (rename + create), which silently drops a watch set on the file itself. @@ -242,7 +328,9 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to watch %s: %w", filepath.Dir(artifactPath), err)) } - fmt.Fprintf(progress, "\nWatching %s for changes — press Ctrl+C to stop.\n", opts.artifact) + if _, err := fmt.Fprintf(progress, "\nWatching %s for changes — press Ctrl+C to stop.\n", opts.artifact); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } rerun := make(chan struct{}, 1) var debounce *time.Timer @@ -250,7 +338,9 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr for { select { case <-ctx.Done(): - fmt.Fprintln(progress, "\nStopping watch mode.") + if _, err := fmt.Fprintln(progress, "\nStopping watch mode."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } return nil case event, ok := <-watcher.Events: @@ -279,32 +369,88 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr if !ok { return nil } - fmt.Fprintf(os.Stderr, "Watch error: %s\n", err) + if _, writeErr := fmt.Fprintf(os.Stderr, "Watch error: %s\n", err); writeErr != nil { + return errors.Wrap(errors.KindEnvironment, writeErr) + } + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } case <-rerun: - fmt.Fprintln(progress, strings.Repeat("-", 60)) - fmt.Fprintf(progress, "Artifact changed, re-importing %s ...\n", opts.artifact) + if _, err := fmt.Fprintln(progress, strings.Repeat("-", 60)); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + if _, err := fmt.Fprintf(progress, "Artifact changed, re-importing %s ...\n", opts.artifact); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } 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.Fprintf(os.Stderr, "Re-import failed, waiting for next change: %s\n", err) + if _, writeErr := fmt.Fprintf(os.Stderr, "Re-import failed, waiting for next change: %s\n", err); writeErr != nil { + return errors.Wrap(errors.KindEnvironment, writeErr) + } + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } continue } - success, testResultID, err := runTestAndWait(mc, opts.params) + if events != nil { + if err := events.emit(dryRunWatchEvent{ + Type: "imported", Artifact: opts.artifact, Service: opts.params.serviceRef, + }); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } + params := opts.params + params.suppressOutput = events != nil + success, testResultID, err := runTestAndWait(mc, params) if err != nil { - fmt.Fprintf(os.Stderr, "Test run failed, waiting for next change: %s\n", err) + if _, writeErr := fmt.Fprintf(os.Stderr, "Test run failed, waiting for next change: %s\n", err); writeErr != nil { + return errors.Wrap(errors.KindEnvironment, writeErr) + } + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } continue } - printDetailsLink(progress, serverAddr, testResultID) + if events != nil { + if err := events.emitTestResult(mc, testResultID); err != nil { + if emitErr := emitDryRunError(events, err); emitErr != nil { + return emitErr + } + continue + } + if err := events.emit(dryRunWatchEvent{Type: "waiting"}); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } + } + if err := printDetailsLink(progress, serverAddr, testResultID); err != nil { + return err + } if success { - fmt.Fprintln(progress, "Contract test PASSED — waiting for next change.") + if _, err := fmt.Fprintln(progress, "Contract test PASSED — waiting for next change."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } } else { - fmt.Fprintln(progress, "Contract test FAILED — waiting for next change.") + if _, err := fmt.Fprintln(progress, "Contract test FAILED — waiting for next change."); err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } } } } } -func printDetailsLink(progress io.Writer, serverAddr, testResultID string) { - fmt.Fprintf(progress, "Test details (live while watching): %s/#/tests/%s\n", serverAddr, testResultID) +func emitDryRunError(events *dryRunEventWriter, sourceErr error) error { + if events == nil { + return nil + } + if err := events.emit(dryRunWatchEvent{Type: "error", Message: sourceErr.Error()}); err != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("writing dry-run error event: %w", err)) + } + return nil +} + +func printDetailsLink(progress io.Writer, serverAddr, testResultID string) error { + _, err := fmt.Fprintf(progress, "Test details (live while watching): %s/#/tests/%s\n", serverAddr, testResultID) + return errors.Wrap(errors.KindEnvironment, err) } diff --git a/cmd/testDryRunEvents.go b/cmd/testDryRunEvents.go new file mode 100644 index 00000000..a713f019 --- /dev/null +++ b/cmd/testDryRunEvents.go @@ -0,0 +1,60 @@ +/* + * 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 cmd + +import ( + "encoding/json" + "io" + "time" + + "github.com/microcks/microcks-cli/pkg/connectors" +) + +type dryRunWatchEvent struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Endpoint string `json:"endpoint,omitempty"` + Artifact string `json:"artifact,omitempty"` + Service string `json:"service,omitempty"` + TestResultID string `json:"testResultId,omitempty"` + Result *connectors.TestResult `json:"result,omitempty"` + Message string `json:"message,omitempty"` +} + +type dryRunEventWriter struct { + encoder *json.Encoder +} + +func newDryRunEventWriter(w io.Writer) *dryRunEventWriter { + return &dryRunEventWriter{encoder: json.NewEncoder(w)} +} + +func (w *dryRunEventWriter) emit(event dryRunWatchEvent) error { + event.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) + return w.encoder.Encode(event) +} + +func (w *dryRunEventWriter) emitTestResult(mc connectors.MicrocksClient, testResultID string) error { + result, err := mc.GetFullTestResult(testResultID) + if err != nil { + return err + } + return w.emit(dryRunWatchEvent{ + Type: "test-result", + TestResultID: testResultID, + Result: result, + }) +} diff --git a/cmd/testDryRunEvents_test.go b/cmd/testDryRunEvents_test.go new file mode 100644 index 00000000..38758b8e --- /dev/null +++ b/cmd/testDryRunEvents_test.go @@ -0,0 +1,48 @@ +/* + * 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 cmd + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestDryRunEventWriterEmitsOneJSONDocumentPerLine(t *testing.T) { + var buffer bytes.Buffer + events := newDryRunEventWriter(&buffer) + if err := events.emit(dryRunWatchEvent{Type: "ready", Endpoint: "http://localhost:1234"}); err != nil { + t.Fatalf("emit returned error: %v", err) + } + if err := events.emit(dryRunWatchEvent{Type: "waiting"}); err != nil { + t.Fatalf("emit returned error: %v", err) + } + + lines := strings.Split(strings.TrimSpace(buffer.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2: %q", len(lines), buffer.String()) + } + for _, line := range lines { + var event dryRunWatchEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Fatalf("line is not JSON: %v", err) + } + if event.Timestamp == "" { + t.Fatal("event timestamp is empty") + } + } +} diff --git a/cmd/testExecutor.go b/cmd/testExecutor.go index 6334340a..fa4de4f5 100644 --- a/cmd/testExecutor.go +++ b/cmd/testExecutor.go @@ -22,6 +22,7 @@ import ( "time" "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" "github.com/microcks/microcks-cli/pkg/output" ) @@ -38,6 +39,7 @@ type testParams struct { oAuth2Context string outputFormat string artifactPath string + suppressOutput bool } // progressWriter returns where human progress/diagnostics should go. For @@ -78,18 +80,24 @@ func runTestAndWait(mc connectors.MicrocksClient, params testParams) (bool, stri } success = testResultSummary.Success inProgress := testResultSummary.InProgress - fmt.Fprintf(progress, "MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)) + if _, err := fmt.Fprintf(progress, "MicrocksClient got status for test \"%s\" - success: %s, inProgress: %s \n", testResultID, fmt.Sprint(success), fmt.Sprint(inProgress)); err != nil { + return false, testResultID, errors.Wrap(errors.KindEnvironment, err) + } if !inProgress { break } - fmt.Fprintln(progress, "MicrocksTester waiting for 2 seconds before checking again or exiting.") + if _, err := fmt.Fprintln(progress, "MicrocksTester waiting for 2 seconds before checking again or exiting."); err != nil { + return false, testResultID, errors.Wrap(errors.KindEnvironment, err) + } time.Sleep(2 * time.Second) } - if err := renderTestResult(mc, testResultID, params.outputFormat, params.artifactPath); err != nil { - return false, testResultID, err + if !params.suppressOutput { + if err := renderTestResult(mc, testResultID, params.outputFormat, params.artifactPath); err != nil { + return false, testResultID, err + } } return success, testResultID, nil diff --git a/documentation/cmd/test.md b/documentation/cmd/test.md index 92791acd..7212b684 100644 --- a/documentation/cmd/test.md +++ b/documentation/cmd/test.md @@ -67,3 +67,17 @@ One of: | `--keycloakClientId` | Keycloak Realm Service Account ClientId | | `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | | `--microcksURL` | Microcks API URL | + +### Structured output contracts + +`test list --output json` writes a JSON array of test-result summaries. +`test get --output json` writes one complete test result, including its +`testCaseResults` when present. Integrations should check for the +`test.list.json` and `test.get.json` capabilities before depending on these +contracts. + +`microcks test ... --output json` and one-shot dry-run tests write the completed +test result to stdout while progress and diagnostics go to stderr. Dry-run +watch mode writes one JSON event per line. Consumers should require +`test.dry-run.watch.events.json` and handle `ready`, `imported`, `test-result`, +`waiting`, `error`, and `stopped`. From e6b9171dc3546e6b2194ace94e4acb7775c797a7 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 4 Aug 2026 16:06:34 +0100 Subject: [PATCH 15/15] docs(service): document service list/get JSON contracts Signed-off-by: caesarsage --- documentation/cmd/service.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/documentation/cmd/service.md b/documentation/cmd/service.md index 0be7771b..433d7f28 100644 --- a/documentation/cmd/service.md +++ b/documentation/cmd/service.md @@ -41,3 +41,14 @@ microcks service get "E-Commerce Platform API:2.0.0" --output json | `--keycloakClientId` | Keycloak Realm Service Account ClientId | | `--keycloakClientSecret` | Keycloak Realm Service Account ClientSecret | | `--microcksURL` | Microcks API URL | + +### JSON contracts + +`service list --output json` writes a JSON array of service summaries. Each +summary includes `id`, `name`, `version`, and `type`, and may include +`operations`. + +`service get --output json` writes an object containing `service` and, when +available, `messagesMap`. Integrations should check for the +`service.list.json` and `service.get.json` capabilities before depending on +these contracts.