Skip to content

Commit 6bd82fc

Browse files
committed
feat(output): add output formatter framework (text/json/yaml/github-actions)
Signed-off-by: caesarsage <destinyerhabor6@gmail.com>
1 parent 2969a4c commit 6bd82fc

6 files changed

Lines changed: 478 additions & 0 deletions

File tree

pkg/output/formatter.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright The Microcks Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
// Package output renders a completed Microcks TestResult in a selectable format
18+
// (text, json, yaml, github-actions) for the `microcks test --output` flag.
19+
package output
20+
21+
import (
22+
"fmt"
23+
24+
"github.com/microcks/microcks-cli/pkg/connectors"
25+
)
26+
27+
// OutputFormat is a supported value of the --output flag.
28+
type OutputFormat string
29+
30+
const (
31+
FormatText OutputFormat = "text"
32+
FormatJSON OutputFormat = "json"
33+
FormatYAML OutputFormat = "yaml"
34+
FormatGitHubActions OutputFormat = "github-actions"
35+
)
36+
37+
// Formatter renders a completed TestResult for a chosen output target. The
38+
// returned string is written to stdout by the caller; formatters that also have
39+
// side effects (e.g. github-actions writing the job step summary) perform them
40+
// during Format.
41+
type Formatter interface {
42+
Format(result *connectors.TestResult) (string, error)
43+
}
44+
45+
// NewFormatter returns the Formatter for the given format.
46+
func NewFormatter(format OutputFormat) (Formatter, error) {
47+
switch format {
48+
case FormatText:
49+
return &TextFormatter{}, nil
50+
case FormatJSON:
51+
return &JSONFormatter{}, nil
52+
case FormatYAML:
53+
return &YAMLFormatter{}, nil
54+
case FormatGitHubActions:
55+
return &GitHubActionsFormatter{}, nil
56+
default:
57+
return nil, fmt.Errorf("unsupported output format %q (use: text, json, yaml, github-actions)", format)
58+
}
59+
}
60+
61+
// IsValid reports whether s is a supported output format.
62+
func IsValid(s string) bool {
63+
switch OutputFormat(s) {
64+
case FormatText, FormatJSON, FormatYAML, FormatGitHubActions:
65+
return true
66+
}
67+
return false
68+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright The Microcks Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package output
17+
18+
import (
19+
"fmt"
20+
"os"
21+
"strings"
22+
23+
"github.com/microcks/microcks-cli/pkg/connectors"
24+
)
25+
26+
// GitHubActionsFormatter renders the result as GitHub Actions workflow commands:
27+
// a collapsible ::group:: per operation, ::error:: annotations for failures
28+
// (and ::notice:: for passes when MICROCKS_ACTIONS_VERBOSE is set), plus a
29+
// markdown table appended to $GITHUB_STEP_SUMMARY.
30+
type GitHubActionsFormatter struct{}
31+
32+
func (f *GitHubActionsFormatter) Format(r *connectors.TestResult) (string, error) {
33+
verbose := os.Getenv("MICROCKS_ACTIONS_VERBOSE") != ""
34+
35+
var b strings.Builder
36+
for _, tc := range r.TestCaseResults {
37+
icon := "✅"
38+
if !tc.Success {
39+
icon = "❌"
40+
}
41+
fmt.Fprintf(&b, "::group::%s %s\n", icon, tc.OperationName)
42+
for _, s := range tc.TestStepResults {
43+
switch {
44+
case !s.Success:
45+
fmt.Fprintf(&b, "::error title=%s::%s\n",
46+
escapeProperty(tc.OperationName), escapeData(stepMessage(s)))
47+
case verbose:
48+
fmt.Fprintf(&b, "::notice title=%s::%s passed\n",
49+
escapeProperty(tc.OperationName), escapeData(s.RequestName))
50+
}
51+
}
52+
fmt.Fprintf(&b, "::endgroup::\n")
53+
}
54+
55+
if r.Success {
56+
fmt.Fprintf(&b, "::notice title=Microcks contract test::All %d operation(s) conform to the contract\n",
57+
len(r.TestCaseResults))
58+
} else {
59+
fmt.Fprintf(&b, "::error title=Microcks contract test::Contract test failed - see annotations above\n")
60+
}
61+
62+
if err := writeStepSummary(r); err != nil {
63+
// The step summary is best-effort; never fail the run over it.
64+
fmt.Fprintf(&b, "::warning::could not write GITHUB_STEP_SUMMARY: %s\n", escapeData(err.Error()))
65+
}
66+
67+
return b.String(), nil
68+
}
69+
70+
// stepMessage returns the failure message, or a sensible default when empty.
71+
func stepMessage(s connectors.TestStepResult) string {
72+
if strings.TrimSpace(s.Message) != "" {
73+
return s.Message
74+
}
75+
if s.RequestName != "" {
76+
return s.RequestName + " did not conform to the contract"
77+
}
78+
return "did not conform to the contract"
79+
}
80+
81+
// writeStepSummary appends a per-operation markdown table to the GitHub job
82+
// summary file, if GITHUB_STEP_SUMMARY is set.
83+
func writeStepSummary(r *connectors.TestResult) error {
84+
path := os.Getenv("GITHUB_STEP_SUMMARY")
85+
if path == "" {
86+
return nil
87+
}
88+
89+
var b strings.Builder
90+
b.WriteString("## Microcks contract test\n\n")
91+
b.WriteString(fmt.Sprintf("**Overall:** %s\n\n", passFail(r.Success)))
92+
b.WriteString("| Operation | Result |\n| --- | --- |\n")
93+
for _, tc := range r.TestCaseResults {
94+
b.WriteString(fmt.Sprintf("| %s | %s |\n", tc.OperationName, passFail(tc.Success)))
95+
}
96+
b.WriteString("\n")
97+
98+
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
99+
if err != nil {
100+
return err
101+
}
102+
defer file.Close()
103+
_, err = file.WriteString(b.String())
104+
return err
105+
}
106+
107+
func passFail(ok bool) string {
108+
if ok {
109+
return "✅ pass"
110+
}
111+
return "❌ fail"
112+
}
113+
114+
// escapeData escapes a GitHub Actions command message per the workflow-command spec.
115+
func escapeData(s string) string {
116+
s = strings.ReplaceAll(s, "%", "%25")
117+
s = strings.ReplaceAll(s, "\r", "%0D")
118+
s = strings.ReplaceAll(s, "\n", "%0A")
119+
return s
120+
}
121+
122+
// escapeProperty escapes a GitHub Actions command property value.
123+
func escapeProperty(s string) string {
124+
s = escapeData(s)
125+
s = strings.ReplaceAll(s, ":", "%3A")
126+
s = strings.ReplaceAll(s, ",", "%2C")
127+
return s
128+
}

pkg/output/json_formatter.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright The Microcks Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package output
17+
18+
import (
19+
"encoding/json"
20+
21+
"github.com/microcks/microcks-cli/pkg/connectors"
22+
)
23+
24+
// JSONFormatter renders the test result as indented JSON.
25+
type JSONFormatter struct{}
26+
27+
func (f *JSONFormatter) Format(r *connectors.TestResult) (string, error) {
28+
b, err := json.MarshalIndent(r, "", " ")
29+
if err != nil {
30+
return "", err
31+
}
32+
return string(b), nil
33+
}

pkg/output/output_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Copyright The Microcks Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package output
17+
18+
import (
19+
"os"
20+
"path/filepath"
21+
"strings"
22+
"testing"
23+
24+
"github.com/microcks/microcks-cli/pkg/connectors"
25+
)
26+
27+
func sampleResult() *connectors.TestResult {
28+
return &connectors.TestResult{
29+
ID: "abc123",
30+
Success: false,
31+
ElapsedTime: 1500,
32+
TestCaseResults: []connectors.TestCaseResult{
33+
{Success: true, OperationName: "GET /products", TestStepResults: []connectors.TestStepResult{
34+
{Success: true, RequestName: "all"},
35+
}},
36+
{Success: false, OperationName: "POST /orders", TestStepResults: []connectors.TestStepResult{
37+
{Success: false, RequestName: "new", Message: "price: expected number\ngot string"},
38+
}},
39+
},
40+
}
41+
}
42+
43+
func TestNewFormatterAndIsValid(t *testing.T) {
44+
for _, f := range []string{"text", "json", "yaml", "github-actions"} {
45+
if !IsValid(f) {
46+
t.Errorf("expected %q to be valid", f)
47+
}
48+
if _, err := NewFormatter(OutputFormat(f)); err != nil {
49+
t.Errorf("NewFormatter(%q) errored: %v", f, err)
50+
}
51+
}
52+
if IsValid("xml") {
53+
t.Error("expected xml to be invalid")
54+
}
55+
if _, err := NewFormatter("xml"); err == nil {
56+
t.Error("expected NewFormatter(xml) to error")
57+
}
58+
}
59+
60+
func TestTextFormatter(t *testing.T) {
61+
out, err := (&TextFormatter{}).Format(sampleResult())
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
for _, want := range []string{"FAILURE", "[PASS] GET /products", "[FAIL] POST /orders", "price: expected number"} {
66+
if !strings.Contains(out, want) {
67+
t.Errorf("text output missing %q\n%s", want, out)
68+
}
69+
}
70+
}
71+
72+
func TestJSONFormatter(t *testing.T) {
73+
out, err := (&JSONFormatter{}).Format(sampleResult())
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
if !strings.Contains(out, `"id": "abc123"`) || !strings.Contains(out, `"operationName": "POST /orders"`) {
78+
t.Errorf("json output unexpected:\n%s", out)
79+
}
80+
}
81+
82+
func TestYAMLFormatter(t *testing.T) {
83+
out, err := (&YAMLFormatter{}).Format(sampleResult())
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
// Keys should be camelCase (json field names), not Go field names.
88+
if !strings.Contains(out, "id: abc123") || !strings.Contains(out, "testCaseResults:") {
89+
t.Errorf("yaml output unexpected:\n%s", out)
90+
}
91+
}
92+
93+
func TestGitHubActionsFormatter(t *testing.T) {
94+
out, err := (&GitHubActionsFormatter{}).Format(sampleResult())
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
checks := []string{
99+
"::group::",
100+
"GET /products",
101+
"::error title=POST /orders::",
102+
"price: expected number%0Agot string", // newline escaped in data; colon only escaped in properties
103+
"::endgroup::",
104+
"::error title=Microcks contract test::",
105+
}
106+
for _, want := range checks {
107+
if !strings.Contains(out, want) {
108+
t.Errorf("github-actions output missing %q\n%s", want, out)
109+
}
110+
}
111+
// No ::notice:: for the passing op unless verbose.
112+
if strings.Contains(out, "::notice title=GET /products::") {
113+
t.Errorf("unexpected ::notice:: without verbose:\n%s", out)
114+
}
115+
}
116+
117+
func TestGitHubActionsVerbose(t *testing.T) {
118+
t.Setenv("MICROCKS_ACTIONS_VERBOSE", "1")
119+
out, err := (&GitHubActionsFormatter{}).Format(sampleResult())
120+
if err != nil {
121+
t.Fatal(err)
122+
}
123+
if !strings.Contains(out, "::notice title=GET /products::") {
124+
t.Errorf("expected ::notice:: for passing op in verbose mode:\n%s", out)
125+
}
126+
}
127+
128+
func TestGitHubActionsStepSummary(t *testing.T) {
129+
summary := filepath.Join(t.TempDir(), "summary.md")
130+
t.Setenv("GITHUB_STEP_SUMMARY", summary)
131+
132+
if _, err := (&GitHubActionsFormatter{}).Format(sampleResult()); err != nil {
133+
t.Fatal(err)
134+
}
135+
136+
data, err := os.ReadFile(summary)
137+
if err != nil {
138+
t.Fatalf("step summary not written: %v", err)
139+
}
140+
s := string(data)
141+
for _, want := range []string{"## Microcks contract test", "| Operation | Result |", "GET /products", "POST /orders"} {
142+
if !strings.Contains(s, want) {
143+
t.Errorf("step summary missing %q\n%s", want, s)
144+
}
145+
}
146+
}
147+
148+
func TestEscaping(t *testing.T) {
149+
if got := escapeData("a%b\nc\rd"); got != "a%25b%0Ac%0Dd" {
150+
t.Errorf("escapeData = %q", got)
151+
}
152+
if got := escapeProperty("a:b,c"); got != "a%3Ab%2Cc" {
153+
t.Errorf("escapeProperty = %q", got)
154+
}
155+
}

0 commit comments

Comments
 (0)