Skip to content

Commit fcc2ce9

Browse files
committed
chore(cmd): refactored main program
Another baby step forward: * refactored error and flags handling in main program * this improves testability, so we add a unit test for main There is still some room for improvement (coverage, I/O injection for better control in a test environment). Essential options in main are covered by tests. Signed-off-by: Frédéric BIDON <fredbi@yahoo.com>
1 parent 3f1c615 commit fcc2ce9

3 files changed

Lines changed: 263 additions & 64 deletions

File tree

cmd/go-ctrf-json-reporter/main.go

Lines changed: 98 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,92 @@
11
package main
22

33
import (
4+
"errors"
45
"flag"
56
"fmt"
7+
"io"
8+
"log"
69
"os"
710

811
"github.com/ctrf-io/go-ctrf-json-reporter/ctrf"
912
"github.com/ctrf-io/go-ctrf-json-reporter/reporter"
1013
)
1114

12-
var buildFailed bool
15+
// commandContext holds the global context of the command.
16+
//
17+
// For now, this boils down to just CLI flags and default stdin/stdout.
18+
type commandContext struct {
19+
commandFlags
1320

14-
func main() {
15-
var outputFile string
16-
var verbose bool
17-
var quiet bool
18-
flag.BoolVar(&verbose, "verbose", false, "Enable verbose output")
19-
flag.BoolVar(&verbose, "v", false, "Enable verbose output (shorthand)")
20-
flag.BoolVar(&quiet, "quiet", false, "Disable all log output")
21-
flag.BoolVar(&quiet, "q", false, "Disable all log output (shorthand)")
22-
flag.StringVar(&outputFile, "output", "ctrf-report.json", "The output file for the test results")
23-
flag.StringVar(&outputFile, "o", "ctrf-report.json", "The output file for the test results (shorthand)")
24-
25-
var tempAppName, tempAppVersion, tempOSPlatform, tempOSRelease, tempOSVersion, tempBuildName, tempBuildNumber string
26-
27-
flag.StringVar(&tempAppName, "appName", "", "The name of the application being tested.")
28-
flag.StringVar(&tempAppVersion, "appVersion", "", "The version of the application being tested.")
29-
flag.StringVar(&tempOSPlatform, "osPlatform", "", "The operating system platform (e.g., Windows, Linux).")
30-
flag.StringVar(&tempOSRelease, "osRelease", "", "The release version of the operating system.")
31-
flag.StringVar(&tempOSVersion, "osVersion", "", "The version number of the operating system.")
32-
flag.StringVar(&tempBuildName, "buildName", "", "The name of the build (e.g., feature branch name).")
33-
flag.StringVar(&tempBuildNumber, "buildNumber", "", "The build number or identifier.")
21+
reader io.Reader
22+
writer io.Writer // makes it easier to test execute() independently
23+
}
3424

35-
flag.Parse()
25+
// commandFlags stores parsed command line flags.
26+
type commandFlags struct {
27+
outputFile string
28+
verbose bool
29+
quiet bool
30+
appName string
31+
appVersion string
32+
oSPlatform string
33+
oSRelease string
34+
oSVersion string
35+
buildName string
36+
buildNumber string
37+
}
3638

37-
var env *ctrf.Environment
39+
// NOTE(fredbi)
40+
// Sugggestions (future enhancements):
41+
//
42+
// - outputFile could be provided as an io.Writer: this makes the package easier to test
43+
// - outputFile is currently required but could default to stdout
44+
// - outputFile set to "-" would also mean stdout (common with unix-like tools)
45+
//
46+
// A similar approach could work for stdin, which is currently not an option, when CLI args (not flags)
47+
// could represent the input files (e.g. could be useful when used with xargs for example).
3848

39-
if tempAppName != "" || tempAppVersion != "" || tempOSPlatform != "" ||
40-
tempOSRelease != "" || tempOSVersion != "" || tempBuildName != "" || tempBuildNumber != "" {
41-
env = &ctrf.Environment{}
49+
func main() {
50+
var ctx commandContext
51+
ctx.reader = os.Stdin
52+
ctx.writer = os.Stdout
4253

43-
if tempAppName != "" {
44-
env.AppName = tempAppName
45-
}
46-
if tempAppVersion != "" {
47-
env.AppVersion = tempAppVersion
48-
}
49-
if tempOSPlatform != "" {
50-
env.OSPlatform = tempOSPlatform
51-
}
52-
if tempOSRelease != "" {
53-
env.OSRelease = tempOSRelease
54-
}
55-
if tempOSVersion != "" {
56-
env.OSVersion = tempOSVersion
57-
}
58-
if tempBuildName != "" {
59-
env.BuildName = tempBuildName
60-
}
61-
if tempBuildNumber != "" {
62-
env.BuildNumber = tempBuildNumber
54+
registerFlags(&ctx.commandFlags)
55+
56+
if err := execute(&ctx); err != nil {
57+
if ctx.quiet {
58+
os.Exit(1) // exit silently
6359
}
60+
61+
log.Fatalf("%v", err)
6462
}
63+
}
6564

66-
effectiveVerbose := verbose && !quiet
65+
func execute(cmd *commandContext) error {
66+
env := ctrfEnvFromFlags(cmd)
67+
effectiveVerbose := cmd.verbose && !cmd.quiet
6768

68-
report, err := reporter.ParseTestResults(os.Stdin, effectiveVerbose, env)
69+
report, err := reporter.ParseTestResults(cmd.reader, effectiveVerbose, env)
6970
if err != nil {
70-
if !quiet {
71-
_, _ = fmt.Fprintf(os.Stderr, "Error parsing test results: %v\n", err)
72-
}
73-
os.Exit(1)
71+
return fmt.Errorf("error parsing test results: %w", err)
7472
}
7573

76-
err = reporter.WriteReportToFile(outputFile, report)
74+
err = reporter.WriteReportToFile(cmd.outputFile, report)
7775
if err != nil {
78-
if !quiet {
79-
_, _ = fmt.Fprintf(os.Stderr, "Error writing the report to file: %v\n", err)
80-
}
81-
os.Exit(1)
76+
return fmt.Errorf("error writing the report to file: %w", err)
8277
}
8378

84-
if !verbose && !quiet {
79+
if !cmd.verbose && !cmd.quiet { // when verbose is enabled, we already got some output
8580
buildOutput := reporter.GetBuildOutput()
86-
fmt.Println(buildOutput)
81+
fmt.Fprint(cmd.writer, buildOutput)
8782
}
8883

84+
var buildFailed bool
8985
if report.Results.Extra != nil {
9086
extraMap, isMap := report.Results.Extra.(map[string]any)
9187
if !isMap {
9288
err = fmt.Errorf("expected a map, but got %T instead", report.Results.Extra)
93-
if !quiet {
94-
_, _ = fmt.Fprintf(os.Stderr, "Error writing the report to file: %v\n", err)
95-
}
96-
os.Exit(1)
89+
return fmt.Errorf("error writing the report to file: %w", err)
9790
}
9891
if _, ok := extraMap["buildFail"]; ok {
9992
buildFailed = true
@@ -108,6 +101,47 @@ func main() {
108101
}
109102

110103
if buildFailed {
111-
os.Exit(1)
104+
return errors.New("build failed")
105+
}
106+
107+
return nil
108+
}
109+
110+
func registerFlags(flags *commandFlags) {
111+
flag.BoolVar(&flags.verbose, "verbose", false, "Enable verbose output")
112+
flag.BoolVar(&flags.verbose, "v", false, "Enable verbose output (shorthand)")
113+
flag.BoolVar(&flags.quiet, "quiet", false, "Disable all log output")
114+
flag.BoolVar(&flags.quiet, "q", false, "Disable all log output (shorthand)")
115+
116+
flag.StringVar(&flags.outputFile, "output", "ctrf-report.json", "The output file for the test results")
117+
flag.StringVar(&flags.outputFile, "o", "ctrf-report.json", "The output file for the test results (shorthand)")
118+
119+
flag.StringVar(&flags.appName, "appName", "", "The name of the application being tested.")
120+
flag.StringVar(&flags.appVersion, "appVersion", "", "The version of the application being tested.")
121+
flag.StringVar(&flags.oSPlatform, "osPlatform", "", "The operating system platform (e.g., Windows, Linux).")
122+
flag.StringVar(&flags.oSRelease, "osRelease", "", "The release version of the operating system.")
123+
flag.StringVar(&flags.oSVersion, "osVersion", "", "The version number of the operating system.")
124+
flag.StringVar(&flags.buildName, "buildName", "", "The name of the build (e.g., feature branch name).")
125+
flag.StringVar(&flags.buildNumber, "buildNumber", "", "The build number or identifier.")
126+
127+
// parsing errors result in os.Exit(1). Perhaps we should call the flagset version and capture the error instead.
128+
flag.Parse()
129+
}
130+
131+
func ctrfEnvFromFlags(cmd *commandContext) *ctrf.Environment {
132+
if cmd.appName == "" && cmd.appVersion == "" && cmd.oSPlatform == "" &&
133+
cmd.oSRelease == "" && cmd.oSVersion == "" && cmd.buildName == "" &&
134+
cmd.buildNumber == "" {
135+
return nil
136+
}
137+
138+
return &ctrf.Environment{
139+
AppName: cmd.appName,
140+
AppVersion: cmd.appVersion,
141+
OSPlatform: cmd.oSPlatform,
142+
OSRelease: cmd.oSRelease,
143+
OSVersion: cmd.oSVersion,
144+
BuildName: cmd.buildName,
145+
BuildNumber: cmd.buildNumber,
112146
}
113147
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestExecute(t *testing.T) {
15+
t.Parallel()
16+
tempDir := t.TempDir() // auto-cleanup when the test tears down
17+
18+
t.Run("with no flags", func(t *testing.T) {
19+
t.Run("should error because no output file is provided", func(t *testing.T) {
20+
ctx := freshContext(nil, nil)
21+
22+
err := execute(ctx)
23+
require.Error(t, err)
24+
require.ErrorContains(t, err, "no such file")
25+
})
26+
27+
t.Run("should error because no report data is provided", func(t *testing.T) {
28+
ctx := freshContext(nil, nil)
29+
output := filepath.Join(tempDir, "test-report-ko.json")
30+
ctx.outputFile = output
31+
32+
err := execute(ctx)
33+
require.Error(t, err)
34+
require.ErrorContains(t, err, "report is invalid")
35+
})
36+
37+
t.Run("should parse go test json data", func(t *testing.T) {
38+
fixture, err := os.Open(filepath.Join("testdata", "test.json"))
39+
require.NoError(t, err)
40+
41+
var stdout bytes.Buffer
42+
ctx := freshContext(&stdout, fixture)
43+
output := filepath.Join(tempDir, "test-report-ok.json")
44+
ctx.outputFile = output
45+
46+
err = execute(ctx)
47+
require.NoError(t, err)
48+
require.FileExists(t, output)
49+
50+
t.Run("stdout should contain some go test output", func(t *testing.T) {
51+
require.Contains(t, stdout.String(), "=== RUN TestExecute")
52+
})
53+
54+
t.Run("report file should be valid JSON", func(t *testing.T) {
55+
buf, err := os.ReadFile(output)
56+
require.NoError(t, err)
57+
58+
jazon := make(map[string]any)
59+
require.NoError(t, json.Unmarshal(buf, &jazon))
60+
require.Contains(t, jazon, "reportFormat")
61+
})
62+
})
63+
})
64+
65+
t.Run("with appName flag", func(t *testing.T) {
66+
t.Run("should parse go test json data", func(t *testing.T) {
67+
fixture, err := os.Open(filepath.Join("testdata", "test.json"))
68+
require.NoError(t, err)
69+
output := filepath.Join(tempDir, "test-report-app.json")
70+
71+
ctx := freshContext(nil, fixture)
72+
ctx.appName = "my-app"
73+
ctx.outputFile = output
74+
75+
err = execute(ctx)
76+
require.NoError(t, err)
77+
require.FileExists(t, output)
78+
79+
t.Run("report file should be valid JSON", func(t *testing.T) {
80+
buf, err := os.ReadFile(output)
81+
require.NoError(t, err)
82+
83+
jazon := make(map[string]any)
84+
require.NoError(t, json.Unmarshal(buf, &jazon))
85+
raw, ok := jazon["results"]
86+
require.True(t, ok)
87+
results, ok := raw.(map[string]any)
88+
require.True(t, ok)
89+
raw, ok = results["environment"]
90+
require.True(t, ok)
91+
environment, ok := raw.(map[string]any)
92+
require.True(t, ok)
93+
raw, ok = environment["appName"]
94+
require.True(t, ok)
95+
appName, ok := raw.(string)
96+
require.True(t, ok)
97+
require.Equal(t, "my-app", appName)
98+
})
99+
})
100+
})
101+
102+
t.Run("with verbose flag", func(t *testing.T) {
103+
t.Run("should parse go test json data", func(t *testing.T) {
104+
fixture, err := os.Open(filepath.Join("testdata", "test.json"))
105+
require.NoError(t, err)
106+
output := filepath.Join(tempDir, "test-report-app.json")
107+
108+
ctx := freshContext(nil, fixture)
109+
ctx.verbose = true
110+
ctx.outputFile = output
111+
112+
err = execute(ctx)
113+
require.NoError(t, err)
114+
require.FileExists(t, output)
115+
116+
t.Run("report file should be valid JSON, without environment", func(t *testing.T) {
117+
buf, err := os.ReadFile(output)
118+
require.NoError(t, err)
119+
120+
jazon := make(map[string]any)
121+
require.NoError(t, json.Unmarshal(buf, &jazon))
122+
raw, ok := jazon["results"]
123+
require.True(t, ok)
124+
results, ok := raw.(map[string]any)
125+
require.True(t, ok)
126+
_, ok = results["environment"]
127+
require.False(t, ok)
128+
})
129+
})
130+
})
131+
}
132+
133+
func freshContext(writer io.Writer, reader io.Reader) *commandContext {
134+
if reader == nil {
135+
reader = os.Stdin
136+
}
137+
if writer == nil {
138+
writer = new(bytes.Buffer)
139+
}
140+
141+
return &commandContext{
142+
writer: writer,
143+
reader: reader,
144+
}
145+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{"Time":"2025-11-24T23:37:16.670113757+01:00","Action":"start","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter"}
2+
{"Time":"2025-11-24T23:37:16.673715443+01:00","Action":"run","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute"}
3+
{"Time":"2025-11-24T23:37:16.673781823+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute","Output":"=== RUN TestExecute\n"}
4+
{"Time":"2025-11-24T23:37:16.673799437+01:00","Action":"run","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags"}
5+
{"Time":"2025-11-24T23:37:16.673806229+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags","Output":"=== RUN TestExecute/with_no_flags\n"}
6+
{"Time":"2025-11-24T23:37:16.673819864+01:00","Action":"run","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_output_file_is_provided"}
7+
{"Time":"2025-11-24T23:37:16.673827269+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_output_file_is_provided","Output":"=== RUN TestExecute/with_no_flags/should_error_because_no_output_file_is_provided\n"}
8+
{"Time":"2025-11-24T23:37:16.674023516+01:00","Action":"run","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_report_data_is_provided"}
9+
{"Time":"2025-11-24T23:37:16.674036664+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_report_data_is_provided","Output":"=== RUN TestExecute/with_no_flags/should_error_because_no_report_data_is_provided\n"}
10+
{"Time":"2025-11-24T23:37:16.674482902+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute","Output":"--- PASS: TestExecute (0.00s)\n"}
11+
{"Time":"2025-11-24T23:37:16.674498998+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags","Output":" --- PASS: TestExecute/with_no_flags (0.00s)\n"}
12+
{"Time":"2025-11-24T23:37:16.674509071+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_output_file_is_provided","Output":" --- PASS: TestExecute/with_no_flags/should_error_because_no_output_file_is_provided (0.00s)\n"}
13+
{"Time":"2025-11-24T23:37:16.674517604+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_output_file_is_provided","Elapsed":0}
14+
{"Time":"2025-11-24T23:37:16.674526483+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_report_data_is_provided","Output":" --- PASS: TestExecute/with_no_flags/should_error_because_no_report_data_is_provided (0.00s)\n"}
15+
{"Time":"2025-11-24T23:37:16.674533735+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags/should_error_because_no_report_data_is_provided","Elapsed":0}
16+
{"Time":"2025-11-24T23:37:16.674540707+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute/with_no_flags","Elapsed":0}
17+
{"Time":"2025-11-24T23:37:16.674549626+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Test":"TestExecute","Elapsed":0}
18+
{"Time":"2025-11-24T23:37:16.674556683+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Output":"PASS\n"}
19+
{"Time":"2025-11-24T23:37:16.675106679+01:00","Action":"output","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Output":"ok \tgithub.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter\t0.005s\n"}
20+
{"Time":"2025-11-24T23:37:16.67514418+01:00","Action":"pass","Package":"github.com/ctrf-io/go-ctrf-json-reporter/cmd/go-ctrf-json-reporter","Elapsed":0.005}

0 commit comments

Comments
 (0)